@homie-rocks/studio 0.27.0 → 0.29.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 +59 -1
- package/README.md +40 -2
- package/bin/homie-studio.mjs +24 -0
- package/chat/CHAT.md +5 -1
- package/chat/LOUNGE.md +114 -0
- package/chat/OWNERS.md +6 -2
- package/lib/build.mjs +5 -0
- package/lib/feedback.mjs +181 -0
- package/lib/lounge-cli.mjs +70 -0
- package/lib/mcp-tools.mjs +140 -1
- package/lib/mcp.mjs +6 -2
- package/lib/redact.mjs +99 -0
- package/lib/scaffold.mjs +25 -2
- package/lib/template-history.json +80 -0
- package/mcp/ui/card.css +8 -0
- package/mcp/ui/feedback.js +112 -0
- package/mcp/ui/setup.js +2 -0
- package/netplay/NETPLAY.md +11 -1
- package/package.json +1 -1
- package/site/SITE.md +13 -0
- package/worker/chat-store.mjs +2 -1
- package/worker/chat.mjs +13 -1
- package/worker/discover.mjs +5 -0
- package/worker/index.mjs +38 -3
- package/worker/lounge-page.mjs +623 -0
- package/worker/lounge-store.mjs +281 -0
- package/worker/lounge.mjs +547 -0
- package/worker/office-page.mjs +99 -0
- package/worker/office.mjs +19 -1
- package/worker/players.mjs +5 -0
- package/worker/room.mjs +116 -12
- package/worker/site.mjs +6 -1
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio lounge` — the studio's Lounge from the studio folder (worker/lounge.mjs says what it is; chat/LOUNGE.md
|
|
3
|
+
* the whole of it). The office key it uses is `office`'s: minted with the studio's own Cloudflare login, ten minutes
|
|
4
|
+
* long, dropped as soon as the answer is in.
|
|
5
|
+
*
|
|
6
|
+
* homie-studio lounge its rules, play nights, moderators, last lines and reports
|
|
7
|
+
* homie-studio lounge night "<title>" --at <time> [--minutes 120] [--game <id>|<https link>] [--note "…"]
|
|
8
|
+
* a play night; --at is an ISO time with its zone, or UTC (…Z)
|
|
9
|
+
* homie-studio lounge night remove <id>
|
|
10
|
+
* homie-studio lounge history <days> keep what is said for that many days (0 keeps nothing)
|
|
11
|
+
* homie-studio lounge rules [--slow <s>] [--who anyone|signed-in] [--mode …] [--hub on|off] [--ai on|off] | --reset
|
|
12
|
+
* homie-studio lounge mod <player id> [--remove] a moderator: Remove, Mute, Kick (an hour at most), slow mode
|
|
13
|
+
* homie-studio lounge remove <line id>|--all
|
|
14
|
+
*
|
|
15
|
+
* Keeping more (a longer history), opening chat up, a new moderator, a mute or a kick are only ASKED for here: the answer
|
|
16
|
+
* is the owner's one-tap link, as `office kick` gives. A play night, slow mode, taking a message down and keeping less
|
|
17
|
+
* happen at once.
|
|
18
|
+
*/
|
|
19
|
+
import { askedFor, withKey } from './office.mjs';
|
|
20
|
+
import { chatFields } from './chat-cli.mjs';
|
|
21
|
+
|
|
22
|
+
export async function loungeShow(root, { url } = {}) {
|
|
23
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/lounge'));
|
|
24
|
+
if (!r.ok) return { ok: false, command: 'lounge', why: r.why ?? r.message ?? 'the office did not answer' };
|
|
25
|
+
return { command: 'lounge', ...r, ok: true };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function loungeNight(root, args, flags, { url } = {}) {
|
|
29
|
+
if (args[0] === 'remove') {
|
|
30
|
+
if (!/^pn_[a-f0-9]{12}$/.test(String(args[1] ?? ''))) return { ok: false, command: 'lounge night', why: 'usage: homie-studio lounge night remove <id> (the ids are in `homie-studio lounge`)' };
|
|
31
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/lounge/night', { remove: args[1] }));
|
|
32
|
+
return r.ok ? { ok: true, command: 'lounge night', message: 'Play night removed.' } : { ok: false, command: 'lounge night', why: r.message ?? r.why };
|
|
33
|
+
}
|
|
34
|
+
const title = args.join(' ').trim();
|
|
35
|
+
if (!title || !flags.get('at')) return { ok: false, command: 'lounge night', why: 'usage: homie-studio lounge night "<title>" --at 2026-10-09T19:00:00-07:00 [--minutes 120] [--game <id>] [--note "…"]' };
|
|
36
|
+
const body = { title, at: String(flags.get('at')), ...(flags.has('minutes') ? { minutes: Number(flags.get('minutes')) } : {}), ...(flags.has('game') ? { game: String(flags.get('game')) } : {}), ...(flags.has('note') ? { note: String(flags.get('note')) } : {}) };
|
|
37
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/lounge/night', body));
|
|
38
|
+
return r.ok ? { ok: true, command: 'lounge night', id: r.id, message: `Play night added (${r.id}): it shows in the Lounge in everyone's own time.` } : { ok: false, command: 'lounge night', why: r.message ?? r.why };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function loungeRulesSet(root, flags, { url, history = undefined } = {}) {
|
|
42
|
+
const body = flags.has('reset') ? { reset: true } : { ...chatFields(flags), ...(history !== undefined ? { history: Math.floor(Number(history)) } : flags.has('history') ? { history: Math.floor(Number(flags.get('history'))) } : {}) };
|
|
43
|
+
if (!body.reset && !Object.keys(body).length) return { ok: false, command: 'lounge rules', why: 'say what to change: --history <days>, --slow, --who, --mode, --hub, --ai, --block, --allow; or --reset' };
|
|
44
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/lounge/rules', body));
|
|
45
|
+
return askedFor(root, url, r, 'lounge rules');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function loungeMod(root, player, { url, remove = false } = {}) {
|
|
49
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(String(player ?? ''))) return { ok: false, command: 'lounge mod', why: 'usage: homie-studio lounge mod <player id> [--remove] (`homie-studio lounge` shows the ids of signed-in senders)' };
|
|
50
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/lounge/mod', { player, ...(remove ? { remove: true } : {}) }));
|
|
51
|
+
return askedFor(root, url, r, 'lounge mod');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function loungeRemove(root, id, { url, all = false } = {}) {
|
|
55
|
+
if (!id && !all) return { ok: false, command: 'lounge remove', why: 'usage: homie-studio lounge remove <line id> | --all' };
|
|
56
|
+
const r = await withKey(root, url, (call) => call('/_studio/api/lounge/remove', all ? { all: true } : { id }));
|
|
57
|
+
return r.ok ? { ok: true, command: 'lounge remove', message: `Took ${r.removed ?? 0} message${r.removed === 1 ? '' : 's'} down, on every screen.` } : { ok: false, command: 'lounge remove', why: r.message ?? r.why };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function loungeLines(r) {
|
|
61
|
+
const L = r.lounge;
|
|
62
|
+
if (!L) return ['This studio has no Lounge: add "lounge": true to studio.json and deploy.'];
|
|
63
|
+
const c = L.rules ?? {};
|
|
64
|
+
const out = [`${L.name} (${L.page}): ${c.mode}${c.mode === 'text' ? `, typing ${c.who}` : ''}, emoji ${c.react}, slow ${c.slow} s, ${c.history ? `kept ${c.history} days` : 'nothing kept'}${c.hub ? ', shown on homie.rocks' : ''}${L.kids ? ', kids' : ''} · rules from ${L.from === 'office' ? 'the office' : 'the defaults'} · ${L.here} here now`];
|
|
65
|
+
for (const n of L.nights ?? []) out.push(` night ${n.id}: ${n.title}, ${n.at} for ${n.minutes} min${n.game?.name ? ` (${n.game.name})` : ''}`);
|
|
66
|
+
for (const m of L.mods ?? []) out.push(` moderator ${m.player}: ${m.name ?? '?'}`);
|
|
67
|
+
for (const l of (L.lines ?? []).filter((x) => x.kind !== 'react').slice(-12)) out.push(` ${l.id} ${l.by === 'studio' ? 'Studio' : l.name}${l.player ? ` [${l.player}]` : ''}: ${l.card ? `card: ${l.card.title} by ${l.card.studio}` : l.text}`);
|
|
68
|
+
for (const p of L.reports ?? []) out.push(` report ${p.id}: "${p.text}" by ${p.name} (${p.reasonText})`);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
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,9 +45,12 @@ 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
|
|
|
52
|
+
/** Homie's own updates by email (0.29.0): the sign-up page on homie.rocks, double opt-in, for the first-run card. */
|
|
53
|
+
export const HOMIE_UPDATES = 'https://homie.rocks/updates/?from=plugin';
|
|
50
54
|
const inside = (base, p) => p === base || p.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
|
|
51
55
|
const realOr = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
52
56
|
|
|
@@ -72,6 +76,10 @@ export class StudioContext {
|
|
|
72
76
|
this.notes = new Map();
|
|
73
77
|
this.runs = new Map();
|
|
74
78
|
this.status = null;
|
|
79
|
+
// Tell Homie: this session's drafts (what the person was shown), and whether an offered note was answered.
|
|
80
|
+
this.feedback = { drafts: new Map(), sent: 0, offers: 0, offeredDone: false, declined: false };
|
|
81
|
+
// The MCP client (initialize's clientInfo), which a note names as an app and nothing more.
|
|
82
|
+
this.client = null;
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
note(root) { if (!this.notes.has(root)) this.notes.set(root, {}); return this.notes.get(root); }
|
|
@@ -208,6 +216,9 @@ async function setupCard(ctx, root, { fresh = false, made = null, install = null
|
|
|
208
216
|
studios, checklist: steps,
|
|
209
217
|
status: { rows: status.rows.map(({ parts, ...r }) => r), features: status.features, next: status.next, meanwhile: status.meanwhile, blocking: status.blocking },
|
|
210
218
|
install: job ? jobView(job) : null, made,
|
|
219
|
+
// Homie's own updates by email (0.29.0): a link the person opens and signs up at themselves (double opt-in on
|
|
220
|
+
// homie.rocks). The card never asks for an address, and an agent never types one in for the person.
|
|
221
|
+
updates: HOMIE_UPDATES,
|
|
211
222
|
};
|
|
212
223
|
const text = [
|
|
213
224
|
current ? `Studio: ${current.name} (${root})${current.installed ? '' : job ? ' (installing its toolkit)' : ''}` : `No studio yet${ctx.studiosDir ? ` in ${ctx.studiosDir}` : ''}.`,
|
|
@@ -217,6 +228,7 @@ async function setupCard(ctx, root, { fresh = false, made = null, install = null
|
|
|
217
228
|
'',
|
|
218
229
|
formatStatus(status),
|
|
219
230
|
'',
|
|
231
|
+
`Homie updates by email (optional): ${HOMIE_UPDATES} (the person signs up there themselves; never type an address in for them).`,
|
|
220
232
|
'The card on screen shows this; it follows the install by itself.',
|
|
221
233
|
].join('\n');
|
|
222
234
|
return ok(text, data);
|
|
@@ -580,6 +592,110 @@ async function githubLogin(ctx) {
|
|
|
580
592
|
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
593
|
}
|
|
582
594
|
|
|
595
|
+
/* ------------------------------------------------------------------ Tell Homie (lib/feedback.mjs) */
|
|
596
|
+
|
|
597
|
+
/** The plugin's version, from the skills folder this server was given (the plugin's own manifest beside it). */
|
|
598
|
+
function pluginVersion(ctx) {
|
|
599
|
+
if (!ctx.skillsDir) return null;
|
|
600
|
+
for (const f of [join(ctx.skillsDir, '..', '.claude-plugin', 'plugin.json'), join(ctx.skillsDir, '..', 'plugin.json')]) {
|
|
601
|
+
try { const v = JSON.parse(readFileSync(f, 'utf8')).version; if (typeof v === 'string') return v; } catch { /* not here */ }
|
|
602
|
+
}
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** 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. */
|
|
607
|
+
function feedbackFacts(ctx, studioArg) {
|
|
608
|
+
let root = null;
|
|
609
|
+
try { root = ctx.root(studioArg, { need: false }); } catch { root = null; }
|
|
610
|
+
let studio = null;
|
|
611
|
+
try { studio = root ? readStudio(root) : null; } catch { studio = null; }
|
|
612
|
+
const pinned = (() => {
|
|
613
|
+
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; }
|
|
614
|
+
})();
|
|
615
|
+
const fromStudio = String(studio?.homie?.directory ?? '');
|
|
616
|
+
// Where notes go: the directory this server was started with (--homie, the operator's own word), else the studio's,
|
|
617
|
+
// and a studio.json (which may be a clone of somebody else's) can only name Homie's own directory or this computer.
|
|
618
|
+
const loopback = (u) => u.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(u.hostname);
|
|
619
|
+
const started = (d) => { try { const u = new URL(d); return u.protocol === 'https:' || loopback(u); } catch { return false; } };
|
|
620
|
+
const homies = (d) => { try { const u = new URL(d); return (u.protocol === 'https:' && /(^|\.)homie\.rocks$/.test(u.hostname)) || loopback(u); } catch { return false; } };
|
|
621
|
+
const directory = (ctx.directory && started(ctx.directory) ? ctx.directory : null) ?? (fromStudio && homies(fromStudio) ? fromStudio : null) ?? 'https://homie.rocks';
|
|
622
|
+
return { studioVersion: pinned ?? STUDIO_VERSION, pluginVersion: pluginVersion(ctx), app: appOf(ctx.client), directory: directory.replace(/\/+$/, ''), studio: root ? basename(root) : null };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const hostOfUrl = (u) => { try { return new URL(u).host; } catch { return 'homie.rocks'; } };
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* homie_feedback: draft (shows the note exactly as it would go, sends nothing), send (only the note a draft showed,
|
|
629
|
+
* after the person's yes), decline (the person said no: nothing is sent, and no note is offered again this session).
|
|
630
|
+
*
|
|
631
|
+
* 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
|
|
632
|
+
* app may start a new server between the draft and the yes (a resumed session, a restart), and the remote Homie MCP
|
|
633
|
+
* keeps no session at all. What this process remembers is only a convenience (a send that names just its draft) and
|
|
634
|
+
* the once-a-session rule.
|
|
635
|
+
*/
|
|
636
|
+
async function feedbackTool(ctx, a) {
|
|
637
|
+
const action = ['draft', 'send', 'decline'].includes(a.action) ? a.action : 'draft';
|
|
638
|
+
const session = ctx.feedback;
|
|
639
|
+
const facts = feedbackFacts(ctx, a.studio);
|
|
640
|
+
const to = hostOfUrl(facts.directory);
|
|
641
|
+
const view = (state, extra = {}) => ({ kind: 'feedback', state, to, ...extra });
|
|
642
|
+
if (action === 'decline') {
|
|
643
|
+
const kept = a.draft ? session.drafts.get(String(a.draft)) : null;
|
|
644
|
+
session.declined = true;
|
|
645
|
+
if (kept?.note.offered || !kept) session.offeredDone = true;
|
|
646
|
+
if (kept) session.drafts.delete(String(a.draft));
|
|
647
|
+
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.',
|
|
648
|
+
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.' }));
|
|
649
|
+
}
|
|
650
|
+
// A send of a draft this process showed sends THAT note (its facts are the ones the person saw, whichever studio is
|
|
651
|
+
// 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.
|
|
652
|
+
const known = a.draft ? session.drafts.get(String(a.draft)) : null;
|
|
653
|
+
const worded = !(a.text === undefined || a.text === null || a.text === '');
|
|
654
|
+
let c;
|
|
655
|
+
if (action === 'send' && known) {
|
|
656
|
+
const said = worded ? cleanNote({ ...known.note, kind: a.kind ?? known.note.kind, text: a.text }) : null;
|
|
657
|
+
if (said && (!said.ok || said.note.text !== known.note.text || said.note.kind !== known.note.kind)) {
|
|
658
|
+
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' }));
|
|
659
|
+
}
|
|
660
|
+
c = { ok: true, note: known.note, taken: known.taken };
|
|
661
|
+
} else {
|
|
662
|
+
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 });
|
|
663
|
+
}
|
|
664
|
+
if (!c.ok) return fail(`Not sent: ${c.why}.`, view('invalid', { why: c.why }));
|
|
665
|
+
const id = await draftId(c.note);
|
|
666
|
+
const shown = { draft: id, note: c.note, taken: c.taken, with: withLine(c.note), studio: facts.studio };
|
|
667
|
+
if (action === 'draft') {
|
|
668
|
+
if (c.note.offered && (session.declined || session.offeredDone)) {
|
|
669
|
+
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' }));
|
|
670
|
+
}
|
|
671
|
+
// An offer nobody answered may be reworded a few times (the person asked for a change); it is not asked over and over.
|
|
672
|
+
if (c.note.offered && !session.drafts.has(id)) {
|
|
673
|
+
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' }));
|
|
674
|
+
session.offers += 1;
|
|
675
|
+
}
|
|
676
|
+
session.drafts.set(id, { note: c.note, taken: c.taken, at: Date.now() });
|
|
677
|
+
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.`;
|
|
678
|
+
return ok([
|
|
679
|
+
'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:',
|
|
680
|
+
'',
|
|
681
|
+
noteBlock(c.note, c.taken),
|
|
682
|
+
'',
|
|
683
|
+
`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}).`,
|
|
684
|
+
].join('\n'), view('draft', { ...shown, next }));
|
|
685
|
+
}
|
|
686
|
+
// send
|
|
687
|
+
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' }));
|
|
688
|
+
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' }));
|
|
689
|
+
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' }));
|
|
690
|
+
const r = await sendNote(c.note, { directory: facts.directory, source: 'plugin', consent: ['card', 'dialog'].includes(a.by) ? a.by : 'chat' });
|
|
691
|
+
if (!r.ok) return fail(`Not sent: ${r.why}`, view('failed', { ...shown, why: r.why }));
|
|
692
|
+
session.sent += 1;
|
|
693
|
+
session.drafts.delete(id);
|
|
694
|
+
if (c.note.offered) session.offeredDone = true;
|
|
695
|
+
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.`,
|
|
696
|
+
view('sent', { ...shown, reference: r.reference, next: 'Sent. Thank the person in one line, and do not offer another note in this session.' }));
|
|
697
|
+
}
|
|
698
|
+
|
|
583
699
|
/* ------------------------------------------------------------------ guides (the plugin's skills) */
|
|
584
700
|
|
|
585
701
|
function guideTopics(ctx) {
|
|
@@ -1120,6 +1236,29 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1120
1236
|
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
1237
|
},
|
|
1122
1238
|
},
|
|
1239
|
+
{
|
|
1240
|
+
name: 'homie_feedback', title: 'Tell Homie',
|
|
1241
|
+
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.',
|
|
1242
|
+
inputSchema: {
|
|
1243
|
+
type: 'object',
|
|
1244
|
+
properties: {
|
|
1245
|
+
action: str('draft (show it; sends nothing), send (after the person\'s yes) or decline (their no)', { enum: ['draft', 'send', 'decline'] }),
|
|
1246
|
+
kind: str('What it is', { enum: [...FEEDBACK_KINDS] }),
|
|
1247
|
+
text: str(`What happened, in plain words, at most ${FEEDBACK_LIMITS.text.toLocaleString('en-US')} characters: your draft, or the person\'s own words`),
|
|
1248
|
+
step: str('Optional: the step or skill it is about, e.g. "studio-setup: put it online" or "publish"'),
|
|
1249
|
+
email: str('Optional: a reply address, only if the person typed it themselves'),
|
|
1250
|
+
offered: { type: 'boolean', description: 'true when you offered it; false when the person asked to tell Homie' },
|
|
1251
|
+
draft: str('With send or decline: the draft id the draft returned (fd_…)'),
|
|
1252
|
+
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'] }),
|
|
1253
|
+
studioVersion: str('Filled in here from the studio (the remote Homie MCP takes it from you)'),
|
|
1254
|
+
pluginVersion: str('Filled in here from the plugin (the remote Homie MCP takes it from you)'),
|
|
1255
|
+
app: str('Filled in here from this app (the remote Homie MCP takes it from you)', { enum: [...FEEDBACK_APPS] }),
|
|
1256
|
+
...STUDIO_ARG,
|
|
1257
|
+
},
|
|
1258
|
+
},
|
|
1259
|
+
annotations: { title: 'Tell Homie', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, _meta: ui(UI.feedback),
|
|
1260
|
+
run: async (a) => feedbackTool(ctx, a),
|
|
1261
|
+
},
|
|
1123
1262
|
{
|
|
1124
1263
|
name: 'file_list', title: 'List studio files',
|
|
1125
1264
|
description: 'The files and folders in the studio (or one folder of it), two levels deep. Paths are relative to the studio.',
|
|
@@ -1238,4 +1377,4 @@ export function slimCodex(html, { max = 140_000, pictures = true } = {}) {
|
|
|
1238
1377
|
return h.length <= max ? h : null;
|
|
1239
1378
|
}
|
|
1240
1379
|
|
|
1241
|
-
export const _test = { checklist, feedFor, slimCodex, expandHome };
|
|
1380
|
+
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 } },
|
package/lib/redact.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SECRETS OUT OF TOOL OUTPUT. The Homie mod runs every tool result through `redact` before Claude reads it or the
|
|
3
|
+
* transcript keeps it. It only matches text: it never opens a key file, the keychain or the environment.
|
|
4
|
+
*
|
|
5
|
+
* What it hides: the studio's own one-time and owner keys (office and stats keys `hsk_`, progress write keys `hbk_`,
|
|
6
|
+
* agent passes `hap_…`, whose public id part stays), Cloudflare API tokens and global keys, provider keys (fal,
|
|
7
|
+
* ElevenLabs, Anthropic, OpenAI, GitHub, npm, Stripe keys and webhook secrets, AWS, Google), bearer tokens, private key blocks, and the value
|
|
8
|
+
* of any `NAME=value` whose name says key, token or secret.
|
|
9
|
+
*
|
|
10
|
+
* A link that carries one (the owner's one-time sign-in link, a confirm link, a player-owner link) is a link for the
|
|
11
|
+
* person, not for Claude: it is taken out whole and handed back in `links`, so the mod can show it to the person in
|
|
12
|
+
* its pane, and Claude reads that it is there.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const HIDE = (kind) => `[${kind} hidden by Homie]`;
|
|
16
|
+
|
|
17
|
+
/** Each rule: what it is, the pattern, and how to write what replaces a match (the default hides the whole match). */
|
|
18
|
+
export const RULES = [
|
|
19
|
+
{ kind: 'private key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
|
|
20
|
+
{ kind: 'office key', re: /\bhsk_[a-f0-9]{48}\b/g },
|
|
21
|
+
{ kind: 'progress key', re: /\bhbk_[a-f0-9]{48}\b/g },
|
|
22
|
+
// An agent pass: its id (hap_ and ten hex digits) is public and stays; the secret after it goes.
|
|
23
|
+
{ kind: 'agent pass', re: /\b(hap_[a-f0-9]{10})_[A-Za-z0-9_-]{40}(?![A-Za-z0-9_-])/g, to: (m, id) => `${id}_${HIDE('agent pass')}` },
|
|
24
|
+
{ kind: 'Anthropic key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
|
|
25
|
+
{ kind: 'OpenAI key', re: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{32,}/g },
|
|
26
|
+
{ kind: 'ElevenLabs key', re: /\bsk_[a-f0-9]{48}\b/g },
|
|
27
|
+
{ kind: 'Stripe key', re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
|
|
28
|
+
// A webhook signing secret has no live/test part: whsec_ and the secret itself.
|
|
29
|
+
{ kind: 'Stripe webhook secret', re: /\bwhsec_[A-Za-z0-9+/=_-]{16,}/g },
|
|
30
|
+
{ kind: 'fal key', re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9a-f]{32}\b/g },
|
|
31
|
+
{ kind: 'GitHub token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/g },
|
|
32
|
+
{ kind: 'npm token', re: /\bnpm_[A-Za-z0-9]{36}\b/g },
|
|
33
|
+
{ kind: 'AWS key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
|
|
34
|
+
{ kind: 'Google key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
35
|
+
{ kind: 'Cloudflare key', re: /(X-Auth-Key["']?\s*[:=]\s*["']?)([a-f0-9]{37})\b/gi, to: (m, head) => `${head}${HIDE('Cloudflare key')}` },
|
|
36
|
+
{ kind: 'token', re: /(\b[Bb]earer\s+)([A-Za-z0-9_\-.~+/]{24,}=*)/g, to: (m, head) => `${head}${HIDE('token')}` },
|
|
37
|
+
// NAME=value (or NAME: value) where the name says what it is: CLOUDFLARE_API_TOKEN, FAL_KEY, ELEVENLABS_API_KEY ...
|
|
38
|
+
// Only a value that looks like a key (16+ characters of letters AND digits, no dots or brackets), so source code
|
|
39
|
+
// that names a key (`const FAL_KEY = process.env.FAL_KEY`) is never changed under Claude's eyes.
|
|
40
|
+
{
|
|
41
|
+
kind: 'secret',
|
|
42
|
+
re: /\b([A-Z][A-Z0-9_]*(?:API_KEY|API_TOKEN|_TOKEN|_SECRET|SECRET_KEY|_KEY|PASSWORD)\b["']?\s*[=:]\s*["']?)([A-Za-z0-9_\-+/=:]{16,})(?![A-Za-z0-9_.(])/g,
|
|
43
|
+
to: (m, head, value) => (/[A-Za-z]/.test(value) && /[0-9]/.test(value) ? `${head}${HIDE('secret')}` : m),
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const URL_RE = /https?:\/\/[^\s"'<>`)\]]+/g;
|
|
48
|
+
const LINK_SECRET = /\b(?:hsk|hbk)_[a-f0-9]{48}\b|\bhap_[a-f0-9]{10}_[A-Za-z0-9_-]{40}/;
|
|
49
|
+
export const LINK_NOTE = '[one-time owner link: the Homie mod showed it to the person in its Studio pane; Claude never sees it]';
|
|
50
|
+
|
|
51
|
+
/** Redact one string: { text, hits: [kinds], links: [urls] }. Unchanged text keeps its identity. */
|
|
52
|
+
export function redactText(text) {
|
|
53
|
+
if (typeof text !== 'string' || text.length < 8) return { text, hits: [], links: [] };
|
|
54
|
+
const hits = [];
|
|
55
|
+
const links = [];
|
|
56
|
+
let out = text.replace(URL_RE, (url) => {
|
|
57
|
+
if (!LINK_SECRET.test(url)) return url;
|
|
58
|
+
links.push(url.replace(/[.,;:]+$/, ''));
|
|
59
|
+
hits.push('owner link');
|
|
60
|
+
return LINK_NOTE + url.slice(url.replace(/[.,;:]+$/, '').length);
|
|
61
|
+
});
|
|
62
|
+
for (const rule of RULES) {
|
|
63
|
+
rule.re.lastIndex = 0;
|
|
64
|
+
out = out.replace(rule.re, (...m) => {
|
|
65
|
+
const replaced = rule.to ? rule.to(...m) : HIDE(rule.kind);
|
|
66
|
+
if (replaced !== m[0]) hits.push(rule.kind);
|
|
67
|
+
return replaced;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return hits.length ? { text: out, hits, links } : { text, hits, links };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Redact every string inside a value (a tool's result record, an MCP result, a string), at most 12 levels deep.
|
|
75
|
+
* Returns { value, hits, links }; `value` is the same object when nothing was hidden.
|
|
76
|
+
*/
|
|
77
|
+
export function redact(value) {
|
|
78
|
+
const hits = [];
|
|
79
|
+
const links = [];
|
|
80
|
+
const walk = (v, depth) => {
|
|
81
|
+
if (typeof v === 'string') {
|
|
82
|
+
const r = redactText(v);
|
|
83
|
+
if (r.hits.length) { hits.push(...r.hits); links.push(...r.links); }
|
|
84
|
+
return r.text;
|
|
85
|
+
}
|
|
86
|
+
if (depth > 12 || v === null || typeof v !== 'object') return v;
|
|
87
|
+
if (Array.isArray(v)) {
|
|
88
|
+
let changed = false;
|
|
89
|
+
const next = v.map((x) => { const y = walk(x, depth + 1); if (y !== x) changed = true; return y; });
|
|
90
|
+
return changed ? next : v;
|
|
91
|
+
}
|
|
92
|
+
let changed = false;
|
|
93
|
+
const next = {};
|
|
94
|
+
for (const [k, x] of Object.entries(v)) { const y = walk(x, depth + 1); if (y !== x) changed = true; next[k] = y; }
|
|
95
|
+
return changed ? next : v;
|
|
96
|
+
};
|
|
97
|
+
const out = walk(value, 0);
|
|
98
|
+
return { value: out, hits: [...new Set(hits)], links: [...new Set(links)] };
|
|
99
|
+
}
|
package/lib/scaffold.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from '../worker/office-schema
|
|
|
30
30
|
import { SERVERS_MIGRATION, SERVERS_MIGRATION_FILE } from '../worker/servers.mjs';
|
|
31
31
|
import { CHAT_MIGRATION, CHAT_MIGRATION_FILE } from '../worker/chat-store.mjs';
|
|
32
32
|
import { SHOP_MIGRATION, SHOP_MIGRATION_FILE } from '../worker/shop-store.mjs';
|
|
33
|
+
import { LOUNGE_MIGRATION, LOUNGE_MIGRATION_FILE } from '../worker/lounge-store.mjs';
|
|
33
34
|
import { themeFile } from './site.mjs';
|
|
34
35
|
|
|
35
36
|
export const COMPAT_DATE = '2026-06-01';
|
|
@@ -427,6 +428,18 @@ chat's card follows the work from then on) and, for a studio still being set up,
|
|
|
427
428
|
the budget until the person says Proceed, and takes keys out of command output. When it refuses a call, say what
|
|
428
429
|
you meant to do and why; do not retry it unless the person asks.
|
|
429
430
|
|
|
431
|
+
## Telling Homie
|
|
432
|
+
|
|
433
|
+
The people who make Homie read every note, and someone who is stuck rarely writes to a forum. So when the person is
|
|
434
|
+
stuck, confused or frustrated, after an error you could not fix, or at the end of their first studio setup or first
|
|
435
|
+
publish, you may OFFER, once in a session, to send them a short note about it: the Homie MCP's \`homie_feedback\`
|
|
436
|
+
(in Claude Code also \`/feedback\`, and Tell Homie in the Studio pane). Draft it in plain words from what happened
|
|
437
|
+
(what they tried, what they expected, what they saw); a draft sends nothing. Show it exactly as it would go, and send
|
|
438
|
+
it only after they say yes (in Claude Code, Claude Code itself asks them with the exact note). Never nag: a no is
|
|
439
|
+
final for the session, and help never depends on it. Never put a key, a log, a file, code or anyone's name in a
|
|
440
|
+
note; a reply address only if they typed it. When they ask to tell Homie something, draft it with
|
|
441
|
+
\`offered: false\`.
|
|
442
|
+
|
|
430
443
|
## Beta
|
|
431
444
|
|
|
432
445
|
Homie for studios is in beta. When something breaks, or a game you want to port does
|
|
@@ -640,6 +653,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
640
653
|
[`site/migrations/${SERVERS_MIGRATION_FILE}`]: SERVERS_MIGRATION,
|
|
641
654
|
[`site/migrations/${CHAT_MIGRATION_FILE}`]: CHAT_MIGRATION,
|
|
642
655
|
[`site/migrations/${SHOP_MIGRATION_FILE}`]: SHOP_MIGRATION,
|
|
656
|
+
[`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
|
|
643
657
|
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
644
658
|
'.claude/skills/.gitkeep': '',
|
|
645
659
|
};
|
|
@@ -754,13 +768,22 @@ function ensureChatMigration(root) {
|
|
|
754
768
|
return `site/migrations/${CHAT_MIGRATION_FILE}`;
|
|
755
769
|
}
|
|
756
770
|
|
|
771
|
+
/** A studio made before 0.29.0 has no Lounge tables: kept chat (only when an owner turns history on), play nights, moderators. */
|
|
772
|
+
function ensureLoungeMigration(root) {
|
|
773
|
+
const file = join(root, 'site', 'migrations', LOUNGE_MIGRATION_FILE);
|
|
774
|
+
if (existsSync(file)) return null;
|
|
775
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
776
|
+
writeFileSync(file, LOUNGE_MIGRATION);
|
|
777
|
+
return `site/migrations/${LOUNGE_MIGRATION_FILE}`;
|
|
778
|
+
}
|
|
779
|
+
|
|
757
780
|
export function ensureMigrations(root) {
|
|
758
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root)].filter(Boolean);
|
|
781
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root)].filter(Boolean);
|
|
759
782
|
}
|
|
760
783
|
|
|
761
784
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
|
762
785
|
export function migrationWord(file) {
|
|
763
|
-
return /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (it sells nothing until shop.json and the owner\'s Stripe key are in)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
|
|
786
|
+
return /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (it sells nothing until shop.json and the owner\'s Stripe key are in)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
|
|
764
787
|
}
|
|
765
788
|
|
|
766
789
|
/**
|
|
@@ -1351,6 +1351,86 @@
|
|
|
1351
1351
|
}
|
|
1352
1352
|
}
|
|
1353
1353
|
}
|
|
1354
|
+
},
|
|
1355
|
+
"0.28.0": {
|
|
1356
|
+
"files": {
|
|
1357
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1358
|
+
"README.md": "684b3cca8001673c",
|
|
1359
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
1360
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1361
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1362
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1363
|
+
"posts/README.md": "37841aae476bd540",
|
|
1364
|
+
"site/README.md": "2928ca4f079ab76b",
|
|
1365
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1366
|
+
},
|
|
1367
|
+
"sections": {
|
|
1368
|
+
"AGENTS.md": {
|
|
1369
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1370
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1371
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
1372
|
+
"## The site": "366ecce1a424837a",
|
|
1373
|
+
"## Making games": "abf2f0d59341512d",
|
|
1374
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1375
|
+
"## Art direction and models": "aaea65236db52126",
|
|
1376
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
1377
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1378
|
+
"## Selling things (the shop)": "b04b0a2b25f524f2",
|
|
1379
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1380
|
+
"## Rules": "7e8458adc1b81ece",
|
|
1381
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
1382
|
+
"## Beta": "9d02638207a26e73"
|
|
1383
|
+
}
|
|
1384
|
+
},
|
|
1385
|
+
"named": {
|
|
1386
|
+
"Homie Arcade": {
|
|
1387
|
+
"sections": {
|
|
1388
|
+
"AGENTS.md": {
|
|
1389
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
},
|
|
1395
|
+
"0.29.0": {
|
|
1396
|
+
"files": {
|
|
1397
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1398
|
+
"README.md": "684b3cca8001673c",
|
|
1399
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
1400
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1401
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1402
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1403
|
+
"posts/README.md": "37841aae476bd540",
|
|
1404
|
+
"site/README.md": "2928ca4f079ab76b",
|
|
1405
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1406
|
+
},
|
|
1407
|
+
"sections": {
|
|
1408
|
+
"AGENTS.md": {
|
|
1409
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1410
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1411
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
1412
|
+
"## The site": "366ecce1a424837a",
|
|
1413
|
+
"## Making games": "abf2f0d59341512d",
|
|
1414
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1415
|
+
"## Art direction and models": "aaea65236db52126",
|
|
1416
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
1417
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1418
|
+
"## Selling things (the shop)": "b04b0a2b25f524f2",
|
|
1419
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1420
|
+
"## Rules": "7e8458adc1b81ece",
|
|
1421
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
1422
|
+
"## Beta": "9d02638207a26e73"
|
|
1423
|
+
}
|
|
1424
|
+
},
|
|
1425
|
+
"named": {
|
|
1426
|
+
"Homie Arcade": {
|
|
1427
|
+
"sections": {
|
|
1428
|
+
"AGENTS.md": {
|
|
1429
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1354
1434
|
}
|
|
1355
1435
|
}
|
|
1356
1436
|
}
|
package/mcp/ui/card.css
CHANGED
|
@@ -92,3 +92,11 @@ details summary{cursor:pointer;color:var(--c-fg2);font-size:13px;margin-top:10px
|
|
|
92
92
|
.clips{display:grid;grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:8px}
|
|
93
93
|
.clip{display:grid;gap:3px;align-content:start;padding:6px;border-radius:var(--r-sm);background:var(--c-bg)}.clip .art{aspect-ratio:1;border-radius:var(--r-sm);background:var(--c-bg3);display:grid;place-items:center;overflow:hidden;color:var(--c-fg3);font-size:20px}.clip .art img{width:100%;height:100%;object-fit:cover}
|
|
94
94
|
.clip b{font-size:13px}.clip span{font-size:11.5px;color:var(--c-fg2);overflow-wrap:anywhere}.clip.missing{opacity:.8;box-shadow:inset 0 0 0 1px var(--c-line2)}.clip button{justify-self:start}
|
|
95
|
+
/* Tell Homie (feedback.js): the note as it would go, and the editor that rewords it. */
|
|
96
|
+
.quote{margin:8px 0 0;padding:12px 14px;border-radius:var(--r-md);background:var(--c-bg2);border-left:3px solid var(--accent);font:15px/1.5 var(--f-sans);color:var(--c-fg);white-space:normal;overflow-wrap:anywhere}
|
|
97
|
+
.kinds{display:flex;flex-wrap:wrap;gap:6px;margin:4px 0 10px}
|
|
98
|
+
textarea{display:block;width:100%;min-height:110px;resize:vertical;font:15px/1.45 var(--f-sans);padding:10px 12px;border-radius:var(--r-md);border:1px solid var(--c-line2);background:var(--c-bg);color:var(--c-fg)}
|
|
99
|
+
input[type=email]{display:block;width:100%;margin:8px 0 12px;font:14px/1.2 var(--f-sans);padding:9px 10px;border-radius:var(--r-sm);border:1px solid var(--c-line2);background:var(--c-bg);color:var(--c-fg)}
|
|
100
|
+
textarea:focus-visible,input[type=email]:focus-visible{outline:2px solid var(--c-fg);outline-offset:1px}
|
|
101
|
+
.actions.send{margin-top:14px}
|
|
102
|
+
@media (max-width:420px){.actions.send button{flex:1 1 auto}}
|