@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/bin/homie-studio.mjs +5 -5
  3. package/functions/FUNCTIONS.md +71 -1
  4. package/guides/app/SKILL.md +130 -1
  5. package/guides/art/SKILL.md +154 -0
  6. package/guides/art/references/RIGHTS.md +20 -0
  7. package/guides/art/scripts/art.mjs +308 -0
  8. package/guides/models/SKILL.md +198 -0
  9. package/guides/models/references/RIGHTS.md +46 -0
  10. package/guides/models/references/models.json +175 -0
  11. package/guides/models/scripts/models.mjs +483 -0
  12. package/guides/music/SKILL.md +197 -0
  13. package/guides/music/references/COMPOSITION.md +59 -0
  14. package/guides/music/references/GAME-AUDIO.md +50 -0
  15. package/guides/music/references/RIGHTS.md +43 -0
  16. package/guides/music/scripts/lib/audio.mjs +258 -0
  17. package/guides/music/scripts/lib/eleven.mjs +260 -0
  18. package/guides/music/scripts/lib/studio.mjs +147 -0
  19. package/guides/music/scripts/music.mjs +644 -0
  20. package/guides/office/SKILL.md +99 -0
  21. package/guides/plan/SKILL.md +126 -0
  22. package/guides/plan/references/CODEX.md +105 -0
  23. package/guides/plan/references/INTERVIEW.md +86 -0
  24. package/guides/playtest/SKILL.md +205 -0
  25. package/guides/playtest/references/METHOD.md +124 -0
  26. package/guides/playtest/references/REVIEWER.md +78 -0
  27. package/guides/playtest/scripts/lib/exit.mjs +31 -0
  28. package/guides/playtest/scripts/lib/judge.mjs +502 -0
  29. package/guides/playtest/scripts/lib/pixels.mjs +128 -0
  30. package/guides/playtest/scripts/lib/preflight.mjs +69 -0
  31. package/guides/playtest/scripts/playtest.mjs +870 -0
  32. package/guides/servers/SKILL.md +111 -0
  33. package/guides/shop/SKILL.md +4 -0
  34. package/guides/sound/SKILL.md +156 -0
  35. package/guides/sound/references/GAME-SOUND.md +100 -0
  36. package/guides/sound/references/SCORE.md +131 -0
  37. package/guides/sound/references/TRAPS.md +93 -0
  38. package/guides/sound/scripts/lib/measure.mjs +252 -0
  39. package/guides/sound/scripts/lib/score.mjs +415 -0
  40. package/guides/sound/scripts/lib/sfx.mjs +222 -0
  41. package/guides/sound/scripts/lib/synth.mjs +509 -0
  42. package/guides/sound/scripts/sound.mjs +520 -0
  43. package/guides/style/SKILL.md +105 -0
  44. package/lib/app-needs.mjs +63 -0
  45. package/lib/check.mjs +10 -4
  46. package/lib/codex.mjs +95 -18
  47. package/lib/decisions.mjs +9 -7
  48. package/lib/functions-build.mjs +10 -0
  49. package/lib/handoff.mjs +1 -1
  50. package/lib/mcp-tools.mjs +10 -9
  51. package/lib/mcp.mjs +1 -1
  52. package/lib/scaffold.mjs +15 -7
  53. package/lib/studio.mjs +3 -3
  54. package/lib/template-history.json +84 -0
  55. package/lib/worker-config.mjs +1 -1
  56. package/package.json +1 -1
  57. package/shop/SHOP.md +4 -0
  58. package/shop/shop.ts +4 -4
  59. package/tools/TOOLS.md +21 -0
  60. package/tools/tools.ts +1 -1
  61. package/worker/build-id.mjs +1 -1
  62. package/worker/event-resources.mjs +116 -0
  63. package/worker/functions-schema.mjs +15 -0
  64. package/worker/functions.mjs +36 -22
  65. package/worker/index.mjs +23 -3
  66. package/worker/mcp-tools.mjs +14 -3
  67. package/worker/mcp.mjs +45 -8
  68. package/worker/shop-page.mjs +1 -1
  69. package/worker/shop.mjs +6 -2
  70. 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();const tools=[...builtins(env,cat,origin),...definitions];
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{await audit(env,caller,name,outcome);}
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{await audit(env,caller,tool.name,outcome);}
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});
@@ -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);
@@ -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.49.0';
2
+ export const STUDIO_VERSION_TAG = '0.50.1';