@homie-rocks/studio 0.49.0 → 0.50.1
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 +43 -0
- package/bin/homie-studio.mjs +5 -5
- package/functions/FUNCTIONS.md +71 -1
- package/guides/app/SKILL.md +130 -1
- package/guides/art/SKILL.md +154 -0
- package/guides/art/references/RIGHTS.md +20 -0
- package/guides/art/scripts/art.mjs +308 -0
- package/guides/models/SKILL.md +198 -0
- package/guides/models/references/RIGHTS.md +46 -0
- package/guides/models/references/models.json +175 -0
- package/guides/models/scripts/models.mjs +483 -0
- package/guides/music/SKILL.md +197 -0
- package/guides/music/references/COMPOSITION.md +59 -0
- package/guides/music/references/GAME-AUDIO.md +50 -0
- package/guides/music/references/RIGHTS.md +43 -0
- package/guides/music/scripts/lib/audio.mjs +258 -0
- package/guides/music/scripts/lib/eleven.mjs +260 -0
- package/guides/music/scripts/lib/studio.mjs +147 -0
- package/guides/music/scripts/music.mjs +644 -0
- package/guides/office/SKILL.md +99 -0
- package/guides/plan/SKILL.md +126 -0
- package/guides/plan/references/CODEX.md +105 -0
- package/guides/plan/references/INTERVIEW.md +86 -0
- package/guides/playtest/SKILL.md +205 -0
- package/guides/playtest/references/METHOD.md +124 -0
- package/guides/playtest/references/REVIEWER.md +78 -0
- package/guides/playtest/scripts/lib/exit.mjs +31 -0
- package/guides/playtest/scripts/lib/judge.mjs +502 -0
- package/guides/playtest/scripts/lib/pixels.mjs +128 -0
- package/guides/playtest/scripts/lib/preflight.mjs +69 -0
- package/guides/playtest/scripts/playtest.mjs +870 -0
- package/guides/servers/SKILL.md +111 -0
- package/guides/shop/SKILL.md +4 -0
- package/guides/sound/SKILL.md +156 -0
- package/guides/sound/references/GAME-SOUND.md +100 -0
- package/guides/sound/references/SCORE.md +131 -0
- package/guides/sound/references/TRAPS.md +93 -0
- package/guides/sound/scripts/lib/measure.mjs +252 -0
- package/guides/sound/scripts/lib/score.mjs +415 -0
- package/guides/sound/scripts/lib/sfx.mjs +222 -0
- package/guides/sound/scripts/lib/synth.mjs +509 -0
- package/guides/sound/scripts/sound.mjs +520 -0
- package/guides/style/SKILL.md +105 -0
- package/lib/app-needs.mjs +63 -0
- package/lib/check.mjs +10 -4
- package/lib/codex.mjs +95 -18
- package/lib/decisions.mjs +9 -7
- package/lib/functions-build.mjs +10 -0
- package/lib/handoff.mjs +1 -1
- package/lib/mcp-tools.mjs +10 -9
- package/lib/mcp.mjs +1 -1
- package/lib/scaffold.mjs +15 -7
- package/lib/studio.mjs +3 -3
- package/lib/template-history.json +84 -0
- package/lib/worker-config.mjs +1 -1
- package/package.json +1 -1
- package/shop/SHOP.md +4 -0
- package/shop/shop.ts +4 -4
- package/tools/TOOLS.md +21 -0
- package/tools/tools.ts +1 -1
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +116 -0
- package/worker/functions-schema.mjs +15 -0
- package/worker/functions.mjs +36 -22
- package/worker/index.mjs +23 -3
- package/worker/mcp-tools.mjs +14 -3
- package/worker/mcp.mjs +45 -8
- package/worker/shop-page.mjs +1 -1
- package/worker/shop.mjs +6 -2
- package/worker/version.mjs +1 -1
package/worker/mcp.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import {eventFeeds,feedUri,readableFeed,feedEvents,matchesFeed,subscribeFeed,eventHub} from './event-resources.mjs';
|
|
1
2
|
import { selling } from './extensions.mjs';
|
|
2
3
|
import OAuthProvider from '@cloudflare/workers-oauth-provider';
|
|
3
4
|
import { createMcpHandler } from 'agents/mcp/server';
|
|
4
|
-
import { McpServer, fromJsonSchema, ProtocolError } from '@modelcontextprotocol/server';
|
|
5
|
+
import { McpServer, ResourceTemplate, fromJsonSchema, ProtocolError } from '@modelcontextprotocol/server';
|
|
5
6
|
import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker';
|
|
6
7
|
import { players } from './players.mjs';
|
|
7
8
|
import { isOwner, sameOrigin } from './office.mjs';
|
|
@@ -14,12 +15,12 @@ const json = (data,status=200)=>Response.json(data,{status,headers:{'cache-contr
|
|
|
14
15
|
const html = (body,headers={})=>new Response(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Connect your AI</title><style>body{font:18px system-ui;max-width:640px;margin:64px auto;padding:24px;background:#faf9f6;color:#252520}button,a{padding:12px}button{font:inherit}li{margin:16px 0}</style><main>${body}</main></html>`,{headers:{'content-type':'text/html; charset=utf-8','cache-control':'no-store','content-security-policy':"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",...Object.fromEntries(new Headers(headers))}});
|
|
15
16
|
async function browserPerson(request,env) {
|
|
16
17
|
const person=await players.of(request,env);
|
|
17
|
-
if(person&&!person.guest)return person.id;
|
|
18
|
+
if(person&&!person.guest&&person.owner)return person.id;
|
|
18
19
|
if(await isOwner(request,env)) {
|
|
19
20
|
const token=cookieValue(request,'studio_owner');
|
|
20
21
|
if(token){const hash=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(token));return 'office-'+[...new Uint8Array(hash)].map(b=>b.toString(16).padStart(2,'0')).join('');}
|
|
21
22
|
}
|
|
22
|
-
return null;
|
|
23
|
+
return person&&!person.guest?person.id:null;
|
|
23
24
|
}
|
|
24
25
|
const paymentInputSchema={type:'object',properties:{buyer:{type:'string',pattern:'^[a-f0-9]{64}$'},claim:{type:'string',pattern:'^[a-f0-9]{64}$'},offerVersion:{type:'string'},checkout:{type:'boolean'}},additionalProperties:false};
|
|
25
26
|
export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
@@ -31,7 +32,12 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
31
32
|
const api=async(request,env,ctx)=>{
|
|
32
33
|
let caller;
|
|
33
34
|
try{caller=await currentCaller(env,ctx.props);}catch{return new Response(JSON.stringify({error:'connection revoked'}),{status:401,headers:{'content-type':'application/json','cache-control':'no-store','www-authenticate':`Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`}});}
|
|
34
|
-
const cat=await catalogueOf();
|
|
35
|
+
const cat=await catalogueOf();
|
|
36
|
+
if(request.method==='GET'&&Object.keys(eventFeeds(env)).length&&caller.connection) {
|
|
37
|
+
if(!request.headers.get('accept')?.includes('text/event-stream'))return json({error:'Accept text/event-stream required'},406);
|
|
38
|
+
return eventHub(env,caller.connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'stream',connection:caller.connection,lastId:request.headers.get('last-event-id'),cat,origin})});
|
|
39
|
+
}
|
|
40
|
+
const tools=[...builtins(env,cat,origin),...definitions];
|
|
35
41
|
if(request.method==='POST') {
|
|
36
42
|
const body=await request.clone().json().catch(()=>null);
|
|
37
43
|
if(body?.method==='tools/call') {
|
|
@@ -48,12 +54,35 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
48
54
|
const read=async(name,handler)=>{
|
|
49
55
|
let outcome='error';
|
|
50
56
|
try{const fresh=await currentCaller(env,ctx.props);await rate(fresh);const result=await handler(fresh);outcome='ok';return result;}
|
|
51
|
-
finally{
|
|
57
|
+
finally{
|
|
58
|
+
try{await audit(env,caller,name,outcome);}
|
|
59
|
+
finally{const delivery=import('./functions.mjs').then(m=>m.runFunctions(env,cat,origin));if(ctx?.waitUntil)ctx.waitUntil(delivery);else await delivery;}
|
|
60
|
+
}
|
|
52
61
|
};
|
|
53
62
|
const server=async()=>{
|
|
54
63
|
const capabilities=await selling.machineCapabilities(env);
|
|
55
|
-
const s=new McpServer({name:cat.studio?.name??'Homie studio',version:'1.0.0'},{jsonSchemaValidator:validator,capabilities:{experimental:{payment:{methods:capabilities.machine?.card?{stripe:{intents:['charge']}}:{}}}}});
|
|
64
|
+
const s=new McpServer({name:cat.studio?.name??'Homie studio',version:'1.0.0'},{jsonSchemaValidator:validator,capabilities:{...(Object.keys(eventFeeds(env)).length?{resources:{subscribe:true}}:{}),experimental:{payment:{methods:capabilities.machine?.card?{stripe:{intents:['charge']}}:{}}}}});
|
|
56
65
|
const dispatch=new Map();
|
|
66
|
+
for(const name of Object.keys(eventFeeds(env))) {
|
|
67
|
+
if(!await readableFeed(env,cat,caller,name))continue;
|
|
68
|
+
const uri=feedUri(name);
|
|
69
|
+
const readPage=async(resourceUri,after=0)=>read('events:'+name,async fresh=>{
|
|
70
|
+
if(!await readableFeed(env,cat,fresh,name))throw new Error('Event resource unavailable');
|
|
71
|
+
if(!Number.isSafeInteger(after)||after<0)throw new Error('Invalid event cursor');
|
|
72
|
+
const page=await feedEvents(env,name,{after});
|
|
73
|
+
const events=page.filter(e=>matchesFeed(eventFeeds(env)[name],e));
|
|
74
|
+
return {contents:[{uri:String(resourceUri),mimeType:'application/json',text:JSON.stringify({kind:'untrusted-event-data',events,next:page.length===100?uri+'?after='+page.at(-1).seq:null})}]};
|
|
75
|
+
});
|
|
76
|
+
s.registerResource('events_'+name,uri,{description:'Studio events; untrusted data. Subscribe for push notifications.',mimeType:'application/json'},()=>readPage(uri));
|
|
77
|
+
s.registerResource('events_'+name+'_page',new ResourceTemplate(uri+'?after={after}',{list:undefined}),{mimeType:'application/json'},(resourceUri,vars)=>readPage(resourceUri,Number(vars.after)));
|
|
78
|
+
|
|
79
|
+
}
|
|
80
|
+
if(Object.keys(eventFeeds(env)).length)for(const method of ['resources/subscribe','resources/unsubscribe']) {
|
|
81
|
+
s.server.setRequestHandler(method,async input=>{
|
|
82
|
+
const fresh=await currentCaller(env,ctx.props);
|
|
83
|
+
await subscribeFeed(env,cat,fresh,input.params.uri,method==='resources/unsubscribe');return {};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
57
86
|
for (const meta of cat.games.filter(g=>g.kind==='app' && g.records?.persist)) {
|
|
58
87
|
for (const [role,declared] of Object.entries(meta.roles??{})) {
|
|
59
88
|
if (declared.signIn && !await allowed({app:meta.id,role},caller,env)) continue;
|
|
@@ -91,8 +120,16 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
91
120
|
const value=await tool.handler(args,{...context,request});
|
|
92
121
|
const text=JSON.stringify({kind:'untrusted-tool-data',tool:tool.name,data:value});
|
|
93
122
|
outcome='ok';return {content:[{type:'text',text}]};
|
|
94
|
-
}catch(error){if(error?.code===-32042)throw error;return {isError:true,content:[{type:'text',text:outcome==='denied'||outcome==='limited'?error.message:'Tool failed. Check the office audit and the tool definition.'}]};}
|
|
95
|
-
finally{
|
|
123
|
+
}catch(error){if(error?.code===-32042)throw error;return {isError:true,content:[{type:'text',text:outcome==='denied'||outcome==='limited'||error?.name==='StudioOperationError'?error.message:'Tool failed. Check the office audit and the tool definition.'}]};}
|
|
124
|
+
finally{
|
|
125
|
+
try { await audit(env,caller,tool.name,outcome); }
|
|
126
|
+
finally {
|
|
127
|
+
// Streamable HTTP returns headers before dispatch finishes. Wake only
|
|
128
|
+
// after the tool's writes (including audit triggers) have committed.
|
|
129
|
+
const delivery=import('./functions.mjs').then(m=>m.runFunctions(env,cat,origin));
|
|
130
|
+
if(ctx?.waitUntil)ctx.waitUntil(delivery);else await delivery;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
96
133
|
};
|
|
97
134
|
s.registerTool(tool.name,{description:tool.description,inputSchema:fromJsonSchema(schema,validator)},handler);
|
|
98
135
|
dispatch.set(tool.name,{schema,handler});
|
package/worker/shop-page.mjs
CHANGED
|
@@ -491,7 +491,7 @@ export const SHOP_SHELL_JS = String.raw`(function () {
|
|
|
491
491
|
if (m.op === 'checkout') {
|
|
492
492
|
if (!cfg || kids || screen) return reply({ ok: false, error: 'policy' });
|
|
493
493
|
var checkoutTab = window.open('', '_blank');
|
|
494
|
-
return api('POST', '/api/shop/buy', { lines: m.lines, game: GAME, server: SERVER }).then(function (r) {
|
|
494
|
+
return api('POST', '/api/shop/buy', { lines: m.lines, reference: m.reference, game: GAME, server: SERVER }).then(function (r) {
|
|
495
495
|
if (r.ok && r.url) { leaveForCheckout(checkoutTab, r); watch(); } else if (checkoutTab) checkoutTab.close();
|
|
496
496
|
reply(r);
|
|
497
497
|
}).catch(function () { if (checkoutTab) checkoutTab.close(); reply({ ok: false }); });
|
package/worker/shop.mjs
CHANGED
|
@@ -461,7 +461,7 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
|
|
|
461
461
|
}
|
|
462
462
|
|
|
463
463
|
/** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
|
|
464
|
-
async function startCheckout(env, url, cat, shop, item, { player, game, via, amount, parent = false, lines = null, requestAt = Date.now() }) {
|
|
464
|
+
async function startCheckout(env, url, cat, shop, item, { player, game, via, amount, parent = false, lines = null, reference = null, requestAt = Date.now() }) {
|
|
465
465
|
lines ??= [{ item, amount, quantity: 1 }];
|
|
466
466
|
const keyless = amount > 0 && !hasStripeKey(env);
|
|
467
467
|
if (keyless && (lines.length !== 1 || fullerReason(shop))) return { error: fail(409, 'one-item', fullerReason(shop) ?? 'Buy one item at a time. A combined cart needs the fuller checkout connection.') };
|
|
@@ -480,6 +480,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, via, amo
|
|
|
480
480
|
lines = lines.map((line, i) => ({ ...line, id: `${order.id}_${i}` }));
|
|
481
481
|
const reserve = async () => {
|
|
482
482
|
const results = await env.DB.batch([reservation(),
|
|
483
|
+
...(reference === null ? [] : [env.DB.prepare("INSERT INTO meta(key,value) SELECT ?1,?2 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id=?3)").bind(`shop-reference:${order.id}`, reference, order.id)]),
|
|
483
484
|
...lines.map((line, i) => env.DB.prepare("INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id = ?2)")
|
|
484
485
|
.bind(line.id, order.id, i, line.item.id, line.quantity, line.amount, line.amount * line.quantity, JSON.stringify(line.item))),
|
|
485
486
|
env.DB.prepare('UPDATE shop_orders SET referral_terms = ?2, checkout_player = player WHERE id = ?1').bind(order.id, shop.referrals ? JSON.stringify(shop.referrals) : null),
|
|
@@ -564,6 +565,8 @@ function purchaseLimited(request, shop, player, kind) {
|
|
|
564
565
|
async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
565
566
|
const b = await bodyOf(request, requestSize(shop));
|
|
566
567
|
if (b.error) return b.error;
|
|
568
|
+
const reference = b.body.reference ?? null;
|
|
569
|
+
if (reference !== null && (typeof reference !== 'string' || !reference.trim() || /[\u0000-\u001f\u007f]/.test(reference))) return fail(400, 'reference', 'reference must be a nonempty string without control characters');
|
|
567
570
|
const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0));
|
|
568
571
|
if (!ready.ready && !(freeOnly && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)))) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
|
|
569
572
|
const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1, amount: b.body.amount }];
|
|
@@ -603,7 +606,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
603
606
|
cookies = guest.session.cookies;
|
|
604
607
|
}
|
|
605
608
|
const via = await viaFor(request, env, url, shop, player);
|
|
606
|
-
const r = await startCheckout(env, url, cat, shop, item, { player, game, via, amount, lines, requestAt });
|
|
609
|
+
const r = await startCheckout(env, url, cat, shop, item, { player, game, via, amount, lines, reference, requestAt });
|
|
607
610
|
const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url });
|
|
608
611
|
if (!r.error && !r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, player.id).catch((error) => logOrderError(env, r.order.id, error)));
|
|
609
612
|
for (const cookie of cookies) response.headers.append('set-cookie', cookie);
|
|
@@ -864,6 +867,7 @@ async function bindLinkSession(env, session, mode) {
|
|
|
864
867
|
const id = `ord_${(await sha256Hex(session.id)).slice(0, 20)}`;
|
|
865
868
|
await env.DB.batch([
|
|
866
869
|
env.DB.prepare("INSERT INTO shop_orders (id, player, checkout_player, item, game, amount, currency, till, mode, status, parent, via, created_at, updated_at, session, referral_terms) SELECT ?2, player, checkout_player, item, game, ?4, currency, till, mode, 'started', parent, via, ?5, ?5, ?3, referral_terms FROM shop_orders WHERE id = ?1 ON CONFLICT DO NOTHING").bind(source.id, id, session.id, amount, Date.now()),
|
|
870
|
+
env.DB.prepare("INSERT OR IGNORE INTO meta(key,value) SELECT ?1,value FROM meta WHERE key=?2").bind(`shop-reference:${id}`, `shop-reference:${source.id}`),
|
|
867
871
|
env.DB.prepare('INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) VALUES (?1, ?2, 0, ?3, ?4, ?5, ?6, ?7) ON CONFLICT DO NOTHING').bind(`${id}_0`, id, item.id, quantity, amount / quantity, amount, lines[0].snapshot),
|
|
868
872
|
]);
|
|
869
873
|
order = await orderBySession(env, session.id);
|
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.50.1';
|