@homie-rocks/studio 0.27.0 → 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 +30 -1
- package/README.md +10 -1
- package/lib/feedback.mjs +181 -0
- package/lib/mcp-tools.mjs +134 -1
- package/lib/mcp.mjs +6 -2
- package/lib/redact.mjs +99 -0
- package/lib/scaffold.mjs +12 -0
- package/lib/template-history.json +40 -0
- package/mcp/ui/card.css +8 -0
- package/mcp/ui/feedback.js +112 -0
- package/package.json +1 -1
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,9 +17,38 @@ 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
|
+
|
|
20
49
|
## [0.27.0] - 2026-10-03
|
|
21
50
|
|
|
22
|
-
**Plugin 0.28.0** · [#42](https://github.com/homie-rocks/homie/pull/42)
|
|
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)
|
|
23
52
|
|
|
24
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.
|
|
25
54
|
|
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>:
|
|
@@ -785,7 +793,8 @@ pinned a tarball at `https://homie.rocks/npm/homie-studio-<version>.tgz`, which
|
|
|
785
793
|
## Beta
|
|
786
794
|
|
|
787
795
|
This is a beta. Bugs, port requests and questions go to
|
|
788
|
-
<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).
|
|
789
798
|
|
|
790
799
|
## License
|
|
791
800
|
|
package/lib/feedback.mjs
ADDED
|
@@ -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 } },
|
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
|
@@ -427,6 +427,18 @@ chat's card follows the work from then on) and, for a studio still being set up,
|
|
|
427
427
|
the budget until the person says Proceed, and takes keys out of command output. When it refuses a call, say what
|
|
428
428
|
you meant to do and why; do not retry it unless the person asks.
|
|
429
429
|
|
|
430
|
+
## Telling Homie
|
|
431
|
+
|
|
432
|
+
The people who make Homie read every note, and someone who is stuck rarely writes to a forum. So when the person is
|
|
433
|
+
stuck, confused or frustrated, after an error you could not fix, or at the end of their first studio setup or first
|
|
434
|
+
publish, you may OFFER, once in a session, to send them a short note about it: the Homie MCP's \`homie_feedback\`
|
|
435
|
+
(in Claude Code also \`/feedback\`, and Tell Homie in the Studio pane). Draft it in plain words from what happened
|
|
436
|
+
(what they tried, what they expected, what they saw); a draft sends nothing. Show it exactly as it would go, and send
|
|
437
|
+
it only after they say yes (in Claude Code, Claude Code itself asks them with the exact note). Never nag: a no is
|
|
438
|
+
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
|
|
439
|
+
note; a reply address only if they typed it. When they ask to tell Homie something, draft it with
|
|
440
|
+
\`offered: false\`.
|
|
441
|
+
|
|
430
442
|
## Beta
|
|
431
443
|
|
|
432
444
|
Homie for studios is in beta. When something breaks, or a game you want to port does
|
|
@@ -1351,6 +1351,46 @@
|
|
|
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
|
+
}
|
|
1354
1394
|
}
|
|
1355
1395
|
}
|
|
1356
1396
|
}
|
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}}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/* The Tell Homie card: a note to the people who make Homie, exactly as it would go, with Send, Edit and Don't send.
|
|
2
|
+
* Nothing leaves this computer until the person presses Send here (or says yes in the chat). Edit drafts the note
|
|
3
|
+
* again from the person's own words, through the same redaction, so what the card shows is always what would go.
|
|
4
|
+
* The model is told what the person chose, once. Every text is set as text, never as HTML. */
|
|
5
|
+
(function () {
|
|
6
|
+
var C = Card;
|
|
7
|
+
var KINDS = [['stuck', 'Stuck'], ['confusing', 'Confusing'], ['idea', 'Idea'], ['praise', 'Praise'], ['bug', 'Bug']];
|
|
8
|
+
var LABEL = { stuck: 'Stuck', confusing: 'Confusing', idea: 'Idea', praise: 'Praise', bug: 'Bug' };
|
|
9
|
+
var ui = { editing: false, busy: false, why: null, kind: null, text: null, email: null };
|
|
10
|
+
|
|
11
|
+
function fieldsOf(sc) {
|
|
12
|
+
var n = sc.note || {};
|
|
13
|
+
return { kind: n.kind, text: n.text, step: n.step || undefined, email: n.email || undefined, offered: n.offered === true,
|
|
14
|
+
studioVersion: n.studioVersion || undefined, pluginVersion: n.pluginVersion || undefined, app: n.app || undefined,
|
|
15
|
+
// The studio the draft was made for (its pinned version is in the note): a send is checked against the same one.
|
|
16
|
+
studio: sc.studio || undefined };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function act(sc, args, after) {
|
|
20
|
+
if (ui.busy) return;
|
|
21
|
+
ui.busy = true; ui.why = null; C.render();
|
|
22
|
+
C.call('homie_feedback', args).then(function (res) {
|
|
23
|
+
ui.busy = false;
|
|
24
|
+
// Refused (not sent, a reworded note that cannot go, the app said no): the note stays on screen with why.
|
|
25
|
+
var d = res && res.structuredContent;
|
|
26
|
+
if (!d || d.kind !== 'feedback' || res.isError) { ui.why = C.textOf(res) || 'That did not work. Nothing was sent.'; C.render(); return; }
|
|
27
|
+
if (after) after(res);
|
|
28
|
+
C.take(res);
|
|
29
|
+
}).catch(function () { ui.busy = false; ui.why = 'The app did not answer. Nothing was sent.'; C.render(); });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function quote(text) {
|
|
33
|
+
var q = C.el('blockquote', 'quote');
|
|
34
|
+
String(text || '').split('\n').forEach(function (line, i) { if (i) q.appendChild(C.el('br')); q.appendChild(document.createTextNode(line)); });
|
|
35
|
+
return q;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function editor(card, sc) {
|
|
39
|
+
var n = sc.note || {};
|
|
40
|
+
if (ui.kind === null) { ui.kind = n.kind || 'idea'; ui.text = n.text || ''; ui.email = n.email || ''; }
|
|
41
|
+
var kinds = C.el('div', 'kinds'); kinds.setAttribute('role', 'radiogroup'); kinds.setAttribute('aria-label', 'What kind of note');
|
|
42
|
+
KINDS.forEach(function (k) {
|
|
43
|
+
var b = C.btn(k[1], 'small ' + (ui.kind === k[0] ? '' : 'ghost'), function () { ui.kind = k[0]; C.render(); });
|
|
44
|
+
b.setAttribute('role', 'radio'); b.setAttribute('aria-checked', ui.kind === k[0] ? 'true' : 'false');
|
|
45
|
+
kinds.appendChild(b);
|
|
46
|
+
});
|
|
47
|
+
card.appendChild(kinds);
|
|
48
|
+
var area = C.el('textarea'); area.value = ui.text; area.rows = 5; area.maxLength = 1500; area.setAttribute('aria-label', 'Your note, in your words');
|
|
49
|
+
area.oninput = function () { ui.text = area.value; };
|
|
50
|
+
card.appendChild(area);
|
|
51
|
+
var mail = C.el('input'); mail.type = 'email'; mail.placeholder = 'Your email, if you want a reply (optional)'; mail.value = ui.email; mail.setAttribute('aria-label', 'Reply address (optional)');
|
|
52
|
+
mail.oninput = function () { ui.email = mail.value; };
|
|
53
|
+
card.appendChild(mail);
|
|
54
|
+
var acts = C.el('div', 'actions');
|
|
55
|
+
acts.appendChild(C.btn(ui.busy ? 'Saving…' : 'Use this', '', function () {
|
|
56
|
+
var f = fieldsOf(sc);
|
|
57
|
+
act(sc, { action: 'draft', kind: ui.kind, text: ui.text, step: f.step, email: (ui.email || '').trim() || undefined, offered: f.offered, studioVersion: f.studioVersion, pluginVersion: f.pluginVersion, app: f.app, studio: f.studio, by: 'card' }, function (res) {
|
|
58
|
+
ui.editing = false; ui.kind = null;
|
|
59
|
+
var d = res && res.structuredContent;
|
|
60
|
+
if (d && d.state === 'draft') C.tell('edit:' + d.draft, 'The person reworded the note on the Tell Homie card. Nothing is sent yet; the card shows the new text (draft ' + d.draft + ') with Send. Do not send it yourself unless they say yes.');
|
|
61
|
+
});
|
|
62
|
+
}));
|
|
63
|
+
acts.appendChild(C.btn('Cancel', 'ghost', function () { ui.editing = false; ui.kind = null; C.render(); }));
|
|
64
|
+
card.appendChild(acts);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function render(sc) {
|
|
68
|
+
var card = C.el('main', 'card');
|
|
69
|
+
var state = sc.state;
|
|
70
|
+
var head = state === 'sent' ? C.pill('Sent', 'ok') : state === 'declined' ? C.pill('Not sent', '') : state === 'draft' ? C.pill('Not sent yet', 'warn') : C.pill('Not sent', 'bad');
|
|
71
|
+
C.add(card, C.top('Note', head));
|
|
72
|
+
card.appendChild(C.el('h1', '', state === 'sent' ? 'Sent to Homie. Thank you.' : state === 'declined' ? 'Not sent' : 'A note to the people who make Homie'));
|
|
73
|
+
var n = sc.note;
|
|
74
|
+
if (state === 'draft' && ui.editing) { editor(card, sc); }
|
|
75
|
+
else if (n) {
|
|
76
|
+
card.appendChild(C.el('p', 'sub', state === 'sent' ? 'This is what they got:' : state === 'declined' ? 'Nothing left this computer. This was the note:' : 'This is exactly what would go:'));
|
|
77
|
+
card.appendChild(C.add(C.el('div', 'chips'), C.pill(LABEL[n.kind] || n.kind, 'live')));
|
|
78
|
+
card.appendChild(quote(n.text));
|
|
79
|
+
if (sc.with) card.appendChild(C.el('p', 'fine', 'With it: ' + sc.with));
|
|
80
|
+
if (sc.taken && sc.taken.length) card.appendChild(C.el('p', 'fine', 'Homie took out ' + sc.taken.join(', ') + ' before showing it here.'));
|
|
81
|
+
}
|
|
82
|
+
if (ui.why) card.appendChild(C.el('p', 'note bad', ui.why));
|
|
83
|
+
if (state === 'draft' && !ui.editing) {
|
|
84
|
+
var acts = C.el('div', 'actions send');
|
|
85
|
+
var send = C.btn(ui.busy ? 'Sending…' : 'Send', '', function () {
|
|
86
|
+
act(sc, Object.assign({ action: 'send', draft: sc.draft, by: 'card' }, fieldsOf(sc)), function (res) {
|
|
87
|
+
var d = res && res.structuredContent;
|
|
88
|
+
if (d && d.state === 'sent') C.tell('sent:' + sc.draft, 'The person pressed Send on the Tell Homie card: the note went to Homie (reference ' + String(d.reference || '').slice(0, 8) + '). Thank them in one line, and do not offer another note in this session.');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
send.disabled = ui.busy;
|
|
92
|
+
acts.appendChild(send);
|
|
93
|
+
var edit = C.btn('Edit', 'ghost', function () { ui.editing = true; ui.kind = null; C.render(); }); edit.disabled = ui.busy;
|
|
94
|
+
acts.appendChild(edit);
|
|
95
|
+
var no = C.btn('Don’t send', 'ghost', function () {
|
|
96
|
+
act(sc, { action: 'decline', draft: sc.draft, by: 'card' }, function () {
|
|
97
|
+
C.tell('declined:' + sc.draft, 'The person pressed Don’t send on the Tell Homie card. Nothing was sent. Do not offer to send a note again in this session.');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
no.disabled = ui.busy;
|
|
101
|
+
acts.appendChild(no);
|
|
102
|
+
card.appendChild(acts);
|
|
103
|
+
}
|
|
104
|
+
if (state === 'sent' && sc.reference) card.appendChild(C.el('p', 'fine', 'Reference ' + String(sc.reference).slice(0, 8) + '. Notes are private: only the people who make Homie read them.'));
|
|
105
|
+
var foot = C.el('div', 'foot');
|
|
106
|
+
foot.appendChild(C.el('span', 'when', state === 'sent' ? 'Sent to ' + (sc.to || 'homie.rocks') : state === 'declined' ? 'Nothing was sent.' : 'Nothing is sent until you press Send. Goes to ' + (sc.to || 'homie.rocks') + ', privately.'));
|
|
107
|
+
card.appendChild(foot);
|
|
108
|
+
return card;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
C.start({ kind: 'feedback', label: 'Note', render: render, poll: null });
|
|
112
|
+
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.28.0';
|