diff --git a/aesel/docs/jev-harness.md b/aesel/docs/jev-harness.md index babbcfa080..bfeab34c3f 100644 --- a/aesel/docs/jev-harness.md +++ b/aesel/docs/jev-harness.md @@ -114,10 +114,10 @@ OpenRouter's [Decisions SDK source](https://github.com/OpenRouterTeam/go-sdk/blo specifies `POST /api/alpha/decisions`; its chat endpoint is not the Jev interface. TypeSafe documents the model's [typed decision primitives and limitations](https://docs.typesafe.ai/concepts/system-one). -## Walkieware musical input +## Whistlegraph musical input `POST /api/easel-musical-jev` accepts authenticated, replaceable observations: -`{schema:"walkieware-input/v1",sessionId,sequence,features}`. The fixed feature +`{schema:"whistlegraph-input/v1",sessionId,sequence,features}`. The fixed feature schema contains speech-presence flags, pitch-contour category, capped attack count, rhythm regularity and energy category. It accepts no transcript, PCM, source, arbitrary prompt, question or provider URL. Speech-to-text and musical @@ -139,7 +139,7 @@ observations are not saved. These limits count requests, not dollars or coding braincells. Phone receipts are saved only during explicit debug fixture runs. Live results and failed attempts are retained under -`apple/walkieware/Tests/audio/`. Decision latency is not an end-to-end creation +`apple/whistlegraph/Tests/audio/`. Decision latency is not an end-to-end creation speed claim: the existing coding model still generates the piece. ### Persistent phone input @@ -151,7 +151,7 @@ is the first JSON message (`{type:"authenticate",token}`), never a URL query; client reconnects after disconnects, and HTTP remains available while cold. Once a decision has been sent, a disconnect does not replay it over HTTP. -`{type:"observation",body:}` receives an immediate +`{type:"observation",body:}` receives an immediate `received` acknowledgement followed by a `decision` or `error`, both with the original session and sequence. One decision runs per socket; only the latest waiting observation survives. `{type:"cancel",sessionId}` aborts active work @@ -168,4 +168,4 @@ VM removes provider inference latency. Measure these components before moving regions or provisioning another service. Focused validation: `node --test lith/musical-socket.test.mjs aesel/test/musical-jev.test.mjs`. -Phone validation: `node apple/walkieware/Tests/musical-benchmark.mjs mixed-request --jev --socket`. +Phone validation: `node apple/whistlegraph/Tests/musical-benchmark.mjs mixed-request --jev --socket`. diff --git a/aesel/src/ac-server.mjs b/aesel/src/ac-server.mjs index 6f987d518b..3200b46fbb 100644 --- a/aesel/src/ac-server.mjs +++ b/aesel/src/ac-server.mjs @@ -103,6 +103,8 @@ export class AcServer extends EventEmitter { piece = null, artifacts = null, settings = null, + // Optional surface controls: tools(), has(name), run(name, input). + controls = null, token = null, fetch = globalThis.fetch, site = SITE, @@ -130,7 +132,7 @@ export class AcServer extends EventEmitter { outputContinuations = 0, // OpenRouter's `reasoning` object, forwarded as given. A surface that shows // the piece being written wants {effort:"none"}: a reasoning model's - // hidden thinking streams nothing visible, and on Walkieware it measured + // hidden thinking streams nothing visible, and on Whistlegraph it measured // as two thirds of all output tokens and ten seconds before the first line. reasoning = null, // Anthropic's `thinking` field, forwarded as given; the relay bounds it. @@ -158,6 +160,7 @@ export class AcServer extends EventEmitter { this.piece = piece; this.artifacts = artifacts; this.settings = settings; + this.controls = controls; this.artifactContext = ''; this.apiMap = loadMap(); this.token = token; @@ -371,6 +374,7 @@ export class AcServer extends EventEmitter { const tools = this.workspace ? [...WORKSPACE_TOOLS, CLOSE_TOOL, ...(()=>{const api=TOOLS.find(t=>t.name==='ac_api');return api?[{name:api.name,description:api.description,input_schema:api.inputSchema}]:[];})(), ...(await this.extensions?.tools().catch(() => []) ?? [])] : [...(this.artifactContext ? await this.artifacts.tools() : [WRITE_PIECE,...(this.layeredEdits?[EDIT_PIECE]:[])]), ...(previewing ? [{name:PREVIEW_TOOL.name,description:PREVIEW_TOOL.description,input_schema:PREVIEW_TOOL.inputSchema}] : [])]; if(this.settings)tools.push({name:SETTINGS_TOOL.name,description:SETTINGS_TOOL.description,input_schema:SETTINGS_TOOL.inputSchema}); + if(this.controls)tools.push(...await this.controls.tools()); if(this.javascriptPiece && !this.workspace) { const api=TOOLS.find(tool=>tool.name==='ac_api'); if(previewing && this.frameCapture)tools.push({name:FRAME_TOOL.name,description:FRAME_TOOL.description+' Hosted mode returns local analysis/OCR only; pixels are not sent to this hosted model.',input_schema:{...FRAME_TOOL.inputSchema,properties:{...FRAME_TOOL.inputSchema.properties,image:{type:'boolean',enum:[false]}}}}); @@ -594,6 +598,15 @@ export class AcServer extends EventEmitter { async #runTool(block) { const signal = this.controller?.signal; const itemId = `tool-${block.id}`; + if(this.controls?.has(block.name)) { + try { + signal?.throwIfAborted(); + const result=await this.controls.run(block.name,block.input||{}); + return {type:'tool_result',tool_use_id:block.id,content:JSON.stringify(result)}; + } catch(error) { + return {type:'tool_result',tool_use_id:block.id,is_error:true,content:error.message}; + } + } if(this.workspace && WORKSPACE_TOOL_NAMES.has(block.name)) return this.#runWorkspaceTool(block, itemId, signal); if(this.workspace && this.extensions?.has(block.name)) { const item = { id: itemId, type: "dynamicToolCall", tool: this.extensions.describe(block.name, block.input || {}) }; diff --git a/aesel/src/musical-input-advisor.mjs b/aesel/src/musical-input-advisor.mjs index 25b5855d22..3826a15acc 100644 --- a/aesel/src/musical-input-advisor.mjs +++ b/aesel/src/musical-input-advisor.mjs @@ -18,8 +18,8 @@ export class MusicalInputAdvisor { try{ await Promise.resolve(); const deadline=new Promise((_,reject)=>{timer=setTimeout(()=>{controller.abort();reject(Error('deadline'));},this.timeoutMs);}); - const response=await Promise.race([deadline,this.fetchImpl(this.endpoint,{method:'POST',signal:controller.signal,headers:{'Content-Type':'application/json',Authorization:`Bearer ${bearer}`},body:JSON.stringify({schema:'walkieware-input/v1',sessionId,sequence,features})}).then(async r=>{if(!r.ok)throw Error(`http_${r.status}`);return r.json();})]); - if(this.sessionId!==sessionId||controller.signal.aborted||response.schema!=='walkieware-decision/v1'||response.sessionId!==sessionId||response.sequence!==sequence||!Object.hasOwn(MUSICAL_CHOICES,response.choice)||!Number.isFinite(response.confidence)||response.confidence<.8||response.confidence>1)return null; + const response=await Promise.race([deadline,this.fetchImpl(this.endpoint,{method:'POST',signal:controller.signal,headers:{'Content-Type':'application/json',Authorization:`Bearer ${bearer}`},body:JSON.stringify({schema:'whistlegraph-input/v1',sessionId,sequence,features})}).then(async r=>{if(!r.ok)throw Error(`http_${r.status}`);return r.json();})]); + if(this.sessionId!==sessionId||controller.signal.aborted||response.schema!=='whistlegraph-decision/v1'||response.sessionId!==sessionId||response.sequence!==sequence||!Object.hasOwn(MUSICAL_CHOICES,response.choice)||!Number.isFinite(response.confidence)||response.confidence<.8||response.confidence>1)return null; const value={key,choice:response.choice,cue:MUSICAL_CHOICES[response.choice],elapsedMs:Math.round(performance.now()-started),transport:response.transport||'http',transportMs:response.transportMs,serverMs:response.serverMs,providerMs:response.elapsedMs}; this.cache=value;this.onEvent('jevDecision',{choice:value.choice,elapsedMs:value.elapsedMs,sequence,transport:response.transport||'http',transportMs:response.transportMs,serverMs:response.serverMs,providerMs:response.elapsedMs});return value; }catch(error){if(this.sessionId===sessionId)this.onEvent('jevFallback',{reason:/^http_\d+$/.test(error.message)?error.message:controller.signal.aborted?'timeout_or_cancel':error.name||'unavailable'});return null;} diff --git a/aesel/src/walkieware-thread.mjs b/aesel/src/walkieware-thread.mjs index b008ebdb17..33121f2fc6 100644 --- a/aesel/src/walkieware-thread.mjs +++ b/aesel/src/walkieware-thread.mjs @@ -1,62 +1,2 @@ -// Stable local identity; the server atomically reserves its pronounceable code. -const ledgerText = ledger => ledger?JSON.stringify({format:ledger.format,head:ledger.head,versions:ledger.versions.map(v=>({id:v.id,parent:v.parent,source:v.source,request:v.request??null,createdAt:v.createdAt||'',layers:Number.isInteger(v.layers)?v.layers:0}))}):'null'; -export async function verifyThreadRevision(command,state) { - const before=state();if(before.busy)throw Error('Device busy'); - const digest=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(before.source)); - const hash=[...new Uint8Array(digest)].map(b=>b.toString(16).padStart(2,'0')).join(''); - const after=state(); - if(after.busy||after.head!==before.head||after.source!==before.source||command.baseVersion!==after.head||command.baseHash!==hash)throw Error('Version changed; inspect before editing'); -} -export function threadIdentity(storage,key,uuid=()=>crypto.randomUUID()) { - const saved=storage.getItem(key+'-thread'); - if(saved){const value=JSON.parse(saved);if(typeof value.id!=='string')throw Error('Invalid thread identity');return value;} - const value={id:uuid(),code:null};storage.setItem(key+'-thread',JSON.stringify(value));return value; -} -export class WalkiewareThread { - constructor({storage,key,token,ledger,state,onStatus,onCommand,WebSocketImpl=globalThis.WebSocket,url='wss://aesthetic.computer/api/walkieware-stream',heartbeatMs=15000,maxIdleMs=45000,reconnectMs=3000}) { - Object.assign(this,{storage,key,token,ledger,state,onStatus,onCommand,WebSocketImpl,url,heartbeatMs,maxIdleMs,reconnectMs}); - this.identity=threadIdentity(storage,key);this.revision=Number(storage.getItem(key+'-cloud-revision')||0);this.last=storage.getItem(key+'-cloud-ledger')||''; - this.active=false;this.sending=false;this.ready=false; - } - async resume() { - this.active=true;if(this.ws)return; - const token=await this.token();if(!token||!this.active||this.ws)return; - const ws=this.ws=new this.WebSocketImpl(this.url); - this.lastSeen=Date.now(); - this.heartbeat=setInterval(()=>{if(Date.now()-this.lastSeen>this.maxIdleMs)this.disconnect(ws);else if(this.ready)this.send({type:'ping'});},this.heartbeatMs); - ws.onopen=()=>this.send({type:'authenticate',role:'device',token,id:this.identity.id}); - ws.onmessage=async event=>{ - let m;try{m=JSON.parse(event.data);}catch{return;} - if(this.ws!==ws)return; - this.lastSeen=Date.now(); - if(m.type==='ready') { - this.identity.code=m.thread.code;this.storage.setItem(this.key+'-thread',JSON.stringify(this.identity)); - const cloud=ledgerText(m.thread.ledger),local=ledgerText(this.ledger()); - // Never silently replace local work with another device's history. - if(m.thread.ledger&&cloud!==local&&m.thread.revision!==this.revision){this.onStatus(this.identity.code,'History conflict');return;} - this.revision=m.thread.revision;this.ready=true; - if(cloud===local){this.last=local;this.storage.setItem(this.key+'-cloud-revision',String(this.revision));this.storage.setItem(this.key+'-cloud-ledger',local);} - this.onStatus(this.identity.code,'Connected');this.sync();this.update(); - } - if(m.type==='saved') { - this.revision=m.thread.revision;this.last=ledgerText(m.thread.ledger);this.sending=false; - this.storage.setItem(this.key+'-cloud-revision',String(this.revision));this.storage.setItem(this.key+'-cloud-ledger',this.last);this.sync(); - } - if(m.type==='conflict'){this.ready=false;this.sending=false;this.onStatus(this.identity.code,'History conflict');} - if(m.type==='error'){this.ready=false;this.sending=false;this.onStatus(this.identity.code,m.error);} - if(m.type==='command') { - try{if(m.action==='layout'){const result=await this.onCommand(m);this.send({type:'result',id:m.id,...result});return;}if(!this.ready||this.sending)throw Error('Thread is not synchronized');const result=await this.onCommand(m);this.sync();await this.flush();this.send({type:'result',id:m.id,...result});} - catch(error){this.send({type:'result',id:m.id,ok:false,error:error.message});} - this.sync();this.update(); - } - }; - ws.onclose=()=>this.disconnect(ws); - ws.onerror=()=>{}; - } - send(value){if(this.ws?.readyState===1)this.ws.send(JSON.stringify(value));} - sync(){if(!this.ready||this.sending)return;const ledger=this.ledger(),next=ledgerText(ledger);if(next===this.last)return;this.sending=true;this.send({type:'sync',revision:this.revision,ledger});} - update(){if(this.ready)this.send({type:'state',state:this.state()});} - async flush(){for(let i=0;i<100;i++){if(!this.ready)throw Error('Connection lost; inspect history before retrying');if(!this.sending&&this.last===ledgerText(this.ledger()))return;await new Promise(r=>setTimeout(r,100));}throw Error('Version sync pending; inspect before retrying');} - disconnect(ws){if(this.ws!==ws)return;this.ws=null;this.ready=false;this.sending=false;clearInterval(this.heartbeat);this.onStatus(this.identity.code,'Offline');try{ws.close();}catch{}if(this.active)this.timer=setTimeout(()=>this.resume(),this.reconnectMs);} - suspend(){this.active=false;clearTimeout(this.timer);if(this.ws)this.disconnect(this.ws);} -} +// Compatibility import for existing tools and historical test scripts. +export {WhistlegraphThread as WalkiewareThread,threadIdentity,verifyThreadRevision} from './whistlegraph-thread.mjs'; diff --git a/aesel/src/whistlegraph-thread.mjs b/aesel/src/whistlegraph-thread.mjs new file mode 100644 index 0000000000..de8893dc4a --- /dev/null +++ b/aesel/src/whistlegraph-thread.mjs @@ -0,0 +1,67 @@ +// Stable local identity; the server atomically reserves its pronounceable code. +const ledgerText = ledger => ledger?JSON.stringify({format:ledger.format,head:ledger.head,versions:ledger.versions.map(v=>({id:v.id,parent:v.parent,source:v.source,request:v.request??null,createdAt:v.createdAt||'',layers:Number.isInteger(v.layers)?v.layers:0}))}):'null'; +export async function verifyThreadRevision(command,state) { + const before=state();if(before.busy)throw Error('Device busy'); + const digest=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(before.source)); + const hash=[...new Uint8Array(digest)].map(b=>b.toString(16).padStart(2,'0')).join(''); + const after=state(); + if(after.busy||after.head!==before.head||after.source!==before.source||command.baseVersion!==after.head||command.baseHash!==hash)throw Error('Version changed; inspect before editing'); +} +export function threadIdentity(storage,key,uuid=()=>crypto.randomUUID()) { + const saved=storage.getItem(key+'-thread'); + if(saved){const value=JSON.parse(saved);if(typeof value.id!=='string')throw Error('Invalid thread identity');return value;} + const value={id:uuid(),code:null};storage.setItem(key+'-thread',JSON.stringify(value));return value; +} +export class WhistlegraphThread { + constructor({storage,key,token,ledger,state,onStatus,onCommand,WebSocketImpl=globalThis.WebSocket,url='wss://aesthetic.computer/api/whistlegraph-stream',heartbeatMs=15000,maxIdleMs=45000,reconnectMs=3000}) { + Object.assign(this,{storage,key,token,ledger,state,onStatus,onCommand,WebSocketImpl,url,heartbeatMs,maxIdleMs,reconnectMs}); + this.identity=threadIdentity(storage,key);this.revision=Number(storage.getItem(key+'-cloud-revision')||0);this.last=storage.getItem(key+'-cloud-ledger')||''; + this.active=false;this.sending=false;this.ready=false;this.connectURL=url; + } + async resume() { + this.active=true;if(this.ws)return; + const token=await this.token();if(!token||!this.active||this.ws)return; + const ws=this.ws=new this.WebSocketImpl(this.connectURL);let opened=false; + this.lastSeen=Date.now(); + this.heartbeat=setInterval(()=>{if(Date.now()-this.lastSeen>this.maxIdleMs)this.disconnect(ws);else if(this.ready)this.send({type:'ping'});},this.heartbeatMs); + ws.onopen=()=>{opened=true;this.send({type:'authenticate',role:'device',token,id:this.identity.id});}; + ws.onmessage=async event=>{ + let m;try{m=JSON.parse(event.data);}catch{return;} + if(this.ws!==ws)return; + this.lastSeen=Date.now(); + if(m.type==='ready') { + this.identity.code=m.thread.code;this.storage.setItem(this.key+'-thread',JSON.stringify(this.identity)); + const cloud=ledgerText(m.thread.ledger),local=ledgerText(this.ledger()); + // Never silently replace local work with another device's history. + if(m.thread.ledger&&cloud!==local&&m.thread.revision!==this.revision){this.onStatus(this.identity.code,'History conflict');return;} + this.revision=m.thread.revision;this.ready=true; + if(cloud===local){this.last=local;this.storage.setItem(this.key+'-cloud-revision',String(this.revision));this.storage.setItem(this.key+'-cloud-ledger',local);} + this.onStatus(this.identity.code,'Connected');this.sync();this.update(); + } + if(m.type==='saved') { + this.revision=m.thread.revision;this.last=ledgerText(m.thread.ledger);this.sending=false; + this.storage.setItem(this.key+'-cloud-revision',String(this.revision));this.storage.setItem(this.key+'-cloud-ledger',this.last);this.sync(); + } + if(m.type==='conflict'){this.ready=false;this.sending=false;this.onStatus(this.identity.code,'History conflict');} + if(m.type==='error'){this.ready=false;this.sending=false;this.onStatus(this.identity.code,m.error);} + if(m.type==='command') { + try{if(m.action==='layout'){const result=await this.onCommand(m);this.send({type:'result',id:m.id,...result});return;}if(!this.ready||this.sending)throw Error('Thread is not synchronized');const result=await this.onCommand(m);this.sync();await this.flush();this.send({type:'result',id:m.id,...result});} + catch(error){this.send({type:'result',id:m.id,ok:false,error:error.message});} + this.sync();this.update(); + } + }; + ws.onclose=()=>{ + // An installed phone can update before the service route is deployed. + // Fall back only within the same first-party host, before authentication. + if(this.ws===ws&&!opened&&this.connectURL==='wss://aesthetic.computer/api/whistlegraph-stream')this.connectURL='wss://aesthetic.computer/api/walkieware-stream'; + this.disconnect(ws); + }; + ws.onerror=()=>{}; + } + send(value){if(this.ws?.readyState===1)this.ws.send(JSON.stringify(value));} + sync(){if(!this.ready||this.sending)return;const ledger=this.ledger(),next=ledgerText(ledger);if(next===this.last)return;this.sending=true;this.send({type:'sync',revision:this.revision,ledger});} + update(){if(this.ready)this.send({type:'state',state:this.state()});} + async flush(){for(let i=0;i<100;i++){if(!this.ready)throw Error('Connection lost; inspect history before retrying');if(!this.sending&&this.last===ledgerText(this.ledger()))return;await new Promise(r=>setTimeout(r,100));}throw Error('Version sync pending; inspect before retrying');} + disconnect(ws){if(this.ws!==ws)return;this.ws=null;this.ready=false;this.sending=false;clearInterval(this.heartbeat);this.onStatus(this.identity.code,'Offline');try{ws.close();}catch{}if(this.active)this.timer=setTimeout(()=>this.resume(),this.reconnectMs);} + suspend(){this.active=false;clearTimeout(this.timer);if(this.ws)this.disconnect(this.ws);} +} diff --git a/aesel/test/ac-server.test.mjs b/aesel/test/ac-server.test.mjs index 70f89c6bd3..9e70a48567 100644 --- a/aesel/test/ac-server.test.mjs +++ b/aesel/test/ac-server.test.mjs @@ -4,6 +4,24 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { AcServer } from "../src/ac-server.mjs"; +import {wareControls} from '../../apple/whistlegraph/Resources/Web/wares.mjs'; + +test('hosted brain advertises and dispatches ware controls without writing the piece',async t=>{ + const dir=await mkdtemp(join(tmpdir(),'ac-ware-'));t.after(()=>rm(dir,{recursive:true,force:true})); + const file=join(dir,'piece.mjs'),source='// existing work\n';await writeFile(file,source); + const controls=wareControls('piece'),requests=[]; + const serve=serving([ + {type:'content_block_start',index:0,content_block:{type:'tool_use',id:'ware-1',name:'whistlegraph_ware'}}, + {type:'content_block_delta',index:0,delta:{type:'input_json_delta',partial_json:JSON.stringify({action:'switch',ware:'roblox'})}}, + {type:'content_block_stop',index:0}, + {type:'message_delta',delta:{stop_reason:'tool_use'}}, + ],say('Opening your Roblox room.')); + const engine=new AcServer({piece:{file},controls,token:async()=>'fixture',fetch:async(url,options)=>{requests.push(JSON.parse(options.body));return serve();}}); + await engine.startTurn('Let me work in my Roblox room'); + assert.ok(requests[0].tools.some(t=>t.name==='whistlegraph_ware')); + assert.equal(controls.pending,'roblox');assert.equal(await readFile(file,'utf8'),source); + assert.ok(JSON.stringify(requests[1].messages).includes('queued')); +}); // A stand-in for the endpoint: hands back whatever SSE the test wants, so the // loop can be driven through paths a live server would be slow or costly to diff --git a/aesel/test/musical-jev.test.mjs b/aesel/test/musical-jev.test.mjs index b0e41ef732..6508f34c36 100644 --- a/aesel/test/musical-jev.test.mjs +++ b/aesel/test/musical-jev.test.mjs @@ -4,7 +4,7 @@ import {MusicalInputAdvisor} from '../src/musical-input-advisor.mjs'; import {createMusicalHandler,musicalBudget} from '../../system/backend/easel-musical-jev.mjs'; const input={transcript:'PRIVATE WORDS',words:[{atMs:0,durationMs:200}],sound:{frames:[{atMs:400,rms:.2,pitchHz:440},{atMs:600,rms:.2,pitchHz:660},{atMs:800,rms:.2,pitchHz:880}],onsetsMs:[400]}}; const features=summarizeMusicalInput(input); -const event={httpMethod:'POST',headers:{authorization:'test'},body:JSON.stringify({schema:'walkieware-input/v1',sessionId:crypto.randomUUID(),sequence:1,features})}; +const event={httpMethod:'POST',headers:{authorization:'test'},body:JSON.stringify({schema:'whistlegraph-input/v1',sessionId:crypto.randomUUID(),sequence:1,features})}; const answer={answers:{mapping:{choice:'follow_speech',probabilities:{follow_speech:.94}}}}; test('server minimizes evidence and echoes observation identity',async()=>{ const handler=createMusicalHandler({authenticate:async()=> 'subject',budget:{consume:async()=>true},evaluate:async request=>{assert.doesNotMatch(JSON.stringify(request),/PRIVATE|subject/);return answer;}}); @@ -20,14 +20,14 @@ test('budget failure, provider failure and invalid account never fabricate advic assert.equal((await createMusicalHandler({...base,budget:{consume:async()=>true},evaluate:async()=>{throw Error('secret');}})(event)).statusCode,503); }); test('streaming advice is cached for matching musical state without sending words',async()=>{ - let calls=0;const advisor=new MusicalInputAdvisor({token:()=> 'test',fetchImpl:async(_,options)=>{calls++;assert.doesNotMatch(options.body,/PRIVATE|transcript|pitchHz/);const b=JSON.parse(options.body);return Response.json({...b,schema:'walkieware-decision/v1',choice:'follow_speech',confidence:.94});}}); + let calls=0;const advisor=new MusicalInputAdvisor({token:()=> 'test',fetchImpl:async(_,options)=>{calls++;assert.doesNotMatch(options.body,/PRIVATE|transcript|pitchHz/);const b=JSON.parse(options.body);return Response.json({...b,schema:'whistlegraph-decision/v1',choice:'follow_speech',confidence:.94});}}); advisor.observe(input);await advisor.pending.work;const r=await advisor.finish(input);assert.equal(r.choice,'follow_speech');assert.equal(calls,1); }); test('late responses, low confidence and timeouts cannot steer',async()=>{ let resolve;const a=new MusicalInputAdvisor({token:()=> 'test',timeoutMs:15,fetchImpl:()=>new Promise(r=>resolve=r)}); const p=a.finish(input);await Promise.resolve();a.cancel();resolve(Response.json({choice:'sustain',confidence:1}));assert.equal(await p,null);assert.equal(a.cache,null); const stalled=new MusicalInputAdvisor({token:()=> 'test',timeoutMs:15,fetchImpl:()=>new Promise(()=>{})});assert.equal(await stalled.finish(input),null); - const uncertain=new MusicalInputAdvisor({token:()=> 'test',fetchImpl:async(_,o)=>Response.json({...JSON.parse(o.body),schema:'walkieware-decision/v1',choice:'sustain',confidence:.2})});assert.equal(await uncertain.finish(input),null); + const uncertain=new MusicalInputAdvisor({token:()=> 'test',fetchImpl:async(_,o)=>Response.json({...JSON.parse(o.body),schema:'whistlegraph-decision/v1',choice:'sustain',confidence:.2})});assert.equal(await uncertain.finish(input),null); }); test('feature summaries separate speech interval from later sound',()=>{assert.equal(features.hasSpeech,true);assert.equal(features.soundAfterSpeech,true);assert.equal(features.contour,'rising');assert.equal(features.attacks,1);}); test('atomic quota reservation refuses a full counter',async()=>{ @@ -35,11 +35,11 @@ test('atomic quota reservation refuses a full counter',async()=>{ assert.equal(await budget.consume('subject',Date.now()),false);assert.equal(increments,1); }); test('default fetch keeps its global receiver for Safari',async t=>{ - t.mock.method(globalThis,'fetch',async function(_,o){assert.equal(this,globalThis);const b=JSON.parse(o.body);return Response.json({...b,schema:'walkieware-decision/v1',choice:'follow_speech',confidence:.95});}); + t.mock.method(globalThis,'fetch',async function(_,o){assert.equal(this,globalThis);const b=JSON.parse(o.body);return Response.json({...b,schema:'whistlegraph-decision/v1',choice:'follow_speech',confidence:.95});}); const advisor=new MusicalInputAdvisor({token:()=> 'test'});assert.equal((await advisor.finish(input)).choice,'follow_speech'); }); test('changed final observations request fresh advice instead of reusing the earlier state',async()=>{ - const bodies=[];const a=new MusicalInputAdvisor({token:()=> 'test',fetchImpl:async(_,o)=>{const b=JSON.parse(o.body);bodies.push(b);return Response.json({...b,schema:'walkieware-decision/v1',choice:b.features.hasSpeech?'follow_speech':'trace_pitch',confidence:.95});}}); + const bodies=[];const a=new MusicalInputAdvisor({token:()=> 'test',fetchImpl:async(_,o)=>{const b=JSON.parse(o.body);bodies.push(b);return Response.json({...b,schema:'whistlegraph-decision/v1',choice:b.features.hasSpeech?'follow_speech':'trace_pitch',confidence:.95});}}); a.observe(input);await a.pending.work;const result=await a.finish({...input,transcript:'',words:[]}); assert.equal(result.choice,'trace_pitch');assert.equal(bodies.length,2);assert.notEqual(bodies[0].sessionId,bodies[1].sessionId); }); diff --git a/apple/walkieware/Resources/Web/generation-policy.mjs b/apple/walkieware/Resources/Web/generation-policy.mjs deleted file mode 100644 index bd280aeced..0000000000 --- a/apple/walkieware/Resources/Web/generation-policy.mjs +++ /dev/null @@ -1,2 +0,0 @@ -export const DEFAULT_MODEL = 'deepseek/deepseek-v4.1-flash'; -export const GENERATION_INSTRUCTIONS = 'This surface is Walkieware. The person watches the piece being made. A local starter may already be visible: its source is the real current piece; refine it instead of discarding it. For an existing piece, make small revision-checked edit_piece calls instead of resending unchanged code. Use write_piece to create the initial piece or for a necessary rewrite. No prose preamble. Each ask becomes one internal version; complete edit checkpoints are preview layers within it. Make the first visible working drawing in 3–6 lines whenever possible. The UI compiles runnable module prefixes during streaming: put required declarations and a complete exported paint function first; avoid long data tables or helper code before that first drawing. Then refine in small complete write_piece checkpoints only as needed to satisfy the request. Do not add unrequested animation or features. For animation use Number(paintCount) from the paint API, or explicit state advanced in sim; there is no numeric frame API. A request for a spoken narrator needs audible speak(), not just written notes; use the speech guide. Use AC ink alpha on the 0–255 scale and size geometry relative to the screen. Do not repeat the prompt or add interaction instructions, help text, usage hints, or captions inside or outside the picture unless explicitly requested. Use the default write font for other in-piece text unless the person requests another. Use ac_preview for runtime observations; this phone has no desktop frame-capture service. Once the requested change is painted and checked, finish the turn. Never fake progress or tests. Keep one line `export const caption = "…"` in the piece: a plain sentence under 120 characters naming the subject, its mood, what moves and what the person can do; update it whenever an edit changes any of that. It is the piece\'s memory of itself and comes back to you with every follow-up. Preserve the current piece and the original intent of the selected branch on revisions. Short follow-ups modify that existing world; do not replace its subject, characters, message, interactions, or sound without a request to do so. Treat uncertain speech transcriptions conservatively in context instead of inventing a new subject from an ambiguous word. Adjectives of culture, nationality or place (Latina, Japanese, Nordic, tropical) describe how things look: palette, patterns, dress, flora, light. They never change the language of any text or speech unless the request mentions words, text, language or voice. Do not turn ordinary spoken requests into text posters, transcript timelines, waveform charts, or word-timed animation unless explicitly requested. No publishing: these are private local previews.'; diff --git a/apple/walkieware/Tests/space-start.mjs b/apple/walkieware/Tests/space-start.mjs deleted file mode 100644 index 2579dc255f..0000000000 --- a/apple/walkieware/Tests/space-start.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import {randomUUID} from 'node:crypto';import {mkdirSync,writeFileSync} from 'node:fs';import {spawnSync} from 'node:child_process'; -const runID=randomUUID().toUpperCase(),root=`apple/walkieware/Tests/spaces/${runID}`;mkdirSync(root,{recursive:true}); -const env={WALKIE_AUDIO_TEST:'0',WALKIE_NETWORK_TEST:'0',WALKIE_LOCAL_SEQUENCE:'0',WALKIE_SCENE_TEST:'0',WALKIE_SPACE_TEST:'1',WALKIE_SEQUENCE_TEST:'1',WALKIE_SEQUENCE_START:process.env.WALKIE_SEQUENCE_START||'1',WALKIE_RUN_ID:runID}; -const r=spawnSync('xcrun',['devicectl','--timeout','20','device','process','launch','--device','00008120-0016501111A2201E','--terminate-existing','--environment-variables',JSON.stringify(env),'computer.aesthetic.walkieware'],{encoding:'utf8',timeout:25000}); -if(r.status!==0)throw Error(r.stderr||r.error?.message||'Launch failed'); -writeFileSync('/tmp/walkieware-space-run.json',JSON.stringify({runID,root}));console.log(JSON.stringify({runID,root})); diff --git a/apple/walkieware/Tests/thread-capture.cjs b/apple/walkieware/Tests/thread-capture.cjs deleted file mode 100644 index 81a060b1e8..0000000000 --- a/apple/walkieware/Tests/thread-capture.cjs +++ /dev/null @@ -1,2 +0,0 @@ -const puppeteer=require('puppeteer');const {readFile,mkdir,writeFile}=require('node:fs/promises');const {resolve}=require('node:path'); -(async()=>{const {endpoint}=JSON.parse(await readFile('/tmp/walkieware-browser-cdp.json','utf8'));const browser=await puppeteer.connect({browserWSEndpoint:endpoint,defaultViewport:null});try{const page=(await browser.pages()).find(p=>p.url().includes('127.0.0.1'));const root=resolve(__dirname,'threads/wwLehoh');await mkdir(root,{recursive:true});const clip=await page.$eval('#live-piece',e=>{const r=e.getBoundingClientRect();return{x:r.x,y:r.y,width:r.width,height:r.height};});const began=Date.now(),times=[];for(let i=0;i<48;i++){times.push(Date.now()-began);await page.screenshot({path:root+`/frame-${i}.png`,clip,captureBeyondViewport:false});await new Promise(r=>setTimeout(r,200));}await writeFile(root+'/frame-times.json',JSON.stringify(times));console.log({frames:times.length,durationMs:times.at(-1),clip});}finally{browser.disconnect();}})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/apple/walkieware/Walkieware.xcodeproj/project.pbxproj b/apple/walkieware/Walkieware.xcodeproj/project.pbxproj deleted file mode 100644 index 2c9175e4f3..0000000000 --- a/apple/walkieware/Walkieware.xcodeproj/project.pbxproj +++ /dev/null @@ -1,522 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXBuildFile section */ - 09A89EDEB03772B566D345A9 /* PlaybackWord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50D5B994181BB8DD173DBA13 /* PlaybackWord.swift */; }; - 0DAF293EA0DCA59330C836C6 /* InputButtonLabels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49B317E893F3D8506A3F246D /* InputButtonLabels.swift */; }; - 27410D22F96D8006B6D29452 /* VersionNarrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8E12122840B2375C0AB2ED0 /* VersionNarrator.swift */; }; - 28DE33D8B94B853B408571A1 /* WalkiewareAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3EC37299741D5E90E45EE5F /* WalkiewareAccount.swift */; }; - 349E23AC69F23D16290DCB48 /* NetworkBenchmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690D4836B59F77E01D9326D4 /* NetworkBenchmark.swift */; }; - 386E3D38C79AB6CF23166708 /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 5E4DE7A1D969E42D910F4BCB /* Fixtures */; }; - 3BF2B70E0C018C0D43C0134A /* WalkiewareScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 220B6400DBD8900B5058A7F0 /* WalkiewareScreen.swift */; }; - 3D181C343F0CF5296E1C4035 /* SwipeToTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA4916078A47F08CDF99B5E /* SwipeToTypeTests.swift */; }; - 465DCE826B91CD18CE48CEF7 /* PlaybackCaption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C614C7FAFB7C430BF574D1B9 /* PlaybackCaption.swift */; }; - 4EDE3317EA5C40F16CB7E32A /* WalkiewareTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BB854B6224E9C6EC2FC901D /* WalkiewareTheme.swift */; }; - 5FD9722A7E3D5329D7B1E770 /* CodeTicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654A6A5F8172661179904170 /* CodeTicker.swift */; }; - 6380DEC1F53B213D184FD656 /* WalkiewareHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D832B5A1A03E760A43BBF8 /* WalkiewareHeader.swift */; }; - 6D7B5EBEDBC462D5029C784D /* Web in Resources */ = {isa = PBXBuildFile; fileRef = 315FD6C024E525106C7FD3BE /* Web */; }; - 7EC06C94440CDAF9B846F0BB /* SequenceBenchmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06F16D0B24CA61442FFD0E27 /* SequenceBenchmark.swift */; }; - 8681CA8AC5E530EAD0589219 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 110077D3E4AF92775C4E6D74 /* Assets.xcassets */; }; - 8CD6B532DB3A5B03BC35EEDD /* HeaderSheetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67B2C6F3854EDC996115B080 /* HeaderSheetsTests.swift */; }; - 91EBE2405BCB456959613E70 /* RecordingTrim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35720E8232DBFB5B795FBEEA /* RecordingTrim.swift */; }; - 97AB97086118FC695F4B2133 /* AudioBenchmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45F6D30D7883EC26BF391CC7 /* AudioBenchmark.swift */; }; - 99B11F258C6DD8DEAD32A419 /* MusicalInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A7C5768F0EE9F4A8A288767 /* MusicalInput.swift */; }; - B01CCEF94DAC4203687C68CB /* SpeechCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 054D4872CAC359CCAFE93F95 /* SpeechCapture.swift */; }; - C57CC34DE256A25EDF176C4A /* NativeScreenFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF403AD7661862BCAC9B2F95 /* NativeScreenFixture.swift */; }; - E0B6A4DB98A5C44344C74589 /* WorkspaceCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D40BA9A3C78CC0324903812B /* WorkspaceCoordinator.swift */; }; - F1E262539EDCC86364A1B426 /* UtteranceRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A3D6D0D1C0C32051B6C6AF4 /* UtteranceRecording.swift */; }; - F9A4BC365780D687A6B8CADB /* NativeSignIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC5AD9D9D7F376F72D8137A3 /* NativeSignIn.swift */; }; - FC34834B44AA5AF9FDB73AC0 /* WalkiewareApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBA7AE20BF7B4A303BD2F09A /* WalkiewareApp.swift */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 6F13103C020139BADAEDA415 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 8084B60DC12031BB4CBCF868 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 42E03A3EE36136CA4C61FBD8; - remoteInfo = Walkieware; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXFileReference section */ - 03D832B5A1A03E760A43BBF8 /* WalkiewareHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalkiewareHeader.swift; sourceTree = ""; }; - 054D4872CAC359CCAFE93F95 /* SpeechCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechCapture.swift; sourceTree = ""; }; - 06F16D0B24CA61442FFD0E27 /* SequenceBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SequenceBenchmark.swift; sourceTree = ""; }; - 110077D3E4AF92775C4E6D74 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 1A7C5768F0EE9F4A8A288767 /* MusicalInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicalInput.swift; sourceTree = ""; }; - 220B6400DBD8900B5058A7F0 /* WalkiewareScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalkiewareScreen.swift; sourceTree = ""; }; - 315FD6C024E525106C7FD3BE /* Web */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Web; path = Resources/Web; sourceTree = SOURCE_ROOT; }; - 35720E8232DBFB5B795FBEEA /* RecordingTrim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingTrim.swift; sourceTree = ""; }; - 3EA4916078A47F08CDF99B5E /* SwipeToTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeToTypeTests.swift; sourceTree = ""; }; - 45F6D30D7883EC26BF391CC7 /* AudioBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBenchmark.swift; sourceTree = ""; }; - 49B317E893F3D8506A3F246D /* InputButtonLabels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputButtonLabels.swift; sourceTree = ""; }; - 4A3D6D0D1C0C32051B6C6AF4 /* UtteranceRecording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UtteranceRecording.swift; sourceTree = ""; }; - 50D5B994181BB8DD173DBA13 /* PlaybackWord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackWord.swift; sourceTree = ""; }; - 5E4DE7A1D969E42D910F4BCB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Fixtures; path = Resources/Fixtures; sourceTree = SOURCE_ROOT; }; - 654A6A5F8172661179904170 /* CodeTicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeTicker.swift; sourceTree = ""; }; - 67B2C6F3854EDC996115B080 /* HeaderSheetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderSheetsTests.swift; sourceTree = ""; }; - 690D4836B59F77E01D9326D4 /* NetworkBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkBenchmark.swift; sourceTree = ""; }; - 706045AC87F69577059E617E /* Walkieware.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Walkieware.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 7BB854B6224E9C6EC2FC901D /* WalkiewareTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalkiewareTheme.swift; sourceTree = ""; }; - 92FF8B832524B3AC4D8291F7 /* WalkiewareUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = WalkiewareUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - C614C7FAFB7C430BF574D1B9 /* PlaybackCaption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackCaption.swift; sourceTree = ""; }; - CBA7AE20BF7B4A303BD2F09A /* WalkiewareApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalkiewareApp.swift; sourceTree = ""; }; - D40BA9A3C78CC0324903812B /* WorkspaceCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceCoordinator.swift; sourceTree = ""; }; - E8E12122840B2375C0AB2ED0 /* VersionNarrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionNarrator.swift; sourceTree = ""; }; - F3EC37299741D5E90E45EE5F /* WalkiewareAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalkiewareAccount.swift; sourceTree = ""; }; - FC5AD9D9D7F376F72D8137A3 /* NativeSignIn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSignIn.swift; sourceTree = ""; }; - FF403AD7661862BCAC9B2F95 /* NativeScreenFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeScreenFixture.swift; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXGroup section */ - 64241EA4D5013A2F023B9C2B /* Products */ = { - isa = PBXGroup; - children = ( - 706045AC87F69577059E617E /* Walkieware.app */, - 92FF8B832524B3AC4D8291F7 /* WalkiewareUITests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 759B7C408B1B0527C2AD8111 /* Sources */ = { - isa = PBXGroup; - children = ( - 45F6D30D7883EC26BF391CC7 /* AudioBenchmark.swift */, - 654A6A5F8172661179904170 /* CodeTicker.swift */, - 49B317E893F3D8506A3F246D /* InputButtonLabels.swift */, - 1A7C5768F0EE9F4A8A288767 /* MusicalInput.swift */, - FF403AD7661862BCAC9B2F95 /* NativeScreenFixture.swift */, - 690D4836B59F77E01D9326D4 /* NetworkBenchmark.swift */, - C614C7FAFB7C430BF574D1B9 /* PlaybackCaption.swift */, - 50D5B994181BB8DD173DBA13 /* PlaybackWord.swift */, - 35720E8232DBFB5B795FBEEA /* RecordingTrim.swift */, - 06F16D0B24CA61442FFD0E27 /* SequenceBenchmark.swift */, - 054D4872CAC359CCAFE93F95 /* SpeechCapture.swift */, - 4A3D6D0D1C0C32051B6C6AF4 /* UtteranceRecording.swift */, - E8E12122840B2375C0AB2ED0 /* VersionNarrator.swift */, - F3EC37299741D5E90E45EE5F /* WalkiewareAccount.swift */, - CBA7AE20BF7B4A303BD2F09A /* WalkiewareApp.swift */, - 03D832B5A1A03E760A43BBF8 /* WalkiewareHeader.swift */, - 220B6400DBD8900B5058A7F0 /* WalkiewareScreen.swift */, - 7BB854B6224E9C6EC2FC901D /* WalkiewareTheme.swift */, - D40BA9A3C78CC0324903812B /* WorkspaceCoordinator.swift */, - ); - path = Sources; - sourceTree = ""; - }; - 8FD6F530EA415417DBA563F3 /* UITests */ = { - isa = PBXGroup; - children = ( - 67B2C6F3854EDC996115B080 /* HeaderSheetsTests.swift */, - 3EA4916078A47F08CDF99B5E /* SwipeToTypeTests.swift */, - ); - path = UITests; - sourceTree = ""; - }; - B5485CCC09007955BCE3FAD6 /* Resources */ = { - isa = PBXGroup; - children = ( - 110077D3E4AF92775C4E6D74 /* Assets.xcassets */, - ); - path = Resources; - sourceTree = ""; - }; - BE17EABD594C8C886A6D0245 /* Sources */ = { - isa = PBXGroup; - children = ( - FC5AD9D9D7F376F72D8137A3 /* NativeSignIn.swift */, - ); - name = Sources; - path = ../aesel/Sources; - sourceTree = ""; - }; - C071A173F178420C7C9D1E5B = { - isa = PBXGroup; - children = ( - 5E4DE7A1D969E42D910F4BCB /* Fixtures */, - 315FD6C024E525106C7FD3BE /* Web */, - B5485CCC09007955BCE3FAD6 /* Resources */, - 759B7C408B1B0527C2AD8111 /* Sources */, - BE17EABD594C8C886A6D0245 /* Sources */, - 8FD6F530EA415417DBA563F3 /* UITests */, - 64241EA4D5013A2F023B9C2B /* Products */, - ); - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 42E03A3EE36136CA4C61FBD8 /* Walkieware */ = { - isa = PBXNativeTarget; - buildConfigurationList = 9AB1AE4E54E1BFC66F8BBF63 /* Build configuration list for PBXNativeTarget "Walkieware" */; - buildPhases = ( - A94692E5600895DA74C938CE /* Sources */, - 4974A5C50239A88086F1106C /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Walkieware; - packageProductDependencies = ( - ); - productName = Walkieware; - productReference = 706045AC87F69577059E617E /* Walkieware.app */; - productType = "com.apple.product-type.application"; - }; - 81D2432862B40D4001794C08 /* WalkiewareUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 34421FD1E3396B200792DD4F /* Build configuration list for PBXNativeTarget "WalkiewareUITests" */; - buildPhases = ( - 73CBE11E83DAED65CF9C53C2 /* Sources */, - ); - buildRules = ( - ); - dependencies = ( - E923111DA65188CE1920567F /* PBXTargetDependency */, - ); - name = WalkiewareUITests; - packageProductDependencies = ( - ); - productName = WalkiewareUITests; - productReference = 92FF8B832524B3AC4D8291F7 /* WalkiewareUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 8084B60DC12031BB4CBCF868 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1430; - TargetAttributes = { - 42E03A3EE36136CA4C61FBD8 = { - DevelopmentTeam = FB5948YR3S; - ProvisioningStyle = Automatic; - }; - 81D2432862B40D4001794C08 = { - DevelopmentTeam = FB5948YR3S; - ProvisioningStyle = Automatic; - TestTargetID = 42E03A3EE36136CA4C61FBD8; - }; - }; - }; - buildConfigurationList = C8AEC1C5A0A8FB1D9040DACF /* Build configuration list for PBXProject "Walkieware" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - Base, - en, - ); - mainGroup = C071A173F178420C7C9D1E5B; - minimizedProjectReferenceProxies = 1; - preferredProjectObjectVersion = 77; - productRefGroup = 64241EA4D5013A2F023B9C2B /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 42E03A3EE36136CA4C61FBD8 /* Walkieware */, - 81D2432862B40D4001794C08 /* WalkiewareUITests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 4974A5C50239A88086F1106C /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 8681CA8AC5E530EAD0589219 /* Assets.xcassets in Resources */, - 386E3D38C79AB6CF23166708 /* Fixtures in Resources */, - 6D7B5EBEDBC462D5029C784D /* Web in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 73CBE11E83DAED65CF9C53C2 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 8CD6B532DB3A5B03BC35EEDD /* HeaderSheetsTests.swift in Sources */, - 3D181C343F0CF5296E1C4035 /* SwipeToTypeTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - A94692E5600895DA74C938CE /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97AB97086118FC695F4B2133 /* AudioBenchmark.swift in Sources */, - 5FD9722A7E3D5329D7B1E770 /* CodeTicker.swift in Sources */, - 0DAF293EA0DCA59330C836C6 /* InputButtonLabels.swift in Sources */, - 99B11F258C6DD8DEAD32A419 /* MusicalInput.swift in Sources */, - C57CC34DE256A25EDF176C4A /* NativeScreenFixture.swift in Sources */, - F9A4BC365780D687A6B8CADB /* NativeSignIn.swift in Sources */, - 349E23AC69F23D16290DCB48 /* NetworkBenchmark.swift in Sources */, - 465DCE826B91CD18CE48CEF7 /* PlaybackCaption.swift in Sources */, - 09A89EDEB03772B566D345A9 /* PlaybackWord.swift in Sources */, - 91EBE2405BCB456959613E70 /* RecordingTrim.swift in Sources */, - 7EC06C94440CDAF9B846F0BB /* SequenceBenchmark.swift in Sources */, - B01CCEF94DAC4203687C68CB /* SpeechCapture.swift in Sources */, - F1E262539EDCC86364A1B426 /* UtteranceRecording.swift in Sources */, - 27410D22F96D8006B6D29452 /* VersionNarrator.swift in Sources */, - 28DE33D8B94B853B408571A1 /* WalkiewareAccount.swift in Sources */, - FC34834B44AA5AF9FDB73AC0 /* WalkiewareApp.swift in Sources */, - 6380DEC1F53B213D184FD656 /* WalkiewareHeader.swift in Sources */, - 3BF2B70E0C018C0D43C0134A /* WalkiewareScreen.swift in Sources */, - 4EDE3317EA5C40F16CB7E32A /* WalkiewareTheme.swift in Sources */, - E0B6A4DB98A5C44344C74589 /* WorkspaceCoordinator.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - E923111DA65188CE1920567F /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 42E03A3EE36136CA4C61FBD8 /* Walkieware */; - targetProxy = 6F13103C020139BADAEDA415 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin XCBuildConfiguration section */ - 55581D923FD71085A5BE5C00 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "iPhone Developer"; - INFOPLIST_FILE = Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = computer.aesthetic.walkieware; - PRODUCT_NAME = Walkieware; - SDKROOT = iphoneos; - SUPPORTS_MACCATALYST = NO; - TARGETED_DEVICE_FAMILY = 1; - }; - name = Debug; - }; - 64033FF21ACF026231FDE1EC /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - GENERATE_INFOPLIST_FILE = YES; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = computer.aesthetic.walkieware.uitests; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Walkieware; - }; - name = Debug; - }; - 74FA50C11A066D9925210D70 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - GENERATE_INFOPLIST_FILE = YES; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = computer.aesthetic.walkieware.uitests; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = Walkieware; - }; - name = Release; - }; - CE4FAD40D07521935A8ABD31 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "iPhone Developer"; - INFOPLIST_FILE = Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = computer.aesthetic.walkieware; - PRODUCT_NAME = Walkieware; - SDKROOT = iphoneos; - SUPPORTS_MACCATALYST = NO; - TARGETED_DEVICE_FAMILY = 1; - }; - name = Release; - }; - D9BC626811DF91C4D14568C9 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_STYLE = Automatic; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 82; - DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = FB5948YR3S; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "$(inherited)", - "DEBUG=1", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 0.1.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - DC1BE817F31584CABAB3C583 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_STYLE = Automatic; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 82; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = FB5948YR3S; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 0.1.0; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 34421FD1E3396B200792DD4F /* Build configuration list for PBXNativeTarget "WalkiewareUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 64033FF21ACF026231FDE1EC /* Debug */, - 74FA50C11A066D9925210D70 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; - 9AB1AE4E54E1BFC66F8BBF63 /* Build configuration list for PBXNativeTarget "Walkieware" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 55581D923FD71085A5BE5C00 /* Debug */, - CE4FAD40D07521935A8ABD31 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; - C8AEC1C5A0A8FB1D9040DACF /* Build configuration list for PBXProject "Walkieware" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - D9BC626811DF91C4D14568C9 /* Debug */, - DC1BE817F31584CABAB3C583 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; - }; -/* End XCConfigurationList section */ - }; - rootObject = 8084B60DC12031BB4CBCF868 /* Project object */; -} diff --git a/apple/walkieware/.gitignore b/apple/whistlegraph/.gitignore similarity index 84% rename from apple/walkieware/.gitignore rename to apple/whistlegraph/.gitignore index d7ff1e2b55..db9ec1ec5d 100644 --- a/apple/walkieware/.gitignore +++ b/apple/whistlegraph/.gitignore @@ -10,3 +10,5 @@ Tests/threads/ Tests/native-shell/ Tests/scenes/ Tests/spaces/ +Resources/Web/room-schema.mjs +Resources/Web/roblox-runtime.mjs diff --git a/apple/walkieware/Info.plist b/apple/whistlegraph/Info.plist similarity index 51% rename from apple/walkieware/Info.plist rename to apple/whistlegraph/Info.plist index abca691966..659905fdfd 100644 --- a/apple/walkieware/Info.plist +++ b/apple/whistlegraph/Info.plist @@ -1,6 +1,6 @@ -CFBundleDevelopmentRegionenCFBundleExecutable$(EXECUTABLE_NAME)CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER)CFBundleNameWalkiewareCFBundleDisplayNameWalkiewareCFBundlePackageTypeAPPLCFBundleShortVersionString$(MARKETING_VERSION)CFBundleVersion$(CURRENT_PROJECT_VERSION)UILaunchScreen -NSMicrophoneUsageDescriptionHold the talk button to tell Walkieware what to make. Recording stops when you release it. -NSSpeechRecognitionUsageDescriptionWalkieware turns your spoken request into text. This demo requires on-device speech recognition and does not save audio. +CFBundleDevelopmentRegionenCFBundleExecutable$(EXECUTABLE_NAME)CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER)CFBundleNameWhistlegraphCFBundleDisplayNameWhistlegraphCFBundlePackageTypeAPPLCFBundleShortVersionString$(MARKETING_VERSION)CFBundleVersion$(CURRENT_PROJECT_VERSION)UILaunchScreen +NSMicrophoneUsageDescriptionHold the talk button to tell Whistlegraph what to make. Recording stops when you release it. +NSSpeechRecognitionUsageDescriptionWhistlegraph uses on-device speech recognition to turn your spoken request into text. Recordings stay on this phone. UISupportedInterfaceOrientationsUIInterfaceOrientationPortraitUIUserInterfaceStyleDarkITSAppUsesNonExemptEncryption diff --git a/apple/walkieware/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apple/whistlegraph/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from apple/walkieware/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json rename to apple/whistlegraph/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/apple/walkieware/Resources/Assets.xcassets/AppIcon.appiconset/icon.png b/apple/whistlegraph/Resources/Assets.xcassets/AppIcon.appiconset/icon.png similarity index 100% rename from apple/walkieware/Resources/Assets.xcassets/AppIcon.appiconset/icon.png rename to apple/whistlegraph/Resources/Assets.xcassets/AppIcon.appiconset/icon.png diff --git a/apple/walkieware/Resources/Assets.xcassets/Contents.json b/apple/whistlegraph/Resources/Assets.xcassets/Contents.json similarity index 100% rename from apple/walkieware/Resources/Assets.xcassets/Contents.json rename to apple/whistlegraph/Resources/Assets.xcassets/Contents.json diff --git a/apple/walkieware/Resources/Fixtures/basic-request.wav b/apple/whistlegraph/Resources/Fixtures/basic-request.wav similarity index 100% rename from apple/walkieware/Resources/Fixtures/basic-request.wav rename to apple/whistlegraph/Resources/Fixtures/basic-request.wav diff --git a/apple/walkieware/Resources/Fixtures/mixed-request.wav b/apple/whistlegraph/Resources/Fixtures/mixed-request.wav similarity index 100% rename from apple/walkieware/Resources/Fixtures/mixed-request.wav rename to apple/whistlegraph/Resources/Fixtures/mixed-request.wav diff --git a/apple/walkieware/Resources/Fixtures/sine-tone.wav b/apple/whistlegraph/Resources/Fixtures/sine-tone.wav similarity index 100% rename from apple/walkieware/Resources/Fixtures/sine-tone.wav rename to apple/whistlegraph/Resources/Fixtures/sine-tone.wav diff --git a/apple/walkieware/Resources/Fixtures/whistle-sweep.wav b/apple/whistlegraph/Resources/Fixtures/whistle-sweep.wav similarity index 100% rename from apple/walkieware/Resources/Fixtures/whistle-sweep.wav rename to apple/whistlegraph/Resources/Fixtures/whistle-sweep.wav diff --git a/apple/walkieware/Resources/Web/ComicRelief-Bold.ttf b/apple/whistlegraph/Resources/Web/ComicRelief-Bold.ttf similarity index 100% rename from apple/walkieware/Resources/Web/ComicRelief-Bold.ttf rename to apple/whistlegraph/Resources/Web/ComicRelief-Bold.ttf diff --git a/apple/walkieware/Resources/Web/attempt-recovery.mjs b/apple/whistlegraph/Resources/Web/attempt-recovery.mjs similarity index 100% rename from apple/walkieware/Resources/Web/attempt-recovery.mjs rename to apple/whistlegraph/Resources/Web/attempt-recovery.mjs diff --git a/apple/walkieware/Resources/Web/base-piece.mjs b/apple/whistlegraph/Resources/Web/base-piece.mjs similarity index 78% rename from apple/walkieware/Resources/Web/base-piece.mjs rename to apple/whistlegraph/Resources/Web/base-piece.mjs index e162c79691..bfae30faa5 100644 --- a/apple/walkieware/Resources/Web/base-piece.mjs +++ b/apple/whistlegraph/Resources/Web/base-piece.mjs @@ -1,12 +1,12 @@ import {PieceVersions} from './piece-versions.mjs'; // Pick once per new workspace; the saved v0 keeps its color across launches. const color = '#' + Math.floor(Math.random() * 0x1000000).toString(16).padStart(6, '0'); -export const BASE_PIECE_SOURCE = `// Walkieware v0 — base color. +export const BASE_PIECE_SOURCE = `// Whistlegraph v0 — base color. export function paint({wipe}) { wipe("${color}"); } `; -export const isBasePiece = source => /^\/\/ Walkieware v0 — (?:base color|TV static)\./.test(source || ''); +export const isBasePiece = source => /^\/\/ (?:Whistlegraph|Walkieware) v0 — (?:base color|TV static)\./.test(source || ''); // Only untouched blank histories migrate. Archive their old identity instead // of rewriting an already-synchronized immutable v0. @@ -18,7 +18,7 @@ export function initializeBasePiece(storage, key) { const identity = storage.getItem(key + '-thread'); if (identity) { const parsed = JSON.parse(identity); - storage.setItem('walkieware-archive-' + parsed.id, JSON.stringify({identity: parsed, ledger, source: ''})); + storage.setItem('whistlegraph-archive-' + parsed.id, JSON.stringify({identity: parsed, ledger, source: ''})); } for (const suffix of ['-versions', '-thread', '-cloud-revision', '-cloud-ledger', '-attempt']) storage.removeItem(key + suffix); new PieceVersions(storage, key + '-versions', BASE_PIECE_SOURCE); diff --git a/apple/walkieware/Resources/Web/branch-context.mjs b/apple/whistlegraph/Resources/Web/branch-context.mjs similarity index 100% rename from apple/walkieware/Resources/Web/branch-context.mjs rename to apple/whistlegraph/Resources/Web/branch-context.mjs diff --git a/apple/walkieware/Resources/Web/engine.mjs b/apple/whistlegraph/Resources/Web/engine.mjs similarity index 82% rename from apple/walkieware/Resources/Web/engine.mjs rename to apple/whistlegraph/Resources/Web/engine.mjs index a61426e004..13d1caf4a4 100644 --- a/apple/walkieware/Resources/Web/engine.mjs +++ b/apple/whistlegraph/Resources/Web/engine.mjs @@ -6,22 +6,24 @@ import {initializeBasePiece,isBasePiece} from './base-piece.mjs'; import {handleCharacterColors,fetchHandleColors} from '/easel/src/handle-colors.mjs'; import {verifyAccount} from '/easel/src/account-access.mjs'; import {localEdit} from './local-scene.mjs'; -import {WalkiewareThread,verifyThreadRevision} from '/easel/src/walkieware-thread.mjs'; +import {WhistlegraphThread,verifyThreadRevision} from '/easel/src/whistlegraph-thread.mjs'; import {instantPiece} from './instant-piece.mjs'; import {MusicalInputSocket} from '/easel/src/musical-input-socket.mjs'; import {MusicalInputAdvisor} from '/easel/src/musical-input-advisor.mjs'; import {musicalPrompt} from './musical-input.mjs'; import {PieceVersions} from './piece-versions.mjs'; +import {wareControls,WARE_INSTRUCTIONS,requestedWare} from './wares.mjs'; +const wares=wareControls('piece'); // The same inference/tool loop as Aesel, with a piece-first streaming renderer. import {AcServer} from '/easel/src/ac-server.mjs'; import {DEFAULT_MODEL,GENERATION_INSTRUCTIONS} from './generation-policy.mjs'; import {runnablePrefix} from './stream-preview.mjs'; import * as vfs from '/easel/phone/shim/fs.mjs'; -const post = body => window.webkit.messageHandlers.walkie.postMessage({id:'engine',...body}); -const benchmark=(event,fields={})=>{post({action:'benchmark',event,fields});window.__walkiewareSequenceEvent?.(event,fields);}; -const file = '/piece/walkieware.mjs'; -const storageKey=window.__walkiewareSpace?'walkieware-space-source':window.__walkiewareLocalSequence?'walkieware-local-source':window.__walkiewareSequence?'walkieware-sequence-source':window.__walkiewareBenchmark?'walkieware-benchmark-source':'walkieware-source'; +const post = body => window.webkit.messageHandlers.whistlegraph.postMessage({id:'engine',...body}); +const benchmark=(event,fields={})=>{post({action:'benchmark',event,fields});window.__whistlegraphSequenceEvent?.(event,fields);}; +const file = '/piece/whistlegraph.mjs'; +const storageKey=window.__whistlegraphSpace?'whistlegraph-space-source':window.__whistlegraphLocalSequence?'whistlegraph-local-source':window.__whistlegraphSequence?'whistlegraph-sequence-source':window.__whistlegraphBenchmark?'whistlegraph-benchmark-source':'whistlegraph-source'; let turnStarter='',starterPainted=false,turnCancelled=false; let thread=null,threadTimer=null; const runtimeErrors=[]; @@ -32,7 +34,7 @@ let token = '', busy = false, server, source = '', previous = '', pending = '', let feedback = null, lastPaintedSource = '', previewSource = '', provisional = '', compileTimer = null; let outputStream='',reasoningStream='',code = '', codeItem = '', firstDelta = false, started = 0, timer, ready = false, painted = false; const events = []; -const signIn=document.createElement('button');signIn.id='connect-ac';signIn.textContent='Account';signIn.onclick=()=>post({action:'signIn'});const identity=document.createElement('div');identity.id='walkieware-identity';const codeLabel=document.createElement('span');codeLabel.id='walkieware-thread';identity.append(signIn,codeLabel);document.body.append(identity); +const signIn=document.createElement('button');signIn.id='connect-ac';signIn.textContent='Account';signIn.onclick=()=>post({action:'signIn'});const identity=document.createElement('div');identity.id='whistlegraph-identity';const codeLabel=document.createElement('span');codeLabel.id='whistlegraph-thread';identity.append(signIn,codeLabel);document.body.append(identity); let accountToken='',accountHandle='',accountPalette=[]; function paintHandle(handle,colors=handleCharacterColors('@'+handle)){ accountPalette=colors; @@ -48,7 +50,7 @@ ui.innerHTML = '
document.querySelectorAll('#version-feed time').forEach(t=>t.tex let postPieces=()=>{}; let nativeTimer=null,nativeLedger=null,nativeRevisions=[],nativeLast='',captionSource=null,caption=''; function nativeSnapshot(){ - if(!window.__walkiewareNativeShell||nativeTimer)return; + if(!window.__whistlegraphNativeShell||nativeTimer)return; nativeTimer=setTimeout(()=>{ nativeTimer=null; const historyChanged=nativeLedger!==versions?.value; @@ -110,14 +112,15 @@ function nativeSnapshot(){ const displayVersion=presentedVersion===null?versions?.head:versions?.value.versions.find(v=>v.id===presentedVersion); if(captionSource!==displayVersion?.source){captionSource=displayVersion?.source;caption=pieceCaption(captionSource||'');} const phaseText=$('live-phase').textContent; - const snapshot={output:outputStream,caption,code:thread?.identity.code||'',handle:accountHandle,colors:accountPalette,head:displayVersion?.id||0,hasPiece:!!source.trim(),hasPreview:!!source.trim()||!!provisional.trim(),busy,phase:phaseText,error:!busy&&/error|unavailable|could not|sign.in|loading|no piece/i.test(phaseText)?phaseText:'',attempt:lastAttempt?{request:lastAttempt.request.slice(0,1000),status:lastAttempt.status,error:lastAttempt.error||''}:null}; + const snapshot={ware:'piece',output:outputStream,caption,code:thread?.identity.code||'',handle:accountHandle,colors:accountPalette,head:displayVersion?.id||0,hasPiece:!!source.trim(),hasPreview:!!source.trim()||!!provisional.trim(),busy,phase:phaseText,error:!busy&&/error|unavailable|could not|sign.in|loading|no piece/i.test(phaseText)?phaseText:'',attempt:lastAttempt?{request:lastAttempt.request.slice(0,1000),status:lastAttempt.status,error:lastAttempt.error||''}:null}; const serialized=JSON.stringify(snapshot);if(serialized===nativeLast&&!historyChanged)return;nativeLast=serialized; if(historyChanged)snapshot.versions=nativeRevisions; post({action:'snapshot',snapshot}); if(historyChanged)postPieces(); },80); } -window.walkiewareNativeCommand=command=>{ +window.whistlegraphNativeCommand=command=>{ + if(command.action==='setWare'&&!busy)window.whistlegraphSelectWare?.(command.ware); if(command.action==='ask'&&typeof command.text==='string'&&command.text.trim()&&Array.from(new Intl.Segmenter(undefined,{granularity:'grapheme'}).segment(command.text)).length<=96&&!busy)void ask(command.text.trim()); if(command.action==='checkout'&&!busy){presentedVersion=null;narrationPending=command.version;jumpVersion(command.version);} if(command.action==='presentVersion'&&!busy){ @@ -125,8 +128,8 @@ window.walkiewareNativeCommand=command=>{ if(version){presentedVersion=version.id;narrationPending=version.id;render(version.source||'export function paint({wipe}){wipe("black");}');nativeSnapshot();} } if(command.action==='endPresentation'){presentedVersion=null;narrationPending=null;render(source);nativeSnapshot();} - if(command.action==='newPiece'&&!busy)window.walkiewareNewPiece?.(); - if(command.action==='openPiece'&&!busy&&typeof command.piece==='string')window.walkiewareOpenPiece?.(command.piece); + if(command.action==='newPiece'&&!busy)window.whistlegraphNewPiece?.(); + if(command.action==='openPiece'&&!busy&&typeof command.piece==='string')window.whistlegraphOpenPiece?.(command.piece); if(command.action==='retry'&&!busy)void resumeAttempt(true); if(command.action==='stop')$('live-stop').click(); if(command.action==='signIn')post({action:'signIn'}); @@ -174,7 +177,7 @@ function compileStream() { } function saved() { try { localStorage.setItem(storageKey,source); } catch {} updateFeed(); } function review(show) { $('speak').disabled=busy; $('speak-label').textContent=busy?'Working…':'Hold to talk'; } -function end() { clearTimeout(compileTimer);compileTimer=null; if(provisional && previewSource!==source){render(source||'export function paint({wipe}) {wipe("black");}');provisional='';} busy=false; updateFeed(); clearInterval(timer); $('live-stop').hidden=true; review(source!==previous); window.walkiewareWorkFinished?.(); } +function end() { clearTimeout(compileTimer);compileTimer=null; if(provisional && previewSource!==source){render(source||'export function paint({wipe}) {wipe("black");}');provisional='';} busy=false; updateFeed(); clearInterval(timer); $('live-stop').hidden=true; review(source!==previous); window.whistlegraphWorkFinished?.(); } function delta(text) { outputStream=(outputStream+text).slice(-6000); if (!firstDelta) {firstDelta=true; log('First model output');benchmark('firstModelOutput');} @@ -204,9 +207,9 @@ vfs.setWriteHandler((path,value)=>{ }); const guides = vfs.preload(['pieces.md','screen.md','hand.md','kidlisp.md','api.json'].map(name=>'/easel/context/'+name)); function makeServer(){ - const value=new AcServer({cwd:'/piece',piece:{file,checkpoint:async()=>{const target=source.trimEnd();for(let i=0;i<100;i++){if(painted&&lastPaintedSource.trimEnd()===target)return;await new Promise(resolve=>setTimeout(resolve,20));}}},frameCapture:false,layeredEdits:true,token:()=>token,model:window.__walkiewareModel||DEFAULT_MODEL, + const value=new AcServer({cwd:'/piece',piece:{file,checkpoint:async()=>{const target=source.trimEnd();for(let i=0;i<100;i++){if(painted&&lastPaintedSource.trimEnd()===target)return;await new Promise(resolve=>setTimeout(resolve,20));}}},frameCapture:false,layeredEdits:true,token:()=>token,model:window.__whistlegraphModel||DEFAULT_MODEL, fetch:async(...args)=>{benchmark('requestDispatched');const response=await globalThis.fetch(...args);benchmark('inferenceHeaders',{status:response.status});return response;},preview:true,rounds:12,outputContinuations:4,reasoning:{effort:'none'},thinking:{type:'disabled'}, - developerInstructions:GENERATION_INSTRUCTIONS}); + controls:wares,developerInstructions:GENERATION_INSTRUCTIONS+'\n'+WARE_INSTRUCTIONS}); value.runtimeFeedback=()=>feedback; value.on('notification',({method,params})=>{ if(method==='turn/progress' && !firstDelta) phase(params.phase==='connecting'?'Connecting…':'Waiting for model…'); @@ -231,6 +234,9 @@ function makeServer(){ } async function ask(text,displayText=text,advice=null,starter=null,localText=text,recovered=null){ if(busy)return; + const target=requestedWare(localText); + if(target){window.whistlegraphWorkFinished?.();window.whistlegraphSelectWare?.(target);return;} + wares.clear(); if(!versions){phase('Version history unavailable');return;} benchmark('generationEntered'); document.body.classList.add('live-mode'); @@ -287,6 +293,15 @@ async function ask(text,displayText=text,advice=null,starter=null,localText=text server??=makeServer();await server.startTurn(contextualRequest(versions.value,text)); }catch(error){turnError=error.message;phase('Could not start');log(error.message);} finally{ + if(wares.pending){ + const target=wares.pending;wares.clear(); + source=previous;vfs.mount(file,source);saved(); + localStorage.removeItem(storageKey+'-inflight');activeAttempt=null;lastAttempt=null; + localStorage.removeItem(storageKey+'-attempt'); + render(source||'export function paint({wipe}) {wipe("black");}');end(); + if(!turnCancelled&&turnSucceeded)window.whistlegraphSelectWare?.(target); + return; + } if(noChange){localStorage.removeItem(storageKey+'-inflight');activeAttempt=null;lastAttempt={...lastAttempt,status:'unchanged'};end();phase('Already there');benchmark('localEditUnchanged');return;} if(!turnSucceeded&&!turnCancelled&&turnStarter&&starterPainted){ // A failed refinement must not erase the useful, verified first drawing. @@ -319,44 +334,44 @@ async function resumeAttempt(manual=false){ phase('Resuming interrupted edit…'); await ask(attempt.text,attempt.displayText,null,null,attempt.localText,attempt); } -window.walkiewareEngineEvent=event=>{ - if(event.kind==='account') {token=event.token;if(token){musicalSocket.resume();thread?.resume();}else{musicalSocket.suspend();thread?.suspend();}window.walkiewareAccountReady=!!token;accountIdentity(token);if(token&&pending)void ask(pending);else void resumeAttempt();} - if(event.kind==='error'){phase('Sign-in needed');log(event.text);window.walkiewareWorkFinished?.();} +window.whistlegraphEngineEvent=event=>{ + if(event.kind==='account') {token=event.token;if(token){musicalSocket.resume();thread?.resume();}else{musicalSocket.suspend();thread?.suspend();}window.whistlegraphAccountReady=!!token;accountIdentity(token);if(token&&pending)void ask(pending);else void resumeAttempt();} + if(event.kind==='error'){phase('Sign-in needed');log(event.text);window.whistlegraphWorkFinished?.();} if(event.kind==='previewReady'){ready=true;log('AC runtime ready');} if(event.kind==='previewEvent'){ - if(event.event.kind==='painted'){if(turnStarter&&previewSource===turnStarter&&!starterPainted){starterPainted=true;benchmark('starterPainted');}if(previewSource.trimEnd()!==previous.trimEnd())window.__walkiewareSequenceEvent?.('painted');painted=true;lastPaintedSource=previewSource;if(busy&&activeAttempt&&source===previewSource&&!turnRuntimeFailed){activeAttempt={...activeAttempt,checkpoint:source};try{saveAttempt(localStorage,storageKey,activeAttempt);}catch(error){log('Could not persist checkpoint: '+error.message);}}feedback={rendered:true,logs:[],updatedAt:new Date().toISOString()};log('Checkpoint painted');phase(busy?'Building…':'Ready to play');if(narrationPending!==null){post({action:'narrationReady',version:narrationPending});narrationPending=null;}void resumeAttempt();} - if(event.event.kind==='invalidated'){turnRuntimeFailed=true;window.__walkiewareSequenceEvent?.('runtimeError',{message:'Preview invalidated'});painted=false;feedback={rendered:false,logs:[{level:'error',text:'Preview invalidated'}],updatedAt:new Date().toISOString()};log('Preview failed; inspect activity');phase('Preview error');if(lastPaintedSource && lastPaintedSource!==previewSource){render(lastPaintedSource);log('Restored last painted checkpoint');}} + if(event.event.kind==='painted'){if(turnStarter&&previewSource===turnStarter&&!starterPainted){starterPainted=true;benchmark('starterPainted');}if(previewSource.trimEnd()!==previous.trimEnd())window.__whistlegraphSequenceEvent?.('painted');painted=true;lastPaintedSource=previewSource;if(busy&&activeAttempt&&source===previewSource&&!turnRuntimeFailed){activeAttempt={...activeAttempt,checkpoint:source};try{saveAttempt(localStorage,storageKey,activeAttempt);}catch(error){log('Could not persist checkpoint: '+error.message);}}feedback={rendered:true,logs:[],updatedAt:new Date().toISOString()};log('Checkpoint painted');phase(busy?'Building…':'Ready to play');if(narrationPending!==null){post({action:'narrationReady',version:narrationPending});narrationPending=null;}void resumeAttempt();} + if(event.event.kind==='invalidated'){turnRuntimeFailed=true;window.__whistlegraphSequenceEvent?.('runtimeError',{message:'Preview invalidated'});painted=false;feedback={rendered:false,logs:[{level:'error',text:'Preview invalidated'}],updatedAt:new Date().toISOString()};log('Preview failed; inspect activity');phase('Preview error');if(lastPaintedSource && lastPaintedSource!==previewSource){render(lastPaintedSource);log('Restored last painted checkpoint');}} if(event.event.kind==='console'&&['error','warn'].includes(event.event.event?.level)){ const entry={level:event.event.event.level,text:event.event.event.message||'Runtime error'}; if(/\b(?:Paint|Sim|Boot) failure\b/i.test(entry.text))entry.level='error'; log(entry.text);runtimeErrors.push(entry.text);if(runtimeErrors.length>20)runtimeErrors.shift(); feedback={rendered:painted,logs:[...(feedback?.logs||[]),entry].slice(-20),updatedAt:new Date().toISOString()}; - if(entry.level==='error'){turnRuntimeFailed=true;turnError=entry.text;window.__walkiewareSequenceEvent?.('runtimeError',{message:entry.text});} + if(entry.level==='error'){turnRuntimeFailed=true;turnError=entry.text;window.__whistlegraphSequenceEvent?.('runtimeError',{message:entry.text});} threadUpdate(); } } }; $('live-stop').onclick=()=>{turnCancelled=true;pending='';server?.interrupt();if(!busy){end();phase('Stopped');}}; -window.walkiewareUndo=()=>{try{source=versions.undo().source;}catch(error){log(error.message);return;}previous=source;vfs.mount(file,source);saved();server?.close();server=null;render(source||'export function paint({wipe}) {wipe("black");}');review(false);phase('Undone');}; -window.walkiewareAsk=ask; +window.whistlegraphUndo=()=>{try{source=versions.undo().source;}catch(error){log(error.message);return;}previous=source;vfs.mount(file,source);saved();server?.close();server=null;render(source||'export function paint({wipe}) {wipe("black");}');review(false);phase('Undone');}; +window.whistlegraphAsk=ask; let musicalTurn=0; const musicalSocket=new MusicalInputSocket({token:()=>token,onEvent:benchmark}); document.addEventListener('visibilitychange',()=>{if(document.hidden){musicalSocket.suspend();thread?.suspend();}else{musicalSocket.resume();thread?.resume();}}); const musicalAdvisor=new MusicalInputAdvisor({fetchImpl:musicalSocket.fetch,token:()=>token,onEvent:(event,fields)=>{benchmark(event,fields);if(event==='jevDecision')log('Jev · '+fields.choice);}}); -window.walkiewareInputStart=()=>{musicalTurn++;musicalAdvisor.reset();}; -window.walkiewareInputCancel=()=>{musicalTurn++;musicalAdvisor.cancel();}; -window.walkiewareObserveSound=input=>{musicalPrompt(input);if(wantsSoundEvidence(input.transcript))musicalAdvisor.observe(input);}; -window.walkiewareAskSound=async input=>{ +window.whistlegraphInputStart=()=>{musicalTurn++;musicalAdvisor.reset();}; +window.whistlegraphInputCancel=()=>{musicalTurn++;musicalAdvisor.cancel();}; +window.whistlegraphObserveSound=input=>{musicalPrompt(input);if(wantsSoundEvidence(input.transcript))musicalAdvisor.observe(input);}; +window.whistlegraphAskSound=async input=>{ const prompt=musicalPrompt(input);const useSound=wantsSoundEvidence(input.transcript);if(!useSound)musicalAdvisor.cancel();phase(useSound?'Interpreting sound…':'Sending…'); return ask(prompt,`${input.transcript||'Sound'} · ${(input.sound.durationMs/1000).toFixed(1)} seconds`,!useSound||localEdit(source,input.transcript)?null:musicalAdvisor.finish(input),instantPiece(input.transcript),input.transcript); }; -window.walkiewareIsBusy=()=>busy; -window.walkiewareHasReview=()=>false; -if(!window.__walkiewareSequence&&!window.__walkiewareBenchmark&&!window.__walkiewareLocalSequence&&!window.__walkiewareDisableThread)try{if(initializeBasePiece(localStorage,storageKey))lastAttempt=null;}catch(error){phase('Could not initialize piece');log(error.message);} -try{source=window.__walkiewareSpace&&window.__walkiewareSequenceStart===1?'':window.__walkiewareLocalSequence?'':window.__walkiewareSequence?((window.__walkiewareSequenceStart>1?localStorage.getItem(storageKey):'')||localStorage.getItem('walkieware-benchmark-source')||''):window.__walkiewareBenchmark?'':localStorage.getItem(storageKey)||'';if(source){previous=source;ui.hidden=false;document.body.classList.add('live-mode','live-preview');$('initial').hidden=true;render(source);}}catch{} +window.whistlegraphIsBusy=()=>busy; +window.whistlegraphHasReview=()=>false; +if(!window.__whistlegraphSequence&&!window.__whistlegraphBenchmark&&!window.__whistlegraphLocalSequence&&!window.__whistlegraphDisableThread)try{if(initializeBasePiece(localStorage,storageKey))lastAttempt=null;}catch(error){phase('Could not initialize piece');log(error.message);} +try{source=window.__whistlegraphSpace&&window.__whistlegraphSequenceStart===1?'':window.__whistlegraphLocalSequence?'':window.__whistlegraphSequence?((window.__whistlegraphSequenceStart>1?localStorage.getItem(storageKey):'')||localStorage.getItem('whistlegraph-benchmark-source')||''):window.__whistlegraphBenchmark?'':localStorage.getItem(storageKey)||'';if(source){previous=source;ui.hidden=false;document.body.classList.add('live-mode','live-preview');$('initial').hidden=true;render(source);}}catch{} try { const versionKey=storageKey+'-versions'; - if(window.__walkiewareLocalSequence||window.__walkiewareBenchmark||(window.__walkiewareSequence&&window.__walkiewareSequenceStart===1))localStorage.removeItem(versionKey); + if(window.__whistlegraphLocalSequence||window.__whistlegraphBenchmark||(window.__whistlegraphSequence&&window.__whistlegraphSequenceStart===1))localStorage.removeItem(versionKey); versions=new PieceVersions(localStorage,versionKey,source); if(versions.head.source!==source){source=previous=versions.head.source;saved();if(source)render(source);} }catch(error){phase('Could not load versions');log(error.message);} @@ -374,28 +389,28 @@ if(versions&&['working','failed','interrupted'].includes(lastAttempt?.status)){ } // Simulator fixture: hold the pending row open with streamed code so the busy // state can be screenshot without a model or an account. Never leaves DEBUG. -if(typeof window.__walkiewareFixtureBusy==='string'){ +if(typeof window.__whistlegraphFixtureBusy==='string'){ busy=true;ui.hidden=false;document.body.classList.add('live-mode','live-preview'); - lastAttempt={request:window.__walkiewareFixtureBusy,parent:versions?.head.id??0,status:'working',startedAt:new Date().toISOString()}; + lastAttempt={request:window.__whistlegraphFixtureBusy,parent:versions?.head.id??0,status:'working',startedAt:new Date().toISOString()}; outputStream='export function paint({ wipe, ink, circle, screen }) {\n wipe("#151838");\n ink("#4653c6");\n circle(screen.width / 2, screen.height / 2, 40, true);'; $('live-code').textContent=outputStream;phase('Writing…');$('live-stop').hidden=false;review(false); } updateFeed(); -if(versions&&!window.__walkiewareSequence&&!window.__walkiewareBenchmark&&!window.__walkiewareDisableThread) { +if(versions&&!window.__whistlegraphSequence&&!window.__whistlegraphBenchmark&&!window.__whistlegraphDisableThread) { const label=codeLabel;label.setAttribute('aria-live','polite'); - thread=new WalkiewareThread({storage:localStorage,key:storageKey,token:()=>token,ledger:()=>versions.value, + thread=new WhistlegraphThread({storage:localStorage,key:storageKey,token:()=>token,ledger:()=>versions.value, state:()=>({busy,phase:$('live-phase').textContent,head:versions.head.id,source:versions.head.source,errors:runtimeErrors,attempt:lastAttempt}), onStatus:(code,status)=>{label.textContent=code?'/'+code:'';label.title=status;label.dataset.status=status;post({action:'threadStatus',code:code||'',threadID:thread.identity.id,status});nativeSnapshot();}, onCommand:async command=>{ if(command.action==='layout'){ if(typeof command.css!=='string'||new TextEncoder().encode(command.css).length>100000)throw Error('Invalid layout'); - window.walkiewareApplyLayout(command.css); + window.whistlegraphApplyLayout(command.css); return {ok:true,head:versions.head.id}; } await verifyThreadRevision(command,()=>({busy,head:versions.head.id,source:versions.head.source})); const before=versions.head.id; if(command.action==='ask')await ask(command.text); - else if(command.action==='undo')window.walkiewareUndo(); + else if(command.action==='undo')window.whistlegraphUndo(); else if(command.action==='edit') { busy=true;previous=source;turnRuntimeFailed=false;turnCancelled=false;checkpoints=1;server?.close();server=null;review(false); source=command.source;vfs.mount(file,source);phase('Applying remote edit…');render(source); @@ -411,14 +426,14 @@ if(versions&&!window.__walkiewareSequence&&!window.__walkiewareBenchmark&&!windo }}); label.textContent=thread.identity.code?'/'+thread.identity.code:''; const newPiece=document.createElement('button');newPiece.textContent='New piece';newPiece.style.cssText='font:24px Comic,Arial;padding:14px'; - newPiece.onclick=window.walkiewareNewPiece=()=>{ + newPiece.onclick=window.whistlegraphNewPiece=()=>{ if(busy)return; archiveCurrentPiece(); location.reload(); }; // Every piece on this phone: the open one plus the archives "New piece" left. // Opening another swaps archives, so the current one is never lost. - const ARCHIVE='walkieware-archive-',ARCHIVE_SUFFIXES=['-cloud-revision','-cloud-ledger']; + const ARCHIVE='whistlegraph-archive-',ARCHIVE_SUFFIXES=['-cloud-revision','-cloud-ledger']; function archiveCurrentPiece(){ const extras={};for(const suffix of ARCHIVE_SUFFIXES){const v=localStorage.getItem(storageKey+suffix);if(v!==null)extras[suffix]=v;} localStorage.setItem(ARCHIVE+thread.identity.id,JSON.stringify({identity:thread.identity,ledger:versions.value,source,extras,archivedAt:new Date().toISOString()})); @@ -438,7 +453,7 @@ if(versions&&!window.__walkiewareSequence&&!window.__walkiewareBenchmark&&!windo return list.sort((a,b)=>(b.current-a.current)||(Date.parse(b.updatedAt)||0)-(Date.parse(a.updatedAt)||0)).slice(0,256); } postPieces=()=>post({action:'pieces',pieces:pieceList()}); - window.walkiewareOpenPiece=id=>{ + window.whistlegraphOpenPiece=id=>{ if(busy||id===thread.identity.id)return; let saved;try{saved=JSON.parse(localStorage.getItem(ARCHIVE+id));}catch{} if(!saved?.identity?.id||!saved.ledger){phase('That piece is no longer on this phone');postPieces();return;} @@ -456,10 +471,10 @@ if(versions&&!window.__walkiewareSequence&&!window.__walkiewareBenchmark&&!windo post({action:'account'}); setTimeout(()=>{if(!ready){ui.hidden=false;phase('Preview still loading');log('AC runtime has not reported ready. Check your connection.');}},20000); -if(window.__walkiewareSequence && !window.__walkiewareLocalSequence && window.__walkiewareReviewVersion===undefined) import("./sequence-benchmark.mjs").then(({runSequence})=>runSequence({ask,ready:()=>ready,source:()=>source,painted:()=>painted&&lastPaintedSource.trimEnd()===source.trimEnd(),interrupt:()=>server?.interrupt(),model:window.__walkiewareModel||DEFAULT_MODEL})); +if(window.__whistlegraphSequence && !window.__whistlegraphLocalSequence && window.__whistlegraphReviewVersion===undefined) import("./sequence-benchmark.mjs").then(({runSequence})=>runSequence({ask,ready:()=>ready,source:()=>source,painted:()=>painted&&lastPaintedSource.trimEnd()===source.trimEnd(),interrupt:()=>server?.interrupt(),model:window.__whistlegraphModel||DEFAULT_MODEL})); -if(window.__walkiewareSequence && Number.isInteger(window.__walkiewareReviewVersion)) void (async()=>{ - const revision=versions.value.versions.find(v=>v.id===window.__walkiewareReviewVersion); +if(window.__whistlegraphSequence && Number.isInteger(window.__whistlegraphReviewVersion)) void (async()=>{ + const revision=versions.value.versions.find(v=>v.id===window.__whistlegraphReviewVersion); if(!revision){phase('Review version unavailable');return;} source=revision.source;render(source); for(let i=0;i<300&&!(ready&&painted&&lastPaintedSource.trimEnd()===source.trimEnd());i++)await new Promise(r=>setTimeout(r,100)); @@ -469,4 +484,4 @@ if(window.__walkiewareSequence && Number.isInteger(window.__walkiewareReviewVers post({action:'sequenceCapture',report:{index:revision.id,total:1,source,evidenceOnly:true,requiresMotion:true,checks:{committedSourcePainted:true},scope:'Exact saved version replayed on physical phone. Sequential native snapshots; no model edit, version commit or visual verdict.'},rect:{x:r.x,y:r.y,width:r.width,height:r.height}}); })(); -if(window.__walkiewareLocalSequence)import('./local-sequence.mjs').then(({runLocalSequence})=>runLocalSequence({ask,source:()=>source,head:()=>versions.value.head,count:()=>versions.value.versions.length,ready:()=>ready,painted:()=>painted&&lastPaintedSource.trimEnd()===(source||'export function paint({wipe}) {wipe("black");}').trimEnd(),undo:()=>window.walkiewareUndo()})); +if(window.__whistlegraphLocalSequence)import('./local-sequence.mjs').then(({runLocalSequence})=>runLocalSequence({ask,source:()=>source,head:()=>versions.value.head,count:()=>versions.value.versions.length,ready:()=>ready,painted:()=>painted&&lastPaintedSource.trimEnd()===(source||'export function paint({wipe}) {wipe("black");}').trimEnd(),undo:()=>window.whistlegraphUndo()})); diff --git a/apple/whistlegraph/Resources/Web/generation-policy.mjs b/apple/whistlegraph/Resources/Web/generation-policy.mjs new file mode 100644 index 0000000000..77f4676b9c --- /dev/null +++ b/apple/whistlegraph/Resources/Web/generation-policy.mjs @@ -0,0 +1,2 @@ +export const DEFAULT_MODEL = 'deepseek/deepseek-v4.1-flash'; +export const GENERATION_INSTRUCTIONS = 'This surface is Whistlegraph. The person watches the piece being made. A local starter may already be visible: its source is the real current piece; refine it instead of discarding it. For an existing piece, make small revision-checked edit_piece calls instead of resending unchanged code. Use write_piece to create the initial piece or for a necessary rewrite. No prose preamble. Each ask becomes one internal version; complete edit checkpoints are preview layers within it. Make the first visible working drawing in 3–6 lines whenever possible. The UI compiles runnable module prefixes during streaming: put required declarations and a complete exported paint function first; avoid long data tables or helper code before that first drawing. Then refine in small complete write_piece checkpoints only as needed to satisfy the request. Do not add unrequested animation or features. For animation use Number(paintCount) from the paint API, or explicit state advanced in sim; there is no numeric frame API. A request for a spoken narrator needs audible speak(), not just written notes; use the speech guide. Use AC ink alpha on the 0–255 scale and size geometry relative to the screen. Do not repeat the prompt or add interaction instructions, help text, usage hints, or captions inside or outside the picture unless explicitly requested. Use the default write font for other in-piece text unless the person requests another. Use ac_preview for runtime observations; this phone has no desktop frame-capture service. Once the requested change is painted and checked, finish the turn. Never fake progress or tests. Keep one line `export const caption = "…"` in the piece: a plain sentence under 120 characters naming the subject, its mood, what moves and what the person can do; update it whenever an edit changes any of that. It is the piece\'s memory of itself and comes back to you with every follow-up. Preserve the current piece and the original intent of the selected branch on revisions. Short follow-ups modify that existing world; do not replace its subject, characters, message, interactions, or sound without a request to do so. Treat uncertain speech transcriptions conservatively in context instead of inventing a new subject from an ambiguous word. Adjectives of culture, nationality or place (Latina, Japanese, Nordic, tropical) describe how things look: palette, patterns, dress, flora, light. They never change the language of any text or speech unless the request mentions words, text, language or voice. Do not turn ordinary spoken requests into text posters, transcript timelines, waveform charts, or word-timed animation unless explicitly requested. No publishing: these are private local previews.'; diff --git a/apple/walkieware/Resources/Web/inference-input.mjs b/apple/whistlegraph/Resources/Web/inference-input.mjs similarity index 100% rename from apple/walkieware/Resources/Web/inference-input.mjs rename to apple/whistlegraph/Resources/Web/inference-input.mjs diff --git a/apple/walkieware/Resources/Web/instant-piece.mjs b/apple/whistlegraph/Resources/Web/instant-piece.mjs similarity index 56% rename from apple/walkieware/Resources/Web/instant-piece.mjs rename to apple/whistlegraph/Resources/Web/instant-piece.mjs index b90c6106f0..1196ffe349 100644 --- a/apple/walkieware/Resources/Web/instant-piece.mjs +++ b/apple/whistlegraph/Resources/Web/instant-piece.mjs @@ -4,5 +4,5 @@ export function instantPiece(text) { const match=text.trim().toLowerCase().match(/^(?:(?:please )?(?:make|draw|create)(?: me)? |i want )?(?:a |an )?(pink|red|blue|green|yellow|orange|purple|white)?\s*(circle|square)(?:\s*[.!?]?|\s+(?:with|that|and)\s+.+)$/); if(!match)return null; const color=match[1]||'pink',shape=match[2]; - return `// Walkieware starter: ${color} ${shape}\nexport function paint({wipe, ink, screen}) {\n wipe(24, 18, 30);\n const r = Math.min(screen.width, screen.height) / 5;\n ink("${color}").${shape==='circle'?'circle(screen.width / 2, screen.height / 2, r, true)':'box(screen.width / 2 - r, screen.height / 2 - r, r * 2, r * 2)'};\n}\n`; + return `// Whistlegraph starter: ${color} ${shape}\nexport function paint({wipe, ink, screen}) {\n wipe(24, 18, 30);\n const r = Math.min(screen.width, screen.height) / 5;\n ink("${color}").${shape==='circle'?'circle(screen.width / 2, screen.height / 2, r, true)':'box(screen.width / 2 - r, screen.height / 2 - r, r * 2, r * 2)'};\n}\n`; } diff --git a/apple/whistlegraph/Resources/Web/legacy-storage.mjs b/apple/whistlegraph/Resources/Web/legacy-storage.mjs new file mode 100644 index 0000000000..c20d92d998 --- /dev/null +++ b/apple/whistlegraph/Resources/Web/legacy-storage.mjs @@ -0,0 +1,11 @@ +// Preserve immutable source and thread IDs while adopting the product name. +// Old keys remain as a recovery copy; reruns never overwrite a new key. +export function migrateLegacyStorage(storage){ + if(storage.getItem('whistlegraph-storage-migrated')==='1')return; + const keys=Array.from({length:storage.length},(_,i)=>storage.key(i)).filter(k=>k?.startsWith('walkieware-')); + for(const old of keys){ + const next='whistlegraph-'+old.slice('walkieware-'.length); + if(storage.getItem(next)===null)storage.setItem(next,storage.getItem(old)); + } + storage.setItem('whistlegraph-storage-migrated','1'); +} diff --git a/apple/walkieware/Resources/Web/local-moves.mjs b/apple/whistlegraph/Resources/Web/local-moves.mjs similarity index 100% rename from apple/walkieware/Resources/Web/local-moves.mjs rename to apple/whistlegraph/Resources/Web/local-moves.mjs diff --git a/apple/walkieware/Resources/Web/local-scene.mjs b/apple/whistlegraph/Resources/Web/local-scene.mjs similarity index 72% rename from apple/walkieware/Resources/Web/local-scene.mjs rename to apple/whistlegraph/Resources/Web/local-scene.mjs index 78aa060852..602c1827ad 100644 --- a/apple/walkieware/Resources/Web/local-scene.mjs +++ b/apple/whistlegraph/Resources/Web/local-scene.mjs @@ -4,24 +4,25 @@ const valid=s=>s&&Object.keys(s).sort().join(',')==='bounce,color,shape,size,spe function legacySource(scene){ if(!valid(scene))throw Error('Invalid local scene'); const s=Object.fromEntries(['shape','color','x','y','size','bounce','speed'].map(k=>[k,scene[k]])); - return `// Walkieware scene v1\nconst scene = ${JSON.stringify(s)};\nlet phase = 0;\nexport function sim() { phase += 0.06 * scene.speed; }\nexport function paint({wipe, ink, screen}) {\n wipe(24, 18, 30);\n const r = Math.min(screen.width, screen.height) * scene.size;\n const x = Math.max(r, Math.min(screen.width - r, screen.width * scene.x));\n const baseY = screen.height * scene.y;\n const y = Math.max(r, Math.min(screen.height - r, baseY - (scene.bounce ? Math.abs(Math.sin(phase)) * screen.height * 0.15 : 0)));\n ink(scene.color);\n if (scene.shape === "circle") ink(scene.color).circle(x, y, r, true);\n else ink(scene.color).box(x - r, y - r, r * 2, r * 2);\n}\n`; + return `// Whistlegraph scene v1\nconst scene = ${JSON.stringify(s)};\nlet phase = 0;\nexport function sim() { phase += 0.06 * scene.speed; }\nexport function paint({wipe, ink, screen}) {\n wipe(24, 18, 30);\n const r = Math.min(screen.width, screen.height) * scene.size;\n const x = Math.max(r, Math.min(screen.width - r, screen.width * scene.x));\n const baseY = screen.height * scene.y;\n const y = Math.max(r, Math.min(screen.height - r, baseY - (scene.bounce ? Math.abs(Math.sin(phase)) * screen.height * 0.15 : 0)));\n ink(scene.color);\n if (scene.shape === "circle") ink(scene.color).circle(x, y, r, true);\n else ink(scene.color).box(x - r, y - r, r * 2, r * 2);\n}\n`; } export function sceneSource(scene,motionId='local'){ if(!/^[a-z0-9-]{1,64}$/i.test(motionId))throw Error('Invalid motion identity'); const base=legacySource(scene); return (scene.shape==='line'?base.replace('if (scene.shape === \"circle\") ink(scene.color).circle(x, y, r, true);\n else ink(scene.color).box(x - r, y - r, r * 2, r * 2);','ink(scene.color).line(x - r, y, x + r, y);'):base) - .replace('// Walkieware scene v1\n',`// Walkieware scene v2\nconst motionId = "${motionId}";\n`) + .replace('// Whistlegraph scene v1\n',`// Whistlegraph scene v2\nconst motionId = "${motionId}";\n`) .replace('let phase = 0;\nexport function sim() { phase += 0.06 * scene.speed; }',`let motion = {id: motionId, phase: 0}; export function boot({store}) { - const saved = store["walkieware:local-motion"]; + const saved = store["whistlegraph:local-motion"]; motion = saved && saved.id === motionId && Number.isFinite(saved.phase) ? saved : motion; - store["walkieware:local-motion"] = motion; + store["whistlegraph:local-motion"] = motion; } export function sim() { if (scene.bounce) motion.phase = (motion.phase + 0.06 * scene.speed) % (Math.PI * 2); }`) .replace('scene.bounce ? Math.abs(Math.sin(phase)) * screen.height * 0.15 : 0','Math.abs(Math.sin(motion.phase)) * screen.height * 0.15'); } -function identity(source){return source.match(/^\/\/ Walkieware scene v2\nconst motionId = "([a-z0-9-]{1,64})";\n/i)?.[1];} +function identity(source){return source.match(/^\/\/ Whistlegraph scene v2\nconst motionId = "([a-z0-9-]{1,64})";\n/i)?.[1];} export function readScene(source){ + source=source.replaceAll('Walkieware scene','Whistlegraph scene').replaceAll('walkieware:local-motion','whistlegraph:local-motion'); try{ const id=identity(source),m=source.match(/(?:^|\n)const scene = (.+);\n/);if(!m)return null; const s=JSON.parse(m[1]),canonical=id?sceneSource(s,id):legacySource(s); @@ -50,5 +51,5 @@ export function localEdit(source,text){ else return null; for(const k of ['x','y','size','speed'])scene[k]=Math.round(scene[k]*100000)/100000; } - const next=sceneSource(scene,identity(source)||crypto.randomUUID());return {action,scene,source:next,changed:next.trimEnd()!==source.trimEnd()}; + const next=sceneSource(scene,identity(source.replace('Walkieware scene','Whistlegraph scene'))||crypto.randomUUID());return {action,scene,source:next,changed:next.trimEnd()!==source.trimEnd()}; } diff --git a/apple/walkieware/Resources/Web/local-sequence.mjs b/apple/whistlegraph/Resources/Web/local-sequence.mjs similarity index 75% rename from apple/walkieware/Resources/Web/local-sequence.mjs rename to apple/whistlegraph/Resources/Web/local-sequence.mjs index b6de11c35c..d214534046 100644 --- a/apple/walkieware/Resources/Web/local-sequence.mjs +++ b/apple/whistlegraph/Resources/Web/local-sequence.mjs @@ -2,12 +2,12 @@ import {localMoves} from './local-moves.mjs'; import {readScene} from './local-scene.mjs'; const delay=ms=>new Promise(r=>setTimeout(r,ms)); export async function runLocalSequence(api){ - while(!window.walkiewareAccountReady||!api.ready())await delay(100); + while(!window.whistlegraphAccountReady||!api.ready())await delay(100); let expected={}; for(let i=0;ievents.push({event,ms:performance.now()-start,...fields}); + window.__whistlegraphSequenceEvent=(event,fields={})=>events.push({event,ms:performance.now()-start,...fields}); await api.ask(text); const source=api.source(),firstHead=api.head(); const checks={properties:JSON.stringify(readScene(source))===JSON.stringify(expected),painted:api.painted(),oneVersion:api.count()===count+1&&firstHead!==head,noInference:!events.some(e=>e.event==='requestDispatched')}; @@ -16,8 +16,8 @@ export async function runLocalSequence(api){ await api.ask(text);checks.replay=JSON.stringify(readScene(api.source()))===JSON.stringify(readScene(source))&&api.painted()&&api.count()===count+2; const report={index:i+1,total:32,prompt:text,source,expected,events,checks,requiresMotion:expected.bounce,scope:'Local text edits on physical phone. Each move checks properties, painted source, one version, undo and replay; no speech or inference.'}; const rect=document.getElementById('live-piece').getBoundingClientRect(); - const result=await new Promise(resolve=>{window.__walkiewareSequenceCaptureDone=resolve;window.webkit.messageHandlers.walkie.postMessage({action:'sequenceCapture',id:'sequence',report,rect:{x:rect.x,y:rect.y,width:rect.width,height:rect.height}});}); - window.__walkiewareSequenceEvent=null; + const result=await new Promise(resolve=>{window.__whistlegraphSequenceCaptureDone=resolve;window.webkit.messageHandlers.whistlegraph.postMessage({action:'sequenceCapture',id:'sequence',report,rect:{x:rect.x,y:rect.y,width:rect.width,height:rect.height}});}); + window.__whistlegraphSequenceEvent=null; document.getElementById('live-phase').textContent=`Local edit ${i+1}/32 ${result.passed?'passed':'failed'}`; if(!result.passed)return; } diff --git a/apple/walkieware/Resources/Web/musical-input.mjs b/apple/whistlegraph/Resources/Web/musical-input.mjs similarity index 95% rename from apple/walkieware/Resources/Web/musical-input.mjs rename to apple/whistlegraph/Resources/Web/musical-input.mjs index b5fbfe01c4..611ef15ff8 100644 --- a/apple/walkieware/Resources/Web/musical-input.mjs +++ b/apple/whistlegraph/Resources/Web/musical-input.mjs @@ -1,6 +1,6 @@ // Only measured fields cross into creation; the model never receives raw PCM. export function musicalPrompt(input) { - if(!input||typeof input.transcript!=='string'||input.transcript.length>12000||input.sound?.schema!=='walkieware-sound/v1')throw Error('Invalid musical input'); + if(!input||typeof input.transcript!=='string'||input.transcript.length>12000||!['whistlegraph-sound/v1','walkieware-sound/v1'].includes(input.sound?.schema))throw Error('Invalid musical input'); const finite=(v,max)=>typeof v==='number'&&Number.isFinite(v)&&v>=0&&v<=max; const frames=input.sound.frames; if(!Array.isArray(frames)||frames.length>128||!frames.every(f=>finite(f.atMs,60000)&&finite(f.rms,2)&&(f.pitchHz===undefined||finite(f.pitchHz,24000))))throw Error('Invalid sound timeline'); diff --git a/apple/walkieware/Resources/Web/native.js b/apple/whistlegraph/Resources/Web/native.js similarity index 53% rename from apple/walkieware/Resources/Web/native.js rename to apple/whistlegraph/Resources/Web/native.js index 690f632a61..da2b29d502 100644 --- a/apple/walkieware/Resources/Web/native.js +++ b/apple/whistlegraph/Resources/Web/native.js @@ -1,25 +1,25 @@ // Native live speech and the shared Aesel generation stream. (() => { - if (!window.webkit?.messageHandlers?.walkie) return; - document.title = 'Walkieware'; - $('home').innerHTML = 'walkieware'; - $('home').setAttribute('aria-label', 'Walkieware'); + if (!window.webkit?.messageHandlers?.whistlegraph) return; + document.title = 'Whistlegraph'; + $('home').innerHTML = 'whistlegraph'; + $('home').setAttribute('aria-label', 'Whistlegraph'); $('home').onclick = () => $('info').showModal(); window.addEventListener('error', e => { if(e.message) toast('Could not load generation: '+e.message); }); document.querySelector('#initial .mark').innerHTML = ''; const style = document.createElement('style'); - style.textContent = '.walkie .top .brand{font-size:32px;letter-spacing:-1.5px}.walkie .top .piece-name,.walkie .top .divider{display:none}.walkie .top{padding:0 21px}.voice-note{font-size:11px;margin-top:13px}.voice-note button{background:none;color:#d9ef9f;padding:5px 9px;font-size:12px;text-decoration:underline}.walkie .walkie-dock{bottom:30px}.walkie .play-deck{bottom:220px}.walkie .conversation{bottom:315px}'; + style.textContent = '.whistlegraph .top .brand{font-size:32px;letter-spacing:-1.5px}.whistlegraph .top .piece-name,.whistlegraph .top .divider{display:none}.whistlegraph .top{padding:0 21px}.voice-note{font-size:11px;margin-top:13px}.voice-note button{background:none;color:#d9ef9f;padding:5px 9px;font-size:12px;text-decoration:underline}.whistlegraph .whistlegraph-dock{bottom:30px}.whistlegraph .play-deck{bottom:220px}.whistlegraph .conversation{bottom:315px}'; style.textContent += ` - #walkieware-thread{position:absolute;top:62px;left:21px;right:21px;z-index:7;font:24px/1.2 Comic,Arial;color:#d9ef9f;pointer-events:none} - .walkie #live-piece{top:100px;height:max(120px,calc(100% - 600px))} + #whistlegraph-thread{position:absolute;top:62px;left:21px;right:21px;z-index:7;font:24px/1.2 Comic,Arial;color:#d9ef9f;pointer-events:none} + .whistlegraph #live-piece{top:100px;height:max(120px,calc(100% - 600px))} html,body,body *{-webkit-user-select:none;user-select:none;-webkit-touch-callout:none} input,textarea,input *,textarea *{-webkit-user-select:text;user-select:text} button{touch-action:manipulation}#speak{touch-action:none} - .walkie .voice-state{background:#19172e;z-index:6} - .walkie .voice-state .wave-bars{height:24px;transform:scale(.5);flex-shrink:0} - .walkie .voice-state h2{font:16px/1.3 Comic,Arial;color:#f7b474;margin:16px 0} - .walkie .voice-state p{font:32px/1.3 Comic,Arial;color:#f4efdd;max-width:100%;max-height:45vh;overflow:auto;overflow-wrap:anywhere;text-align:left;white-space:pre-wrap} + .whistlegraph .voice-state{background:#19172e;z-index:6} + .whistlegraph .voice-state .wave-bars{height:24px;transform:scale(.5);flex-shrink:0} + .whistlegraph .voice-state h2{font:16px/1.3 Comic,Arial;color:#f7b474;margin:16px 0} + .whistlegraph .voice-state p{font:32px/1.3 Comic,Arial;color:#f4efdd;max-width:100%;max-height:45vh;overflow:auto;overflow-wrap:anywhere;text-align:left;white-space:pre-wrap} #live-piece{position:absolute;inset:80px 0 415px;width:100%;height:calc(100% - 495px);border:0;visibility:hidden;background:#19172e;z-index:2} .live-preview #live-piece{visibility:visible}.live-mode #initial,.live-mode #art,.live-mode .stage-top,.live-mode .composer-wrap,.live-mode #play-deck{display:none!important} #live-work{position:absolute;z-index:6;bottom:220px;left:21px;right:21px;padding:12px;background:#211d36f5;border-radius:8px;font:13px/1.4 Comic,Arial;max-height:255px;color:#f4efdd} @@ -28,10 +28,10 @@ #live-decisions{display:flex;gap:12px;margin-top:8px}#live-decisions button{flex:1;padding:12px;background:#d9ef9f;color:#19172e;font:22px Comic,Arial}#live-decisions button+button{background:#f59d98} `; style.textContent += ` - .walkie .walkie-dock{bottom:22px}.speak-button{font-size:34px;min-height:132px} - .walkie .voice-note{display:flex;gap:12px;margin-top:14px;letter-spacing:0;font-size:0} - .walkie .voice-note button{flex:1;min-height:52px;font:22px/1.2 Comic,Arial;text-decoration:none;border:2px solid #665973;border-radius:6px;padding:10px;color:#f4efdd;background:#272139} - .walkie .voice-state{bottom:225px}.walkie .voice-state h2{font-size:22px}.walkie .voice-state p{font-size:36px} + .whistlegraph .whistlegraph-dock{bottom:22px}.speak-button{font-size:34px;min-height:132px} + .whistlegraph .voice-note{display:flex;gap:12px;margin-top:14px;letter-spacing:0;font-size:0} + .whistlegraph .voice-note button{flex:1;min-height:52px;font:22px/1.2 Comic,Arial;text-decoration:none;border:2px solid #665973;border-radius:6px;padding:10px;color:#f4efdd;background:#272139} + .whistlegraph .voice-state{bottom:225px}.whistlegraph .voice-state h2{font-size:22px}.whistlegraph .voice-state p{font-size:36px} #live-work{bottom:244px;max-height:260px;font-size:18px;padding:14px} #live-time{font-size:16px}.live-line button{font-size:18px;min-height:44px} #live-request{max-height:52px;font-size:19px}#live-details summary{font-size:20px;min-height:36px} @@ -52,24 +52,24 @@ #live-phase[hidden],#live-time[hidden],#live-details[hidden],#live-request[hidden]{display:none!important} `; style.textContent += ` - .walkie #live-piece{left:50%;right:auto;bottom:auto;transform:translateX(-50%);width:min(calc(100% - 28px),calc(max(120px,100dvh - 560px) * 4 / 3));height:auto;aspect-ratio:4/3;box-sizing:content-box;border:0;border-radius:0;background:#19172e;box-shadow:none;filter:none} + .whistlegraph #live-piece{left:50%;right:auto;bottom:auto;transform:translateX(-50%);width:min(calc(100% - 28px),calc(max(120px,100dvh - 560px) * 4 / 3));height:auto;aspect-ratio:4/3;box-sizing:content-box;border:0;border-radius:0;background:#19172e;box-shadow:none;filter:none} `; style.textContent += ` - #walkieware-identity{position:absolute;top:64px;right:21px;left:21px;z-index:7;display:flex;justify-content:flex-end;align-items:center;font:22px/1.2 Comic,Arial;color:#d9ef9f} - #walkieware-identity #walkieware-thread{position:static;font:inherit;white-space:pre;pointer-events:auto} - #walkieware-identity #connect-ac{font:inherit;color:inherit;background:none;border:0;padding:6px 0;min-height:32px} - .walkie .voice-note{display:none}#live-work{bottom:170px}.walkie .voice-state{bottom:165px} + #whistlegraph-identity{position:absolute;top:64px;right:21px;left:21px;z-index:7;display:flex;justify-content:flex-end;align-items:center;font:22px/1.2 Comic,Arial;color:#d9ef9f} + #whistlegraph-identity #whistlegraph-thread{position:static;font:inherit;white-space:pre;pointer-events:auto} + #whistlegraph-identity #connect-ac{font:inherit;color:inherit;background:none;border:0;padding:6px 0;min-height:32px} + .whistlegraph .voice-note{display:none}#live-work{bottom:170px}.whistlegraph .voice-state{bottom:165px} `; style.textContent += ` @font-face{font-family:ComicTitle;src:url('ComicRelief-Bold.ttf');font-weight:700} - #walkieware-identity,.walkie .top .brand{font-family:ComicTitle,Comic,Arial;font-weight:700;-webkit-text-stroke:1px #141414;paint-order:stroke fill;text-shadow:.75px .75px #ed629d,1px 1px #a478df,2px 2px #55cdd9} - #walkieware-identity #walkieware-thread{color:#fff6e8} + #whistlegraph-identity,.whistlegraph .top .brand{font-family:ComicTitle,Comic,Arial;font-weight:700;-webkit-text-stroke:1px #141414;paint-order:stroke fill;text-shadow:.75px .75px #ed629d,1px 1px #a478df,2px 2px #55cdd9} + #whistlegraph-identity #whistlegraph-thread{color:#fff6e8} #connect-ac>span{display:inline-block} `; style.textContent += ` - .walkie #live-preview-box{position:absolute;top:100px;left:50%;transform:translateX(-50%);width:min(calc(100% - 28px),calc(max(120px,100dvh - 560px) * 4 / 3));aspect-ratio:4/3;z-index:2} - .walkie #live-preview-box #live-piece{inset:0;transform:none;width:100%;height:100%;aspect-ratio:auto} - #live-preview-box #walkieware-identity{top:calc(100% + 8px);bottom:auto;right:0;left:0;pointer-events:none} + .whistlegraph #live-preview-box{position:absolute;top:100px;left:50%;transform:translateX(-50%);width:min(calc(100% - 28px),calc(max(120px,100dvh - 560px) * 4 / 3));aspect-ratio:4/3;z-index:2} + .whistlegraph #live-preview-box #live-piece{inset:0;transform:none;width:100%;height:100%;aspect-ratio:auto} + #live-preview-box #whistlegraph-identity{top:calc(100% + 8px);bottom:auto;right:0;left:0;pointer-events:none} #live-preview-box #connect-ac{pointer-events:auto} `; style.textContent += ` @@ -78,57 +78,57 @@ .live-line{flex-shrink:0} `; style.textContent += ` - .walkie #art,.walkie .stage-top,.walkie .composer-wrap,.walkie #play-deck,.walkie #conversation,.walkie .initial-form{display:none!important} - .walkie:not(.live-mode) #live-preview-box{visibility:hidden} + .whistlegraph #art,.whistlegraph .stage-top,.whistlegraph .composer-wrap,.whistlegraph #play-deck,.whistlegraph #conversation,.whistlegraph .initial-form{display:none!important} + .whistlegraph:not(.live-mode) #live-preview-box{visibility:hidden} `; - if(window.__walkiewareNativeShell){ + if(window.__whistlegraphNativeShell){ document.documentElement.classList.add('native-shell'); - const nativeStyle=document.createElement('style');nativeStyle.textContent=`html.native-shell body>*:not(#stage){display:none!important}html.native-shell #stage{position:fixed;inset:0}html.native-shell #stage>*:not(#live-preview-box){display:none!important}html.native-shell .walkie #live-preview-box{position:absolute;inset:0;transform:none;width:100%;height:100%;aspect-ratio:auto;visibility:visible}html.native-shell #walkieware-identity{display:none!important}`; + const nativeStyle=document.createElement('style');nativeStyle.textContent=`html.native-shell body>*:not(#stage){display:none!important}html.native-shell #stage{position:fixed;inset:0}html.native-shell #stage>*:not(#live-preview-box){display:none!important}html.native-shell .whistlegraph #live-preview-box{position:absolute;inset:0;transform:none;width:100%;height:100%;aspect-ratio:auto;visibility:visible}html.native-shell #whistlegraph-identity{display:none!important}`; document.head.append(nativeStyle); } document.head.append(style); - const liveStyle=document.createElement('style');liveStyle.id='walkieware-live-layout';document.head.append(liveStyle); + const liveStyle=document.createElement('style');liveStyle.id='whistlegraph-live-layout';document.head.append(liveStyle); function nativeLayout(){ - if(!window.__walkiewareNativeShell)return; + if(!window.__whistlegraphNativeShell)return; const computed=getComputedStyle(document.documentElement),layout={}; for(const name of ['spacing','page-inset','history-size','talk-height','title-size']){const value=parseFloat(computed.getPropertyValue('--ww-'+name));if(Number.isFinite(value))layout[name]=value;} - window.webkit.messageHandlers.walkie.postMessage({action:'layout',id:'engine',layout}); + window.webkit.messageHandlers.whistlegraph.postMessage({action:'layout',id:'engine',layout}); } - window.walkiewareApplyLayout=css=>{localStorage.setItem('walkieware-layout-css',css);liveStyle.textContent=css;nativeLayout();}; - try{liveStyle.textContent=localStorage.getItem('walkieware-layout-css')||'';}catch{} + window.whistlegraphApplyLayout=css=>{localStorage.setItem('whistlegraph-layout-css',css);liveStyle.textContent=css;nativeLayout();}; + try{liveStyle.textContent=localStorage.getItem('whistlegraph-layout-css')||(!localStorage.getItem('whistlegraph-storage-migrated')&&localStorage.getItem('walkieware-layout-css'))||'';}catch{} nativeLayout(); $('speak').setAttribute('aria-label','Hold to talk, up to eight seconds'); const note = document.querySelector('.voice-note'); note.textContent = ''; - const blocked = () => window.walkiewareIsBusy ? window.walkiewareIsBusy() : gameMode === 'review'; - $('info').querySelector('h2').textContent = 'Walkieware'; - $('info').querySelectorAll('p')[0].textContent = 'Hold to talk. Release to make. Your words are transcribed on the iPhone; audio is not saved. Allow Speech and Microphone access the first time, then hold again.'; - $('info').querySelectorAll('p')[1].textContent = 'Your ww code identifies this piece. Source, versions, and live errors sync privately to your AC account so your other devices and agents can inspect and edit it. Audio stays on this phone.'; - $('speak').setAttribute('aria-label', 'Hold to talk to Walkieware'); + const blocked = () => window.whistlegraphIsBusy ? window.whistlegraphIsBusy() : gameMode === 'review'; + $('info').querySelector('h2').textContent = 'Whistlegraph'; + $('info').querySelectorAll('p')[0].textContent = 'Hold to talk. Release to make. Your words are transcribed on the iPhone; recordings stay on this phone. Allow Speech and Microphone access the first time, then hold again.'; + $('info').querySelectorAll('p')[1].textContent = 'Your code identifies this piece. Source, versions, and live errors sync privately to your AC account so your other devices and agents can inspect and edit it. Audio stays on this phone.'; + $('speak').setAttribute('aria-label', 'Hold to talk to Whistlegraph'); $('export').hidden = true; $('reset').hidden = true; $('new').hidden = true; $('export').onclick = () => { $('more-menu').hidden = true; - window.webkit.messageHandlers.walkie.postMessage({action: 'share', id: 'image', data: canvas.toDataURL('image/png')}); + window.webkit.messageHandlers.whistlegraph.postMessage({action: 'share', id: 'image', data: canvas.toDataURL('image/png')}); }; let id = '', starting = false, completing = false, voiceClock; const clearClock=()=>{clearInterval(voiceClock);voiceClock=null;}; - const send = action => window.webkit.messageHandlers.walkie.postMessage({action, id}); - function clearVoice() { window.webkit.messageHandlers.walkie.postMessage({action:'voiceIdle',id:'engine'}); clearClock(); talking = false; voiceBusy = false; starting = false; completing = false; $('voice-state').hidden = true; $('speak').classList.remove('holding'); document.body.classList.remove('making'); $('speak').disabled = blocked(); $('speak-label').textContent = blocked() ? 'Working…' : 'Hold to talk'; } + const send = action => window.webkit.messageHandlers.whistlegraph.postMessage({action, id}); + function clearVoice() { window.webkit.messageHandlers.whistlegraph.postMessage({action:'voiceIdle',id:'engine'}); clearClock(); talking = false; voiceBusy = false; starting = false; completing = false; $('voice-state').hidden = true; $('speak').classList.remove('holding'); document.body.classList.remove('making'); $('speak').disabled = blocked(); $('speak-label').textContent = blocked() ? 'Working…' : 'Hold to talk'; } voiceStart = () => { if (talking || voiceBusy || blocked()) return; - window.webkit.messageHandlers.walkie.postMessage({action:'account',id:'engine'}); + window.webkit.messageHandlers.whistlegraph.postMessage({action:'account',id:'engine'}); id = 'voice-' + Date.now(); talking = true; starting = true; $('speak').classList.add('holding'); $('speak-label').textContent = 'Release when done'; $('voice-state').hidden = false; $('voice-heading').textContent = 'Opening the microphone…'; $('voice-transcript').textContent = 'Allow access if asked, then hold again.'; - window.walkiewareInputStart?.(); + window.whistlegraphInputStart?.(); send('start'); }; voiceEnd = (cancel = false) => { if (completing || (!talking && !voiceBusy) || (voiceBusy && !cancel)) return; - if (cancel || starting) { window.walkiewareInputCancel?.(); send('cancel'); id = ''; clearVoice(); return; } + if (cancel || starting) { window.whistlegraphInputCancel?.(); send('cancel'); id = ''; clearVoice(); return; } clearClock(); talking = false; voiceBusy = true; $('speak').classList.remove('holding'); $('speak').disabled = true; $('speak-label').textContent = 'Finishing…'; $('voice-heading').textContent = 'Finishing…'; send('stop'); @@ -138,20 +138,20 @@ clearClock(); completing = true; voiceBusy = true; $('speak').disabled = true; $('voice-state').hidden = true; $('speak-label').textContent = 'Working…'; - if (!window.walkiewareAsk) { id = ''; clearVoice(); toast('Generation is still loading. Please try again.'); return; } + if (!window.whistlegraphAsk) { id = ''; clearVoice(); toast('Generation is still loading. Please try again.'); return; } id = ''; - try { await (sound ? window.walkiewareAskSound(JSON.parse(text)) : window.walkiewareAsk(text)); } + try { await (sound ? window.whistlegraphAskSound(JSON.parse(text)) : window.whistlegraphAsk(text)); } catch { clearVoice(); toast("Could not interpret this sound. Please try again."); } } - window.walkiewareRecording=()=>talking||voiceBusy||starting||completing; - window.walkiewareStartFixture = () => { voiceStart(); }; - window.walkiewareWorkFinished = () => { id = ''; clearVoice(); }; + window.whistlegraphRecording=()=>talking||voiceBusy||starting||completing; + window.whistlegraphStartFixture = () => { voiceStart(); }; + window.whistlegraphWorkFinished = () => { id = ''; clearVoice(); }; - window.walkieNativeEvent = event => { + window.whistlegraphNativeEvent = event => { if (event.id !== id || !id) return; if (event.kind === 'listening') { starting = false; clearClock(); const began=performance.now(); $('speak-label').textContent='8s · Release to send'; voiceClock=setInterval(()=>{const left=Math.max(0,8-(performance.now()-began)/1000);$('speak-label').textContent=Math.ceil(left)+'s · Release to send';if(left<=0)voiceEnd();},100); $('voice-heading').textContent = 'Listening…'; $('voice-transcript').textContent = ''; } - if (event.kind === 'partial') { $('voice-transcript').textContent = event.text; $('voice-transcript').scrollTop = $('voice-transcript').scrollHeight; requestAnimationFrame(()=>window.webkit.messageHandlers.walkie.postMessage({action:'benchmark',id:'engine',event:'transcriptPainted'})); } - if (event.kind === 'musicalObservation') { try { window.walkiewareObserveSound?.(JSON.parse(event.text)); } catch {} } + if (event.kind === 'partial') { $('voice-transcript').textContent = event.text; $('voice-transcript').scrollTop = $('voice-transcript').scrollHeight; requestAnimationFrame(()=>window.webkit.messageHandlers.whistlegraph.postMessage({action:'benchmark',id:'engine',event:'transcriptPainted'})); } + if (event.kind === 'musicalObservation') { try { window.whistlegraphObserveSound?.(JSON.parse(event.text)); } catch {} } if (event.kind === 'sound') { $('voice-heading').textContent = 'Listening · '+event.text; } if (event.kind === 'mixedFinal') { talking = false; makeFromWords(event.text, true); } if (event.kind === 'final') { talking = false; makeFromWords(event.text); } @@ -161,6 +161,6 @@ event.preventDefault(); const text = $('words').value.trim(); if (!text) return; $('prompt-modal').close(); makeFromWords(text); }; - window.webkit.messageHandlers.walkie.postMessage({action:'ready',id:'startup'}); + window.webkit.messageHandlers.whistlegraph.postMessage({action:'ready',id:'startup'}); document.addEventListener('visibilitychange', () => { if (document.hidden) voiceEnd(true); }); })(); diff --git a/apple/walkieware/Resources/Web/piece-caption.mjs b/apple/whistlegraph/Resources/Web/piece-caption.mjs similarity index 100% rename from apple/walkieware/Resources/Web/piece-caption.mjs rename to apple/whistlegraph/Resources/Web/piece-caption.mjs diff --git a/apple/walkieware/Resources/Web/piece-versions.mjs b/apple/whistlegraph/Resources/Web/piece-versions.mjs similarity index 100% rename from apple/walkieware/Resources/Web/piece-versions.mjs rename to apple/whistlegraph/Resources/Web/piece-versions.mjs diff --git a/apple/whistlegraph/Resources/Web/roblox-connection.mjs b/apple/whistlegraph/Resources/Web/roblox-connection.mjs new file mode 100644 index 0000000000..acea0c65d1 --- /dev/null +++ b/apple/whistlegraph/Resources/Web/roblox-connection.mjs @@ -0,0 +1,26 @@ +import {validateRoom} from './room-schema.mjs'; +export function launchURL(value){ + const url=new URL(value); + if(url.protocol!=='https:'||url.hostname!=='www.roblox.com'||url.pathname!=='/share'||url.username||url.password||url.port||!url.searchParams.get('code'))throw Error('The Roblox launch link is invalid.'); + return url.href; +} +// Journal before sending. Replaying a lost response uses the same idempotency +// key; a later edit waits until that previous save has been reconciled. +export async function saveForLaunch({storage,owner,room,request,uuid=()=>crypto.randomUUID()}){ + if(!owner)throw Error('Wait for your account to connect.'); + const key='whistlegraph-roblox-cloud-'+owner,source=JSON.stringify(validateRoom(room)); + const settle=async pending=>{ + validateRoom(pending.room); + const result=await request('POST',pending); + if(!Number.isSafeInteger(result.revision)||result.revision!==pending.expectedRevision+1)throw Error('The room save was not acknowledged.'); + const url=launchURL(result.launchURL); + storage.setItem(key,JSON.stringify({revision:result.revision,source:JSON.stringify(pending.room)})); + storage.removeItem(key+'-pending');return {...result,launchURL:url}; + }; + const saved=storage.getItem(key+'-pending'); + if(saved){const pending=JSON.parse(saved),result=await settle(pending);if(JSON.stringify(pending.room)===source)return result;} + const prior=JSON.parse(storage.getItem(key)||'null'); + const pending={action:'save',expectedRevision:prior?.revision||0,room:JSON.parse(source),requestId:uuid()}; + storage.setItem(key+'-pending',JSON.stringify(pending)); + return settle(pending); +} diff --git a/apple/whistlegraph/Resources/Web/roblox-engine.mjs b/apple/whistlegraph/Resources/Web/roblox-engine.mjs new file mode 100644 index 0000000000..bf5f25c958 --- /dev/null +++ b/apple/whistlegraph/Resources/Web/roblox-engine.mjs @@ -0,0 +1,148 @@ +import {AcServer} from '/easel/src/ac-server.mjs'; +import {verifyAccount} from '/easel/src/account-access.mjs'; +import {DEFAULT_MODEL} from './generation-policy.mjs'; +import {PieceVersions} from './piece-versions.mjs'; +import {wareControls,WARE_INSTRUCTIONS,requestedWare} from './wares.mjs'; +import {starterRoom,validateRoom,drawPath,localRoomEdit,ROOM_SCHEMA,ROOM_LIMITS} from './room-schema.mjs'; +import {roomPreview} from './roblox-preview.mjs'; +import {roomPlace} from './roblox-export.mjs'; +import {saveForLaunch} from './roblox-connection.mjs'; + +const post=body=>window.webkit.messageHandlers.whistlegraph.postMessage({id:'roblox',...body}); +const key='whistlegraph-roblox-room',endpoint='https://aesthetic.computer/api/whistlegraph-roblox'; +const versions=new PieceVersions(localStorage,key+'-versions',JSON.stringify(starterRoom())); +let room=validateRoom(JSON.parse(versions.head.source)),selected='bridge',token='',handle='',owner='',busy=false,server=null; +let notice='Saved on this phone.',error='',output='',attempt=null,staged=null,cancelled=false,successful=false; +const wares=wareControls('roblox'); +document.body.classList.add('live-mode'); +document.getElementById('initial').hidden=true; +const box=document.createElement('div');box.id='live-preview-box';document.getElementById('stage').prepend(box); +const preview=roomPreview(box,{getRoom:()=>staged||room,canEdit:()=>!busy&&!window.whistlegraphRecording?.(), + onPath:points=>commit(drawPath(room,points),'Draw a path'),onSelect:id=>{selected=id;snapshot();},onError:message=>{error=message;snapshot();}}); +function snapshot(){ + post({action:'snapshot',snapshot:{ware:'roblox',roblox:{selected,notice:error||notice},code:'',handle,colors:[],head:versions.head.id, + hasPiece:true,hasPreview:true,busy,phase:busy?'Building room…':'Room plan',error,output:output.slice(-6000),attempt, + versions:versions.value.versions.map(v=>({id:v.id,parent:v.parent,utterance:v.request||'',createdAt:v.createdAt})), + }}); +} +function commit(next,request){ + if(versions.value.versions.length>=ROOM_LIMITS.history)throw Error('This room has reached its version limit. Export it before starting another.'); + const value=validateRoom(next),source=JSON.stringify(value); + if(source===versions.head.source)return false; + versions.commit({source,request,layers:1});room=value;staged=null;error='';notice='Saved on this phone.'; + preview.paint();snapshot();return true; +} +function checkout(id){ + const version=versions.value.versions.find(v=>v.id===id); + if(!version)throw Error('Version unavailable'); + const next=validateRoom(JSON.parse(version.source)); + versions.checkout(id);room=next;staged=null;error='';notice='Saved on this phone.';preview.paint();snapshot(); +} +const artifacts={ + context:async()=>`You are editing a Whistlegraph Roblox Room. The app shows a top-down room plan, not Roblox physics. All content is bounded data; never emit scripts. Existing object IDs must remain stable. Coordinates are Roblox studs: Y is height, X/Z are the plan. Use platform or goal; bounce is upward speed (0 disables it). Preserve a reachable spawn and the rest of the room. Selected object: ${selected}. Current room: ${JSON.stringify(staged||room)}`, + tools:async()=>[{name:'artifact_room',description:'Stage a complete valid room description. Preserve existing objects unless asked to remove them. One successful request saves one version after the turn finishes.',input_schema:{type:'object',properties:{room:ROOM_SCHEMA},required:['room'],additionalProperties:false}}], + run:async(name,input)=>{ + if(cancelled||wares.pending)throw Error('Room editing stopped'); + if(name!=='room'||Object.keys(input).some(k=>k!=='room'))throw Error('Use the room tool'); + staged=validateRoom(input.room);preview.paint();snapshot(); + return {version:versions.head.id,summary:'Room plan staged; Roblox execution not tested',objects:staged.objects.length}; + }, +}; +async function ask(text){ + if(busy)return; + if(!String(text).trim()){error='Say what should change in the room.';snapshot();window.whistlegraphWorkFinished?.();return;} + const target=requestedWare(text); + if(target){window.whistlegraphWorkFinished?.();window.whistlegraphSelectWare?.(target);return;} + error='';output='';wares.clear();cancelled=false;successful=false;staged=null; + try { + const local=localRoomEdit(room,text,selected); + if(local){commit(local,text);return;} + if(!token){error='Sign in to ask the brain to edit this room.';post({action:'signIn'});return;} + busy=true;attempt={request:text,status:'working',error:''};preview.cancel();snapshot(); + server=new AcServer({token:()=>token,model:window.__whistlegraphModel||DEFAULT_MODEL,artifacts,controls:wares,preview:false,frameCapture:false,rounds:6,reasoning:{effort:'none'},thinking:{type:'disabled'}, + developerInstructions:'This is Whistlegraph. Make the requested room edit using artifact_room. Keep changes small and use only the supported grammar. Do not claim to have run Roblox, published a game, or linked an account. No preamble. If the requested behavior is unsupported, explain that briefly instead of inventing a component. '+WARE_INSTRUCTIONS}); + server.on('notification',({method,params})=>{ + if(method==='item/agentMessage/delta'){output+=params.delta;snapshot();} + if(method==='turn/completed'){successful=params.turn.status==='completed'&&!params.turn.error;if(!successful&&!cancelled)error=params.turn.error?.message||'Could not finish the room';} + }); + await server.startTurn(text); + if(!cancelled&&successful&&!wares.pending){ + if(staged)commit(staged,text); + else notice=output.trim().slice(0,500)||'No change to the room.'; + } + attempt={...attempt,status:successful?'completed':'failed',error}; + }catch(failure){error=failure.message;if(attempt)attempt={...attempt,status:'failed',error};} + finally{ + const target=!cancelled&&successful?wares.pending:null;wares.clear(); + staged=null;busy=false;server?.close();server=null;preview.paint();snapshot(); + window.whistlegraphWorkFinished?.(); + if(target)window.whistlegraphSelectWare?.(target); + } +} +async function requestRoom(method,body){ + const response=await fetch(endpoint,{method,headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'},...(body?{body:JSON.stringify(body)}:{}),signal:AbortSignal.timeout(15000)}); + const data=await response.json().catch(()=>null); + if(!response.ok)throw Error(data?.error||(response.status===404?'Roblox connection is not set up yet.':`Room service unavailable (${response.status})`)); + return data; +} +async function play(){ + if(busy)return; + if(!token){error='Sign in to connect this room to Roblox.';post({action:'signIn'});snapshot();return;} + if(!owner){error='Wait for your account to connect.';notice=error;snapshot();return;} + const identity=owner,credential=token; + const request=async(...args)=>{ + if(owner!==identity||token!==credential)throw Error('Account changed. Tap Play again.'); + const result=await requestRoom(...args); + if(owner!==identity||token!==credential)throw Error('Account changed. Tap Play again.'); + return result; + }; + busy=true;error='';notice='Saving room for Roblox…';snapshot(); + try { + const config=await request('GET'); + if(!config.available)throw Error('Roblox connection is not set up yet. You can export this room for Studio.'); + // The backend binds this signed-in maker to a configured Roblox pilot + // identity. The client never supplies or trusts a Roblox user ID. + const data=await saveForLaunch({storage:localStorage,owner:identity,room,request}); + notice='Saved for Roblox. Opening…';post({action:'openRoblox',url:data.launchURL}); + }catch(failure){error=failure.message;notice=error;} + finally{busy=false;snapshot();} +} +window.whistlegraphNativeCommand=command=>{ + if(command.action==='signIn'){post({action:'signIn'});return;} + if(command.action==='stop'){cancelled=true;server?.interrupt();return;} + if(busy)return; + try { + if(command.action==='setWare')window.whistlegraphSelectWare?.(command.ware); + if(command.action==='ask'&&typeof command.text==='string'&&command.text.trim()&&Array.from(new Intl.Segmenter(undefined,{granularity:'grapheme'}).segment(command.text)).length<=96)void ask(command.text.trim()); + if(command.action==='checkout')checkout(command.version); + if(command.action==='undoRoom'&&versions.head.parent!==null)checkout(versions.head.parent); + if(command.action==='retry'&&attempt?.request)void ask(attempt.request); + if(command.action==='playRoblox')void play(); + if(command.action==='exportRoom')post({action:'exportRoom',source:roomPlace(room)}); + }catch(failure){error=failure.message;snapshot();} +}; +window.whistlegraphEngineEvent=event=>{ + if(event.kind==='account'){ + token=event.token||'';handle='';owner='';window.whistlegraphAccountReady=!!token; + if(!token&&busy){cancelled=true;server?.interrupt();} + const expected=token; + if(token)void verifyAccount(token).then(account=>{if(token===expected){handle=account.handle||'';owner=account.sub;snapshot();}}).catch(()=>{}); + snapshot(); + } + if(event.kind==='error'){error=event.text||'Sign-in failed';snapshot();window.whistlegraphWorkFinished?.();} + if(event.kind==='robloxLaunch'){notice=event.opened?'Room saved. Switch back here to keep editing.':'Could not open the Roblox link.';snapshot();} +}; +window.whistlegraphAsk=ask; +window.whistlegraphAskSound=input=>ask(input.transcript?.trim()||''); +window.whistlegraphIsBusy=()=>busy; +window.whistlegraphUndo=()=>{if(!busy&&versions.head.parent!==null)checkout(versions.head.parent);}; +document.addEventListener('visibilitychange',()=>{ + if(document.hidden){preview.cancel();if(busy){cancelled=true;server?.interrupt();}} + else if(owner&&!busy){ + const identity=owner,saved=JSON.parse(localStorage.getItem('whistlegraph-roblox-cloud-'+identity)||'null'); + if(saved&&saved.source===JSON.stringify(room))void requestRoom('GET').then(status=>{ + if(identity===owner&&saved.source===JSON.stringify(room)&&status.appliedRevision===saved.revision){notice='Applied in Roblox.';snapshot();} + }).catch(()=>{}); + } +}); +snapshot();post({action:'account'}); diff --git a/apple/whistlegraph/Resources/Web/roblox-export.mjs b/apple/whistlegraph/Resources/Web/roblox-export.mjs new file mode 100644 index 0000000000..f3606a7ccf --- /dev/null +++ b/apple/whistlegraph/Resources/Web/roblox-export.mjs @@ -0,0 +1,8 @@ +import {validateRoom} from './room-schema.mjs'; +import {ROOM_RUNTIME} from './roblox-runtime.mjs'; +const xml=value=>String(value).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"'); +export function roomPlace(room){ + const data=JSON.stringify(validateRoom(room)); + const source=`local Http = game:GetService("HttpService")\nlocal Room = (function()\n${ROOM_RUNTIME}\nend)()\nlocal room = Http:JSONDecode([==[${data}]==])\nlocal model = Room.build(room, Vector3.zero)\nmodel.Parent = workspace\nworkspace.FallenPartsDestroyHeight = -60\n`; + return `WorkspaceServerScriptServiceWhistlegraphRoom${xml(source)}`; +} diff --git a/apple/whistlegraph/Resources/Web/roblox-preview.mjs b/apple/whistlegraph/Resources/Web/roblox-preview.mjs new file mode 100644 index 0000000000..1b76f86ae3 --- /dev/null +++ b/apple/whistlegraph/Resources/Web/roblox-preview.mjs @@ -0,0 +1,43 @@ +// A room plan, not a Roblox physics simulation. Drawn strokes become platforms. +export function roomPreview(container,{getRoom,onPath,onSelect,onError,canEdit}){ + container.innerHTML='
Room plan
'; + const style=document.createElement('style');style.textContent=`.room-toolbar{height:44px;display:flex;align-items:center;gap:8px;padding:0 8px;background:#f1eee7;color:#24202b;font:15px Comic,Arial}.room-toolbar span{flex:1}.room-toolbar button{min-height:40px;padding:0 10px;background:transparent;color:inherit;border-radius:7px}.room-toolbar button[aria-pressed=true]{background:#d9c5da}#room-plan{display:block;width:100%;height:calc(100% - 44px);touch-action:none;background:#f8f6f1}`;document.head.append(style); + const canvas=container.querySelector('canvas'),context=canvas.getContext('2d'); + let drawing=false,selected='bridge',points=null,pointer=null; + const select=container.querySelector('#room-select'),draw=container.querySelector('#room-draw'); + function mode(value){drawing=value;select.setAttribute('aria-pressed',String(!value));draw.setAttribute('aria-pressed',String(value));} + select.onclick=()=>mode(false);draw.onclick=()=>mode(true); + function layout(){const rect=canvas.getBoundingClientRect();return {w:rect.width,h:rect.height,scale:Math.min(rect.width,rect.height)/100};} + function local(event){const rect=canvas.getBoundingClientRect(),{w,h,scale}=layout();return [Math.max(-48,Math.min(48,(event.clientX-rect.left-w/2)/scale)),Math.max(-48,Math.min(48,(event.clientY-rect.top-h/2)/scale))];} + function paint(){ + const {w,h,scale}=layout(),dpr=window.devicePixelRatio||1; + canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr); + context.setTransform(dpr,0,0,dpr,0,0);context.fillStyle='#f8f6f1';context.fillRect(0,0,w,h); + context.translate(w/2,h/2);context.scale(scale,scale); + context.strokeStyle='#e8e3dc';context.lineWidth=.5; + for(let i=-48;i<=48;i+=8){context.beginPath();context.moveTo(i,-48);context.lineTo(i,48);context.moveTo(-48,i);context.lineTo(48,i);context.stroke();} + for(const o of getRoom().objects){ + context.save();context.translate(o.position[0],o.position[2]);context.rotate(-o.yaw*Math.PI/180); + context.fillStyle=`rgb(${o.color.join(',')})`;context.fillRect(-o.size[0]/2,-o.size[2]/2,o.size[0],o.size[2]); + context.lineWidth=o.id===selected?1.1:.3;context.strokeStyle=o.id===selected?'#292032':'#ffffff';context.strokeRect(-o.size[0]/2,-o.size[2]/2,o.size[0],o.size[2]); + if(o.bounce>0){context.fillStyle='#ffffff';context.font='bold 5px Arial';context.textAlign='center';context.fillText('↑',0,2);} + if(o.kind==='goal'){context.fillStyle='#292032';context.beginPath();context.arc(0,0,1.2,0,2*Math.PI);context.fill();} + context.restore(); + } + const spawn=getRoom().spawn;context.beginPath();context.arc(spawn[0],spawn[2],1.8,0,2*Math.PI);context.fillStyle='#fff';context.fill();context.strokeStyle='#24202b';context.lineWidth=.7;context.stroke(); + if(points){context.beginPath();points.forEach((p,i)=>i?context.lineTo(...p):context.moveTo(...p));context.strokeStyle='#b44887';context.lineWidth=4;context.lineCap='round';context.stroke();} + } + canvas.onpointerdown=event=>{ + if(!canEdit())return;const point=local(event); + if(drawing){pointer=event.pointerId;points=[point];canvas.setPointerCapture(pointer);} + else {for(const o of [...getRoom().objects].reverse()){ + const dx=point[0]-o.position[0],dz=point[1]-o.position[2],a=o.yaw*Math.PI/180; + if(Math.abs(dx*Math.cos(a)-dz*Math.sin(a))<=o.size[0]/2&&Math.abs(dx*Math.sin(a)+dz*Math.cos(a))<=o.size[2]/2){selected=o.id;onSelect(o.id);break;} + }}paint(); + }; + canvas.onpointermove=event=>{if(event.pointerId!==pointer||!points||points.length>=128)return;const p=local(event);if(Math.hypot(p[0]-points.at(-1)[0],p[1]-points.at(-1)[1])>=3){points.push(p);paint();}}; + canvas.onpointerup=event=>{if(event.pointerId!==pointer||!points)return;const path=points;points=null;pointer=null;try{if(canEdit())onPath(path);}catch(error){onError(error.message);}paint();}; + canvas.onpointercancel=()=>{points=null;pointer=null;paint();}; + new ResizeObserver(paint).observe(canvas); + return {paint,select(id){selected=id;paint();},cancel(){points=null;pointer=null;paint();}}; +} diff --git a/apple/walkieware/Resources/Web/sequence-benchmark.mjs b/apple/whistlegraph/Resources/Web/sequence-benchmark.mjs similarity index 84% rename from apple/walkieware/Resources/Web/sequence-benchmark.mjs rename to apple/whistlegraph/Resources/Web/sequence-benchmark.mjs index 0ab8e21931..79582c336d 100644 --- a/apple/walkieware/Resources/Web/sequence-benchmark.mjs +++ b/apple/whistlegraph/Resources/Web/sequence-benchmark.mjs @@ -26,35 +26,35 @@ export const sceneMoves = [ const sceneContract = 'Use standard AC JavaScript lifecycle boot/sim/paint. Read screen dimensions at paint/sim time, use relative sizing. Use documented wipe, ink, line, box and circle drawing APIs and numeric RGB. No external assets, network, audio, randomness, publishing, or clock dependencies. Always redraw the complete scene each paint. Use a frame counter in sim for smooth deterministic animation. Preserve all previous scene features unless this ask changes them.'; export async function runSequence(api) { const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms)); - while(!window.walkiewareAccountReady||!api.ready())await delay(100); - const start=window.__walkiewareSequenceStart||1; - const prompts=window.__walkiewareSpace?spaceMoves:window.__walkiewareScene?sceneMoves:moves; - const contract=window.__walkiewareSpace||window.__walkiewareScene?sceneContract:scenarioContract; + while(!window.whistlegraphAccountReady||!api.ready())await delay(100); + const start=window.__whistlegraphSequenceStart||1; + const prompts=window.__whistlegraphSpace?spaceMoves:window.__whistlegraphScene?sceneMoves:moves; + const contract=window.__whistlegraphSpace||window.__whistlegraphScene?sceneContract:scenarioContract; for(let index=start;index<=prompts.length;index++) { const before=api.source();const events=[];const errors=[];let finished=false; const began=performance.now(); - window.__walkiewareSequenceEvent=(event,fields={})=>{ + window.__whistlegraphSequenceEvent=(event,fields={})=>{ events.push({event,ms:Math.round(performance.now()-began),...fields}); if(event==='generationFinished')finished=true; if(event==='runtimeError')errors.push(fields.message||'Preview error'); }; document.getElementById('live-keep').click(); - const deadline=setTimeout(()=>api.interrupt(),window.__walkiewareScene||window.__walkiewareSpace?120000:60000); - try {await api.ask(window.__walkiewareSpace&&index===1?prompts[0]:prompts[index-1]+' '+contract+' Apply the change using small edit_piece layers; avoid rewriting unchanged code.');} + const deadline=setTimeout(()=>api.interrupt(),window.__whistlegraphScene||window.__whistlegraphSpace?120000:60000); + try {await api.ask(window.__whistlegraphSpace&&index===1?prompts[0]:prompts[index-1]+' '+contract+' Apply the change using small edit_piece layers; avoid rewriting unchanged code.');} finally {clearTimeout(deadline);} // Give the committed module time to load and expose delayed runtime failures. for(let n=0;n<50&&!api.painted();n++)await delay(100); await delay(750); const source=api.source(); - const report={index,total:prompts.length,prompt:prompts[index-1],requiresMotion:window.__walkiewareSpace?index>=5:!window.__walkiewareScene||index>1,motionReview:window.__walkiewareSpace&&index>=5,model:api.model,source,events,errors, + const report={index,total:prompts.length,prompt:prompts[index-1],requiresMotion:window.__whistlegraphSpace?index>=5:!window.__whistlegraphScene||index>1,motionReview:window.__whistlegraphSpace&&index>=5,model:api.model,source,events,errors, scope:'Successive text edits on physical iPhone; audio excluded. Runtime and animation checks plus screenshots; feature semantics require visual review.', checks:{...checksFor({finished,changed:source.trim()!==before.trim(),painted:api.painted(),errors,source}),oneVersionPerAsk:events.filter(e=>e.event==='versionCommitted').length===1}}; const result=await new Promise(resolve=>{ - window.__walkiewareSequenceCaptureDone=resolve; + window.__whistlegraphSequenceCaptureDone=resolve; const rect=document.getElementById('live-piece').getBoundingClientRect(); - window.webkit.messageHandlers.walkie.postMessage({action:'sequenceCapture',id:'sequence',report,rect:{x:rect.x,y:rect.y,width:rect.width,height:rect.height}}); + window.webkit.messageHandlers.whistlegraph.postMessage({action:'sequenceCapture',id:'sequence',report,rect:{x:rect.x,y:rect.y,width:rect.width,height:rect.height}}); }); - window.__walkiewareSequenceEvent=null; + window.__whistlegraphSequenceEvent=null; document.getElementById('live-phase').textContent=`Move ${index}/${prompts.length} ${result.passed?'passed':'failed'}`; if(!result.passed){if(events.some(e=>e.event==='versionCommitted'))document.getElementById('live-undo').click();return;} } diff --git a/apple/walkieware/Resources/Web/shell.html b/apple/whistlegraph/Resources/Web/shell.html similarity index 59% rename from apple/walkieware/Resources/Web/shell.html rename to apple/whistlegraph/Resources/Web/shell.html index 15f7cfff5a..81671b982e 100644 --- a/apple/walkieware/Resources/Web/shell.html +++ b/apple/whistlegraph/Resources/Web/shell.html @@ -1,12 +1,12 @@ -Walkieware +Whistlegraph -
+@font-face{font-family:Comic;src:url('ComicRelief-Regular.ttf')}*{box-sizing:border-box}body{margin:0;background:#19172e;color:#f4efdd;font-family:Comic,Arial;overflow:hidden}button{font:inherit;border:0;cursor:pointer}button:disabled{opacity:.6}[hidden]{display:none!important}.top{height:68px;padding:16px 21px}.brand{color:#f4efdd;background:none}.stage{position:absolute;inset:0;pointer-events:none}.stage>*{pointer-events:auto}.initial{position:absolute;inset:110px 30px 180px;display:flex;align-items:center;justify-content:center;flex-direction:column}.initial h1{font-size:36px;text-align:center}.mark{margin-bottom:24px}.whistlegraph-dock{position:absolute;bottom:22px;left:21px;right:21px;z-index:7}.speak-button{width:100%;min-height:132px;background:#d9ef9f;color:#262038;border:3px solid #f7f5dc;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px}.speak-button.holding{background:#f7b474}.speak-button svg{width:30px;height:30px;fill:none;stroke:currentColor;stroke-width:2}.voice-state{position:absolute;inset:70px 0 165px;padding:30px;display:flex;flex-direction:column;justify-content:center;z-index:8}.voice-state p{white-space:pre-wrap}.wave-bars{display:none}dialog{background:#272139;color:#f4efdd;border:1px solid #665973;border-radius:12px;max-width:90%;font:22px Comic,Arial}dialog button{padding:14px}textarea{font:24px Comic,Arial;width:100%}.toast{position:absolute;top:80px;left:20px;right:20px;z-index:20;background:#4d2840;padding:15px} + +

Say what it should do.

-

-

Walkieware

+

+

Whistlegraph

'+importMap); -html=html.replace('',''); +html=html.replace('',''); await writeFile(new URL('Resources/Web/index.html',base),html); await copyFile(new URL('system/public/papers.aesthetic.computer/foundry/fonts/ComicRelief-Regular.ttf',root),new URL('Resources/Web/ComicRelief-Regular.ttf',base)); diff --git a/apple/walkieware/icon.svg b/apple/whistlegraph/icon.svg similarity index 100% rename from apple/walkieware/icon.svg rename to apple/whistlegraph/icon.svg diff --git a/apple/walkieware/project.yml b/apple/whistlegraph/project.yml similarity index 85% rename from apple/walkieware/project.yml rename to apple/whistlegraph/project.yml index 4a5ed82955..f6d30c8a30 100644 --- a/apple/walkieware/project.yml +++ b/apple/whistlegraph/project.yml @@ -1,4 +1,4 @@ -name: Walkieware +name: Whistlegraph options: bundleIdPrefix: computer.aesthetic deploymentTarget: @@ -9,9 +9,9 @@ settings: CODE_SIGN_STYLE: Automatic SWIFT_VERSION: "5.0" MARKETING_VERSION: "0.1.0" - CURRENT_PROJECT_VERSION: "82" + CURRENT_PROJECT_VERSION: "83" targets: - Walkieware: + Whistlegraph: type: application platform: iOS sources: @@ -27,7 +27,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: computer.aesthetic.walkieware - PRODUCT_NAME: Walkieware + PRODUCT_NAME: Whistlegraph INFOPLIST_FILE: Info.plist TARGETED_DEVICE_FAMILY: "1" SUPPORTS_MACCATALYST: NO @@ -35,17 +35,17 @@ targets: scheme: gatherCoverageData: false - WalkiewareUITests: + WhistlegraphUITests: type: bundle.ui-testing platform: iOS sources: - UITests dependencies: - - target: Walkieware + - target: Whistlegraph settings: base: PRODUCT_BUNDLE_IDENTIFIER: computer.aesthetic.walkieware.uitests GENERATE_INFOPLIST_FILE: YES scheme: testTargets: - - WalkiewareUITests + - WhistlegraphUITests diff --git a/apple/walkieware/run.sh b/apple/whistlegraph/run.sh similarity index 65% rename from apple/walkieware/run.sh rename to apple/whistlegraph/run.sh index a3de675914..e5bd3b9b8f 100755 --- a/apple/walkieware/run.sh +++ b/apple/whistlegraph/run.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build and install the isolated Walkieware demo. DEVICE may select a paired phone. +# Build and install the isolated Whistlegraph demo. DEVICE may select a paired phone. # ./run.sh device | ./run.sh simulator set -euo pipefail cd "$(dirname "$0")" @@ -7,18 +7,18 @@ node bundle.mjs xcodegen generate >/dev/null mode=${1:-device} if [[ "$mode" == simulator ]]; then - derived=${DERIVED:-/tmp/walkieware-sim-dd} + derived=${DERIVED:-/tmp/whistlegraph-sim-dd} simulator=${SIMULATOR:-FEB14FE3-7FDA-4D18-BCD6-F06509360BEA} - xcodebuild -project Walkieware.xcodeproj -scheme Walkieware -configuration Debug -destination 'generic/platform=iOS Simulator' -derivedDataPath "$derived" -jobs 2 CODE_SIGNING_ALLOWED=NO build + xcodebuild -project Whistlegraph.xcodeproj -scheme Whistlegraph -configuration Debug -destination 'generic/platform=iOS Simulator' -derivedDataPath "$derived" -jobs 2 CODE_SIGNING_ALLOWED=NO build xcrun simctl boot "$simulator" 2>/dev/null || true - xcrun simctl install "$simulator" "$derived/Build/Products/Debug-iphonesimulator/Walkieware.app" + xcrun simctl install "$simulator" "$derived/Build/Products/Debug-iphonesimulator/Whistlegraph.app" xcrun simctl launch "$simulator" computer.aesthetic.walkieware elif [[ "$mode" == device ]]; then - derived=${DERIVED:-/tmp/walkieware-dd} - xcodebuild -project Walkieware.xcodeproj -scheme Walkieware -configuration Debug -destination 'generic/platform=iOS' -derivedDataPath "$derived" -allowProvisioningUpdates -jobs 2 build + derived=${DERIVED:-/tmp/whistlegraph-dd} + xcodebuild -project Whistlegraph.xcodeproj -scheme Whistlegraph -configuration Debug -destination 'generic/platform=iOS' -derivedDataPath "$derived" -allowProvisioningUpdates -jobs 2 build device=${DEVICE:-$(xcrun devicectl list devices --json-output - 2>/dev/null | python3 -c 'import json,sys; devices=json.load(sys.stdin).get("result",{}).get("devices",[]); matches=[d["identifier"] for d in devices if d.get("hardwareProperties",{}).get("deviceType")=="iPhone" and d.get("hardwareProperties",{}).get("reality")=="physical" and d.get("connectionProperties",{}).get("pairingState")=="paired"]; print(matches[0] if len(matches)==1 else "")')} [[ -n "$device" ]] || { echo 'Select a paired iPhone with DEVICE=.' >&2; exit 1; } - xcrun devicectl device install app --device "$device" "$derived/Build/Products/Debug-iphoneos/Walkieware.app" + xcrun devicectl device install app --device "$device" "$derived/Build/Products/Debug-iphoneos/Whistlegraph.app" xcrun devicectl device process launch --device "$device" --terminate-existing computer.aesthetic.walkieware else echo 'Usage: ./run.sh device|simulator' >&2; exit 1 diff --git a/docs/embedding-ac-pieces.md b/docs/embedding-ac-pieces.md index 079fc7ede4..54f0db8a43 100644 --- a/docs/embedding-ac-pieces.md +++ b/docs/embedding-ac-pieces.md @@ -107,8 +107,8 @@ Existing integrations: - [Shared “Try” page](../system/public/aesthetic.computer/lib/try/shared-page.mjs): constructs embedded URLs with `nogap`, `nolabel`, and `noauth`. - [Aesel phone sessions](../aesel/phone/session.mjs): published-piece preview URLs. -- [Walkieware host](../apple/walkieware/Resources/Web/engine.mjs), - [native source bridge](../apple/walkieware/Sources/WalkiewareAccount.swift). +- [Whistlegraph host](../apple/whistlegraph/Resources/Web/engine.mjs), + [native source bridge](../apple/whistlegraph/Sources/WhistlegraphAccount.swift). ## Diagnose an unwanted gap @@ -136,13 +136,17 @@ that have no normal query string. BIOS honors it on every reframe, including explicit nonzero gap requests. It is not a URL parameter, and an ordinary web embed should use `nogap=true` rather than depend on script injection. -## Iterate on Walkieware layout without restarting +## Iterate on Whistlegraph layout without restarting -For a running Walkieware app with the live-layout bridge, send a CSS file through +Whistlegraph lives in `apple/whistlegraph` (formerly `apple/walkieware`). The installed bundle identifier, Keychain service, and WebKit storage origin retain their legacy identifiers so updates preserve sign-in and artwork. Local storage is copied once to `whistlegraph-*` keys; saved source and history remain intact. Existing `ww…` piece codes, API routes, module imports, and the `ww.mjs` CLI remain compatibility aliases. New codes use `wg…`; the primary CLI is `slab/bin/whistlegraph.mjs`. + +The Ware picker defaults to **Aesthetic.Computer Piece** and also offers **Roblox Room**. Each has separate saved versions. The brain can queue a ware switch; explicit requests such as “switch to Roblox” also work locally. The room editor and private service setup are described in [`roblox/rooms/README.md`](../roblox/rooms/README.md). + +For a running Whistlegraph app with the live-layout bridge, send a CSS file through its existing owner-authenticated thread socket: ```sh -node slab/bin/ww.mjs layout wwRuboh /tmp/walkieware-layout.css +node slab/bin/whistlegraph.mjs layout wwRuboh /tmp/whistlegraph-layout.css ``` The stylesheet replaces the previous live override and persists locally across @@ -153,8 +157,8 @@ returns an acknowledgement after applying the override. CSS is limited to 100 KB Native Swift changes still require an app build/install; this is a CSS iteration path, not Swift code hot reload. -Walkieware's native shell keeps its `Workspace` WebView at one stable SwiftUI -position. `WalkiewareScreen.swift` owns the preview card, trailing caption, +Whistlegraph's native shell keeps its `Workspace` WebView at one stable SwiftUI +position. `WhistlegraphScreen.swift` owns the preview card, trailing caption, version feed, and talk dock. `engine.mjs` remains the authority for versions and generation; it sends typed, throttled display snapshots and accepts native checkout/new-piece/stop commands. The engine document is the dedicated diff --git a/lith/musical-socket.test.mjs b/lith/musical-socket.test.mjs index d8ec40174f..4046550cc5 100644 --- a/lith/musical-socket.test.mjs +++ b/lith/musical-socket.test.mjs @@ -9,7 +9,7 @@ import {MusicalInputSocket} from '../aesel/src/musical-input-socket.mjs'; import {MusicalInputAdvisor} from '../aesel/src/musical-input-advisor.mjs'; const input={transcript:'Private words',words:[],sound:{frames:[1,2,3].map(i=>({atMs:i*100,pitchHz:440,rms:.2})),onsetsMs:[]}}; const features={hasSpeech:true,hasTonalSound:true,soundAfterSpeech:false,contour:'steady',attacks:0,rhythm:'unknown',energy:'steady'}; -const body=(sequence=1)=>({schema:'walkieware-input/v1',sessionId:'12345678-1234-1234-1234-123456789012',sequence,features}); +const body=(sequence=1)=>({schema:'whistlegraph-input/v1',sessionId:'12345678-1234-1234-1234-123456789012',sequence,features}); const answer={answers:{mapping:{choice:'follow_speech',probabilities:{follow_speech:.95}}}}; async function fixture(t,overrides={}){ let auths=0,calls=0,quotas=0; @@ -38,7 +38,7 @@ test('unauthenticated, malformed and expired connections are closed', {timeout:3 }); test('slow inference keeps newest pending observation; cancel aborts active work', {timeout:3000},async t=>{ const started=[],releases=[]; - const f=await fixture(t,{decide:(event,{signal})=>new Promise(resolve=>{const b=JSON.parse(event.body);started.push(b.sequence);const release=()=>resolve({statusCode:200,body:JSON.stringify({...b,schema:'walkieware-decision/v1',choice:'sustain',confidence:.95})});releases.push(release);signal.addEventListener('abort',release);})}); + const f=await fixture(t,{decide:(event,{signal})=>new Promise(resolve=>{const b=JSON.parse(event.body);started.push(b.sequence);const release=()=>resolve({statusCode:200,body:JSON.stringify({...b,schema:'whistlegraph-decision/v1',choice:'sustain',confidence:.95})});releases.push(release);signal.addEventListener('abort',release);})}); const c=await client(f.url);c.send({type:'authenticate',token:'valid'});await c.next('ready'); c.send({type:'observation',body:body(1)});await c.next('received'); c.send({type:'observation',body:body(2)});await c.next('received'); diff --git a/lith/server.mjs b/lith/server.mjs index 2edefb6ef6..9aa0627160 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -48,7 +48,7 @@ if (typeof globalThis.awslambda === "undefined") { import express from "express"; import {attachMusicalSocket} from "./musical-socket.mjs"; -import {attachWalkiewareSocket} from "./walkieware-socket.mjs"; +import {attachWhistlegraphSocket} from "./whistlegraph-socket.mjs"; import { sendStream } from "./stream-response.mjs"; import { userMediaTarget } from "./media-path.mjs"; import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, renameSync, statSync } from "fs"; @@ -1462,8 +1462,8 @@ if (DEV && HAS_SSL) { // Share authentication, concurrency guards and durable quotas with the HTTP lane. const musicalAPI = await import(pathToFileURL(join(SYSTEM, "netlify/functions/easel-musical-jev.mjs")).href); const musicalSocket = attachMusicalSocket(server, {authenticate:musicalAPI.authenticateMusical, decide:musicalAPI.musicalDecision}); -const walkiewareAPI = await import(pathToFileURL(join(SYSTEM, "netlify/functions/walkieware.mjs")).href); -const walkiewareSocket = attachWalkiewareSocket(server, {authenticate:walkiewareAPI.authenticateWalkieware, store:walkiewareAPI.walkiewareStore}); +const whistlegraphAPI = await import(pathToFileURL(join(SYSTEM, "netlify/functions/whistlegraph.mjs")).href); +const whistlegraphSocket = attachWhistlegraphSocket(server, {authenticate:whistlegraphAPI.authenticateWhistlegraph, store:whistlegraphAPI.whistlegraphStore}); // --- Account deletions --- // Purges accounts whose grace period has ended (system/backend/ @@ -1541,7 +1541,7 @@ const DRAIN_TIMEOUT = 10_000; // 10s max wait function gracefulShutdown(signal) { console.log(`[lith] ${signal} received, draining connections...`); musicalSocket.close(); - walkiewareSocket.close(); + whistlegraphSocket.close(); server.close(() => { console.log("[lith] all connections drained, exiting"); process.exit(0); diff --git a/lith/walkieware-socket.mjs b/lith/walkieware-socket.mjs index 46c504af3c..c4cf9b815b 100644 --- a/lith/walkieware-socket.mjs +++ b/lith/walkieware-socket.mjs @@ -1,87 +1,2 @@ -import {WebSocketServer,WebSocket} from 'ws'; -import {randomUUID} from 'node:crypto'; -import {publicThread,sourceHash} from '../system/backend/walkieware.mjs'; - -export function attachWalkiewareSocket(server,{authenticate,store,authMs=5000,lifetimeMs=600000}={}) { - const wss=new WebSocketServer({noServer:true,maxPayload:8_100_000,perMessageDeflate:false}); - const rooms=new Map(); - const send=(ws,value)=>{if(ws?.readyState===WebSocket.OPEN){if(ws.bufferedAmount>8_100_000)ws.close(1008,'Slow reader');else ws.send(JSON.stringify(value));}}; - const upgrade=(req,socket,head)=>{ - if(req.url!=='/api/walkieware-stream')return; - if(wss.clients.size>=64){socket.destroy();return;} - wss.handleUpgrade(req,socket,head,ws=>wss.emit('connection',ws)); - }; - server.on('upgrade',upgrade); - wss.on('connection',ws=>{ - let owner,row,room,role,closed=false,chain=Promise.resolve(),pending=0,alive=true,messages=0,windowAt=Date.now(); - const authTimer=setTimeout(()=>ws.close(1008,'Authenticate first'),authMs); - const lifeTimer=setTimeout(()=>ws.close(1000,'Renew session'),lifetimeMs); - const heartbeat=setInterval(()=>{if(!alive)return ws.terminate();alive=false;ws.ping();},20000); - for(const timer of [authTimer,lifeTimer,heartbeat])timer.unref?.(); - ws.on('pong',()=>alive=true); - const broadcast=value=>{for(const client of room?.clients||[])send(client,value);}; - async function message(m) { - if(closed)return; - if(!owner) { - if(m.type!=='authenticate'||typeof m.token!=='string'||m.token.length>7000||!['device','agent'].includes(m.role))throw Error('Authenticate first'); - const user=await authenticate({authorization:`Bearer ${m.token}`}); - if(!user||closed)throw Error('Authentication failed'); - const db=await store();role=m.role; - row=role==='device'?await db.open(user,m.id):await db.read(user,m.code); - if(!row||closed)throw Error('Thread unavailable'); - owner=user;clearTimeout(authTimer); - room=rooms.get(row._id); - if(!room){room={clients:new Set(),device:null,state:null,commands:new Map()};rooms.set(row._id,room);} - if(role==='device'&&room.device)throw Error('This thread is running on another device'); - room.clients.add(ws);if(role==='device')room.device=ws; - send(ws,{type:'ready',thread:publicThread(row),online:!!room.device,state:room.state}); - if(role==='device')broadcast({type:'presence',online:true}); - return; - } - if(m.type==='sync'&&role==='device') { - const saved=await (await store()).save(owner,row._id,m.revision,m.ledger); - if(!saved){send(ws,{type:'conflict',thread:publicThread(await (await store()).read(owner,row.code))});return;} - row=saved;broadcast({type:'saved',thread:publicThread(row)});return; - } - if(m.type==='ping'){send(ws,{type:'pong',online:!!room.device,role,peers:room.clients.size,attached:rooms.get(row._id)===room});return;} - if(m.type==='state'&&role==='device') { - const state=m.state; - if(!state||JSON.stringify(state).length>525000)throw Error('Invalid state'); - room.state={busy:!!state.busy,phase:String(state.phase||'').slice(0,200),head:state.head,sourceHash:typeof state.source==='string'?sourceHash(state.source):null,source:typeof state.source==='string'?state.source.slice(0,500000):'',errors:Array.isArray(state.errors)?state.errors.slice(-20).map(e=>String(e).slice(0,1000)):[],attempt:state.attempt&&typeof state.attempt==='object'?{request:String(state.attempt.request||'').slice(0,20000),status:String(state.attempt.status||'').slice(0,40),error:String(state.attempt.error||'').slice(0,1000),parent:state.attempt.parent,startedAt:state.attempt.startedAt,finishedAt:state.attempt.finishedAt}:null,updatedAt:new Date().toISOString()}; - broadcast({type:'state',state:room.state}); - await (await store()).state?.(owner,row._id,room.state);return; - } - if(m.type==='command'&&role==='agent') { - const id=typeof m.id==='string'&&/^[a-zA-Z0-9-]{1,80}$/.test(m.id)?m.id:randomUUID(); - if(!room.device){send(ws,{type:'result',id,ok:false,error:'Device offline; saved versions remain available'});return;} - if(room.commands.size||room.state?.busy){send(ws,{type:'result',id,ok:false,error:'Device busy'});return;} - if(m.action==='layout'&&(typeof m.css!=='string'||Buffer.byteLength(m.css)>100000))throw Error('Invalid layout'); - if(!['ask','undo','edit','layout'].includes(m.action)||!Number.isSafeInteger(m.baseVersion)||typeof m.baseHash!=='string'||(m.action==='ask'&&(typeof m.text!=='string'||!m.text.trim()||m.text.length>20000))||(m.action==='edit'&&(typeof m.source!=='string'||Buffer.byteLength(m.source)>500000||!m.source.trim())))throw Error('Invalid command'); - const timer=setTimeout(()=>{room.commands.delete(id);send(ws,{type:'result',id,ok:false,error:'Timed out; inspect history before retrying'});},180000);timer.unref?.(); - room.commands.set(id,{ws,timer}); - send(room.device,{type:'command',id,action:m.action,text:m.text,source:m.source,css:m.css,baseVersion:m.baseVersion,baseHash:m.baseHash}); - send(ws,{type:'accepted',id});return; - } - if(m.type==='result'&&role==='device') { - const command=room.commands.get(m.id);if(!command)return; - clearTimeout(command.timer);room.commands.delete(m.id); - send(command.ws,{type:'result',id:m.id,ok:m.ok===true,head:m.head,error:String(m.error||'').slice(0,1000)});return; - } - throw Error('Unsupported message'); - } - ws.on('message',(data,binary)=>{ - if(Date.now()-windowAt>=1000){messages=0;windowAt=Date.now();} - if(++messages>30){ws.close(1008,'Message rate exceeded');return;} - if(binary||++pending>16){ws.close(1008,'Message limit');return;} - chain=chain.then(async()=>{try{await message(JSON.parse(data.toString()));}catch(error){send(ws,{type:'error',error:error.message});if(!room?.clients.has(ws))ws.close(1008,'Session unavailable');}finally{pending--;}}); - }); - ws.on('error',()=>{}); - ws.on('close',()=>{ - closed=true;clearTimeout(authTimer);clearTimeout(lifeTimer);clearInterval(heartbeat); - room?.clients.delete(ws); - if(room?.device===ws){room.device=null;room.state=null;broadcast({type:'presence',online:false});for(const [id,c] of room.commands){clearTimeout(c.timer);send(c.ws,{type:'result',id,ok:false,error:'Device disconnected; inspect history before retrying'});}room.commands.clear();} - if(room&&!room.clients.size)rooms.delete(row._id); - }); - }); - return {wss,close(){server.off('upgrade',upgrade);for(const ws of wss.clients)ws.terminate();wss.close();}}; -} +// Compatibility import for existing local tooling. +export {attachWhistlegraphSocket as attachWalkiewareSocket} from './whistlegraph-socket.mjs'; diff --git a/lith/whistlegraph-socket.mjs b/lith/whistlegraph-socket.mjs new file mode 100644 index 0000000000..53932c8359 --- /dev/null +++ b/lith/whistlegraph-socket.mjs @@ -0,0 +1,88 @@ +import {WebSocketServer,WebSocket} from 'ws'; +import {randomUUID} from 'node:crypto'; +import {publicThread,sourceHash} from '../system/backend/whistlegraph.mjs'; + +export function attachWhistlegraphSocket(server,{authenticate,store,authMs=5000,lifetimeMs=600000}={}) { + const wss=new WebSocketServer({noServer:true,maxPayload:8_100_000,perMessageDeflate:false}); + const rooms=new Map(); + const send=(ws,value)=>{if(ws?.readyState===WebSocket.OPEN){if(ws.bufferedAmount>8_100_000)ws.close(1008,'Slow reader');else ws.send(JSON.stringify(value));}}; + const upgrade=(req,socket,head)=>{ + // Older installed clients retain their authenticated connection route. + if(!['/api/whistlegraph-stream','/api/walkieware-stream'].includes(req.url))return; + if(wss.clients.size>=64){socket.destroy();return;} + wss.handleUpgrade(req,socket,head,ws=>wss.emit('connection',ws)); + }; + server.on('upgrade',upgrade); + wss.on('connection',ws=>{ + let owner,row,room,role,closed=false,chain=Promise.resolve(),pending=0,alive=true,messages=0,windowAt=Date.now(); + const authTimer=setTimeout(()=>ws.close(1008,'Authenticate first'),authMs); + const lifeTimer=setTimeout(()=>ws.close(1000,'Renew session'),lifetimeMs); + const heartbeat=setInterval(()=>{if(!alive)return ws.terminate();alive=false;ws.ping();},20000); + for(const timer of [authTimer,lifeTimer,heartbeat])timer.unref?.(); + ws.on('pong',()=>alive=true); + const broadcast=value=>{for(const client of room?.clients||[])send(client,value);}; + async function message(m) { + if(closed)return; + if(!owner) { + if(m.type!=='authenticate'||typeof m.token!=='string'||m.token.length>7000||!['device','agent'].includes(m.role))throw Error('Authenticate first'); + const user=await authenticate({authorization:`Bearer ${m.token}`}); + if(!user||closed)throw Error('Authentication failed'); + const db=await store();role=m.role; + row=role==='device'?await db.open(user,m.id):await db.read(user,m.code); + if(!row||closed)throw Error('Thread unavailable'); + owner=user;clearTimeout(authTimer); + room=rooms.get(row._id); + if(!room){room={clients:new Set(),device:null,state:null,commands:new Map()};rooms.set(row._id,room);} + if(role==='device'&&room.device)throw Error('This thread is running on another device'); + room.clients.add(ws);if(role==='device')room.device=ws; + send(ws,{type:'ready',thread:publicThread(row),online:!!room.device,state:room.state}); + if(role==='device')broadcast({type:'presence',online:true}); + return; + } + if(m.type==='sync'&&role==='device') { + const saved=await (await store()).save(owner,row._id,m.revision,m.ledger); + if(!saved){send(ws,{type:'conflict',thread:publicThread(await (await store()).read(owner,row.code))});return;} + row=saved;broadcast({type:'saved',thread:publicThread(row)});return; + } + if(m.type==='ping'){send(ws,{type:'pong',online:!!room.device,role,peers:room.clients.size,attached:rooms.get(row._id)===room});return;} + if(m.type==='state'&&role==='device') { + const state=m.state; + if(!state||JSON.stringify(state).length>525000)throw Error('Invalid state'); + room.state={busy:!!state.busy,phase:String(state.phase||'').slice(0,200),head:state.head,sourceHash:typeof state.source==='string'?sourceHash(state.source):null,source:typeof state.source==='string'?state.source.slice(0,500000):'',errors:Array.isArray(state.errors)?state.errors.slice(-20).map(e=>String(e).slice(0,1000)):[],attempt:state.attempt&&typeof state.attempt==='object'?{request:String(state.attempt.request||'').slice(0,20000),status:String(state.attempt.status||'').slice(0,40),error:String(state.attempt.error||'').slice(0,1000),parent:state.attempt.parent,startedAt:state.attempt.startedAt,finishedAt:state.attempt.finishedAt}:null,updatedAt:new Date().toISOString()}; + broadcast({type:'state',state:room.state}); + await (await store()).state?.(owner,row._id,room.state);return; + } + if(m.type==='command'&&role==='agent') { + const id=typeof m.id==='string'&&/^[a-zA-Z0-9-]{1,80}$/.test(m.id)?m.id:randomUUID(); + if(!room.device){send(ws,{type:'result',id,ok:false,error:'Device offline; saved versions remain available'});return;} + if(room.commands.size||room.state?.busy){send(ws,{type:'result',id,ok:false,error:'Device busy'});return;} + if(m.action==='layout'&&(typeof m.css!=='string'||Buffer.byteLength(m.css)>100000))throw Error('Invalid layout'); + if(!['ask','undo','edit','layout'].includes(m.action)||!Number.isSafeInteger(m.baseVersion)||typeof m.baseHash!=='string'||(m.action==='ask'&&(typeof m.text!=='string'||!m.text.trim()||m.text.length>20000))||(m.action==='edit'&&(typeof m.source!=='string'||Buffer.byteLength(m.source)>500000||!m.source.trim())))throw Error('Invalid command'); + const timer=setTimeout(()=>{room.commands.delete(id);send(ws,{type:'result',id,ok:false,error:'Timed out; inspect history before retrying'});},180000);timer.unref?.(); + room.commands.set(id,{ws,timer}); + send(room.device,{type:'command',id,action:m.action,text:m.text,source:m.source,css:m.css,baseVersion:m.baseVersion,baseHash:m.baseHash}); + send(ws,{type:'accepted',id});return; + } + if(m.type==='result'&&role==='device') { + const command=room.commands.get(m.id);if(!command)return; + clearTimeout(command.timer);room.commands.delete(m.id); + send(command.ws,{type:'result',id:m.id,ok:m.ok===true,head:m.head,error:String(m.error||'').slice(0,1000)});return; + } + throw Error('Unsupported message'); + } + ws.on('message',(data,binary)=>{ + if(Date.now()-windowAt>=1000){messages=0;windowAt=Date.now();} + if(++messages>30){ws.close(1008,'Message rate exceeded');return;} + if(binary||++pending>16){ws.close(1008,'Message limit');return;} + chain=chain.then(async()=>{try{await message(JSON.parse(data.toString()));}catch(error){send(ws,{type:'error',error:error.message});if(!room?.clients.has(ws))ws.close(1008,'Session unavailable');}finally{pending--;}}); + }); + ws.on('error',()=>{}); + ws.on('close',()=>{ + closed=true;clearTimeout(authTimer);clearTimeout(lifeTimer);clearInterval(heartbeat); + room?.clients.delete(ws); + if(room?.device===ws){room.device=null;room.state=null;broadcast({type:'presence',online:false});for(const [id,c] of room.commands){clearTimeout(c.timer);send(c.ws,{type:'result',id,ok:false,error:'Device disconnected; inspect history before retrying'});}room.commands.clear();} + if(room&&!room.clients.size)rooms.delete(row._id); + }); + }); + return {wss,close(){server.off('upgrade',upgrade);for(const ws of wss.clients)ws.terminate();wss.close();}}; +} diff --git a/lith/walkieware-socket.test.mjs b/lith/whistlegraph-socket.test.mjs similarity index 74% rename from lith/walkieware-socket.test.mjs rename to lith/whistlegraph-socket.test.mjs index efe1892620..d5950b4b6c 100644 --- a/lith/walkieware-socket.test.mjs +++ b/lith/whistlegraph-socket.test.mjs @@ -3,12 +3,24 @@ import assert from 'node:assert/strict'; import {createServer} from 'node:http'; import {once} from 'node:events'; import {WebSocket} from 'ws'; -import {attachWalkiewareSocket} from './walkieware-socket.mjs'; +import {attachWhistlegraphSocket} from './whistlegraph-socket.mjs'; import {attachMusicalSocket} from './musical-socket.mjs'; -import {mongoWalkiewareStore,validateLedger,sourceHash,validCode,pronounceableCode} from '../system/backend/walkieware.mjs'; -import {WalkiewareThread,threadIdentity,verifyThreadRevision} from '../aesel/src/walkieware-thread.mjs'; +import {mongoWhistlegraphStore,validateLedger,sourceHash,validCode,pronounceableCode} from '../system/backend/whistlegraph.mjs'; +import {WhistlegraphThread,threadIdentity,verifyThreadRevision} from '../aesel/src/whistlegraph-thread.mjs'; const id='11111111-1111-4111-8111-111111111111'; const ledger={format:1,head:0,versions:[{id:0,parent:null,source:'export function paint({wipe}){wipe(0);}',request:null,createdAt:'today',layers:0}]}; +test('a phone updated before the renamed service falls back to its legacy route without changing identity',{timeout:2000},async()=>{ + const urls=[],values=new Map(),storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}; + let resolveReady;const ready=new Promise(r=>resolveReady=r); + class RollingSocket { + constructor(url){urls.push(url);this.readyState=url.endsWith('/whistlegraph-stream')?3:1;queueMicrotask(()=>this.readyState===3?this.onclose?.():this.onopen?.());} + send(raw){if(JSON.parse(raw).type==='authenticate')queueMicrotask(()=>this.onmessage?.({data:JSON.stringify({type:'ready',thread:{code:'wwRuboh',revision:0,ledger}})}));} + close(){this.readyState=3;} + } + const phone=new WhistlegraphThread({storage,key:'rolling',token:()=> 'owner',ledger:()=>ledger,state:()=>({}),onStatus:(_,status)=>{if(status==='Connected')resolveReady();},onCommand:async()=>({ok:true}),WebSocketImpl:RollingSocket,reconnectMs:1}); + const identity=phone.identity.id; + try{await phone.resume();await ready;assert.deepEqual(urls,['wss://aesthetic.computer/api/whistlegraph-stream','wss://aesthetic.computer/api/walkieware-stream']);assert.equal(phone.identity.id,identity);assert.equal(phone.identity.code,'wwRuboh');}finally{phone.suspend();} +}); test('remote edits reject stale versions, mismatched source and a local ask starting during hashing',async()=>{ const source=ledger.versions[0].source,state={busy:false,head:0,source}; const command={baseVersion:0,baseHash:sourceHash(source)}; @@ -24,11 +36,11 @@ function memoryCollection(){ } function inbox(ws){const queue=[],waiters=[];ws.on('message',raw=>{const m=JSON.parse(raw);const i=waiters.findIndex(w=>w.type===m.type);if(i<0)queue.push(m);else waiters.splice(i,1)[0].resolve(m);});return type=>{const i=queue.findIndex(m=>m.type===type);return i>=0?Promise.resolve(queue.splice(i,1)[0]):new Promise(resolve=>waiters.push({type,resolve}));};} async function client(url,auth){const ws=new WebSocket(url),next=inbox(ws);await once(ws,'open');ws.send(JSON.stringify({type:'authenticate',token:'owner',...auth}));return {ws,next,send:m=>ws.send(JSON.stringify(m))};} -async function fixture(t){const server=createServer();const store=mongoWalkiewareStore(memoryCollection(),{name:()=> 'wgRuboh'});const auth=async h=>h.authorization==='Bearer owner'?'owner':h.authorization==='Bearer stranger'?'stranger':null; +async function fixture(t){const server=createServer();const store=mongoWhistlegraphStore(memoryCollection(),{name:()=> 'wgRuboh'});const auth=async h=>h.authorization==='Bearer owner'?'owner':h.authorization==='Bearer stranger'?'stranger':null; const musical=attachMusicalSocket(server,{authenticate:auth,decide:async()=>{}}); - const binding=attachWalkiewareSocket(server,{authenticate:auth,store:async()=>store});server.listen(0,'127.0.0.1');await once(server,'listening');t.after(()=>{binding.close();musical.close();server.close();});return {url:`ws://127.0.0.1:${server.address().port}/api/walkieware-stream`,store};} + const binding=attachWhistlegraphSocket(server,{authenticate:auth,store:async()=>store});server.listen(0,'127.0.0.1');await once(server,'listening');t.after(()=>{binding.close();musical.close();server.close();});return {url:`ws://127.0.0.1:${server.address().port}/api/whistlegraph-stream`,store};} test('names are reserved, versions immutable, undo retains history, stale writes rejected',async()=>{ - let n=0;const store=mongoWalkiewareStore(memoryCollection(),{name:()=>++n<3?'wgRuboh':'wgLemop'}); + let n=0;const store=mongoWhistlegraphStore(memoryCollection(),{name:()=>++n<3?'wgRuboh':'wgLemop'}); assert.equal((await store.open('owner',id)).code,'wgRuboh'); assert.equal((await store.open('owner','22222222-2222-4222-8222-222222222222')).code,'wgLemop'); await assert.rejects(store.open('other',id));assert.equal(await store.read('other','wgRuboh'),null); @@ -63,7 +75,7 @@ test('phone client persists identity, coalesces ledger sync without echo loop an const f=await fixture(t),values=new Map(),storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}; const identity=threadIdentity(storage,'piece',()=>id);assert.equal(threadIdentity(storage,'piece',()=> 'wrong').id,identity.id); let connectedResolve;const connected=new Promise(r=>connectedResolve=r); - const phone=new WalkiewareThread({storage,key:'piece',token:()=> 'owner',ledger:()=>ledger,state:()=>({busy:false,head:0,source:ledger.versions[0].source}),onStatus:(code,status)=>{if(status==='Connected')connectedResolve(code);},onCommand:async()=>({ok:true}),WebSocketImpl:WebSocket,url:f.url});t.after(()=>phone.suspend()); + const phone=new WhistlegraphThread({storage,key:'piece',token:()=> 'owner',ledger:()=>ledger,state:()=>({busy:false,head:0,source:ledger.versions[0].source}),onStatus:(code,status)=>{if(status==='Connected')connectedResolve(code);},onCommand:async()=>({ok:true}),WebSocketImpl:WebSocket,url:f.url});t.after(()=>phone.suspend()); await phone.resume();assert.equal(await connected,'wgRuboh'); for(let i=0;i<50&&phone.sending;i++)await new Promise(r=>setTimeout(r,10)); await new Promise(r=>setTimeout(r,50));assert.equal((await f.store.read('owner','wgRuboh')).revision,1); @@ -78,7 +90,7 @@ test('silent socket stalls reconnect even when close never emits; concurrent res send(text){const m=JSON.parse(text);if(m.type==='authenticate')queueMicrotask(()=>this.onmessage?.({data:JSON.stringify({type:'ready',thread:{code:'wgRuboh',revision:1,ledger}})}));} close(){this.readyState=3;} } - const phone=new WalkiewareThread({storage,key:'stall',token:()=> 'owner',ledger:()=>ledger,state:()=>({}),onStatus:(_,status)=>{if(status==='Offline')offline++;},onCommand:async()=>({ok:true}),WebSocketImpl:SilentSocket,heartbeatMs:5,maxIdleMs:15,reconnectMs:1}); + const phone=new WhistlegraphThread({storage,key:'stall',token:()=> 'owner',ledger:()=>ledger,state:()=>({}),onStatus:(_,status)=>{if(status==='Offline')offline++;},onCommand:async()=>({ok:true}),WebSocketImpl:SilentSocket,heartbeatMs:5,maxIdleMs:15,reconnectMs:1}); try{await Promise.all([phone.resume(),phone.resume()]);assert.equal(count,1);await new Promise(r=>setTimeout(r,55));assert.ok(count>=2);assert.ok(offline>=1);}finally{phone.suspend();} }); @@ -87,9 +99,9 @@ test('new codes use wg while existing ww threads keep their identity and ownersh for(const code of ['wgPaluf','wwPaluf','WGPALUF','wwpaluf'])assert.equal(validCode(code),true); for(const code of ['wg','wgabc','xxPaluf','wgPaluf/','wgPalu1',null])assert.equal(validCode(code),false); const collection=memoryCollection(); - const legacy=mongoWalkiewareStore(collection,{name:()=> 'wwPaluf'}); + const legacy=mongoWhistlegraphStore(collection,{name:()=> 'wwPaluf'}); await legacy.open('owner',id); - const current=mongoWalkiewareStore(collection); + const current=mongoWhistlegraphStore(collection); assert.equal((await current.open('owner',id)).code,'wwPaluf'); assert.equal((await current.read('owner','WWPALUF'))._id,id); assert.equal(await current.read('stranger','wwPaluf'),null); diff --git a/papers/arxiv-whistlegraph-roblox/README.md b/papers/arxiv-whistlegraph-roblox/README.md new file mode 100644 index 0000000000..5e427ae8fb --- /dev/null +++ b/papers/arxiv-whistlegraph-roblox/README.md @@ -0,0 +1,13 @@ +# Whistlegraph in Roblox + +Design study, 5 October 2026. The PDF records the baseline before implementation. Subsequent work renamed the app to [`apple/whistlegraph`](../../apple/whistlegraph) and added a Ware picker and private [`Roblox Room prototype`](../../roblox/rooms/README.md). Local validation is recorded with that implementation; Roblox play and mobile handoff remain unverified. The paper's source hashes and historical internal name describe its original inspection. + +Build `whistlegraph-roblox.tex` through Paper MCP (`paper_build`, absolute source path), then run `paper_figure_table_qa_check`, inspect every page and diagram crop, and record the current PDF hash in `aesthetic-eye.json` before running `paper_aesthetic_eye_check`. + +Build inside the Aesthetic Computer repository: the shared paper layout, source attachment style, and title fonts are repository dependencies. No bibliography processor is required; `references.tex` is the typeset bibliography and `references.bib` contains the corresponding machine-readable entries. + +`api-audit.json` records endpoint-level boundaries. `evidence.json` records source hashes, public-read evidence and the scope of local tests. `example-room.json` illustrates the proposed data format; no runtime consumes it. `notes.md` records the research consultation and limitations. + +The PDF embeds one source ZIP. The bundle contains text sources and the explicit audit records; it excludes private archives, credentials, participant data, downloaded documentation, fonts, and generated PDFs. Shared layout dependencies are resolved from the repository rather than duplicated into this directory. + +No game, asset, account configuration, paper-site entry, or public release is changed by this study. diff --git a/papers/arxiv-whistlegraph-roblox/aesthetic-eye.json b/papers/arxiv-whistlegraph-roblox/aesthetic-eye.json new file mode 100644 index 0000000000..0479fc5280 --- /dev/null +++ b/papers/arxiv-whistlegraph-roblox/aesthetic-eye.json @@ -0,0 +1,92 @@ +{ + "schema": 1, + "paper": "Whistlegraph in Roblox: Making Games While Playing", + "pdf": "whistlegraph-roblox.pdf", + "expectedFigures": 0, + "expectedDiagrams": 2, + "visualInference": true, + "reviewer": { + "kind": "visual-inference", + "agent": "Codex" + }, + "brand": { + "canonicalName": "Aesthetic.Computer", + "dotColor": "#B44887", + "design": "pass", + "checks": { + "period": "pass", + "dotColor": "pass" + } + }, + "figures": [], + "diagrams": [ + { + "id": "integration-routes", + "page": 2, + "crop": [ + 0.08, + 0.055, + 0.41, + 0.265 + ], + "design": "pass", + "checks": { + "tangents": "pass", + "type": "pass", + "balance": "pass", + "spaceUse": "pass", + "hierarchy": "pass", + "edgeRouting": "pass" + }, + "notes": "Inspected final page 2, prepared crop and contact sheet. Two peer routes have matching widths, aligned baselines and a visible gutter. Fork and join arrows are separated from labels; labels and caption read at normal paper scale." + }, + { + "id": "revision-protocol", + "page": 4, + "crop": [ + 0.08, + 0.055, + 0.41, + 0.27 + ], + "design": "pass", + "checks": { + "tangents": "pass", + "type": "pass", + "balance": "pass", + "spaceUse": "pass", + "hierarchy": "pass", + "edgeRouting": "pass" + }, + "notes": "Inspected final page 4, prepared crop and contact sheet. Equal-width nodes, consistent gaps and downward arrows; the final applied state is distinct in text and tint. No collisions or clipped labels." + } + ], + "pdfSha256": "c7e12df39281d2f5f4f2d9d8fbd0ebe2cd0244f104c0ff392c9ed5e28e561a62", + "reviewedAt": "2026-10-06T00:06:45.763345+00:00", + "pageReview": { + "pages": [ + 1, + 2, + 3, + 4, + 5, + 6 + ], + "design": "pass", + "notes": "Inspected all six page rasters, final all-page contact sheet, both diagram crops and the final API table page. After the table-width correction, pages 1, 2, 4, 5 and 6 have byte-identical rasters to the fully inspected previous build. Page 3 was inspected again. Text and references remain legible at normal reading scale, no overflowing columns or sparse interior pages. Final source-attachment block stays within the right column." + }, + "tables": [ + { + "id": "api-boundaries", + "page": 3, + "design": "pass", + "notes": "Full-width table; header and four columns align, Dashboard fits, all rows visible and readable." + }, + { + "id": "evidence", + "page": 5, + "design": "pass", + "notes": "Ragged short evidence labels and wrapped conclusions remain within the column; historical records are visibly distinguished from current tests." + } + ] +} diff --git a/papers/arxiv-whistlegraph-roblox/api-audit.json b/papers/arxiv-whistlegraph-roblox/api-audit.json new file mode 100644 index 0000000000..846b3b063f --- /dev/null +++ b/papers/arxiv-whistlegraph-roblox/api-audit.json @@ -0,0 +1,113 @@ +{ + "asOf": "2026-10-05", + "scope": "Official API documentation plus repository inspection. No authenticated requests or Studio tool execution.", + "interfaces": [ + { + "id": "studio-mcp", + "status": "documented", + "auth": "Local desktop stdio; chosen open Studio instance", + "operations": [ + "script_read", + "multi_edit (Edit)", + "execute_luau (Edit/Client/Server)", + "screen_capture", + "start_stop_play" + ], + "url": "https://create.roblox.com/docs/studio/mcp", + "limit": "Not a public-server API. Play-mode mutations do not by themselves establish saved project edits." + }, + { + "id": "place-publish", + "status": "documented; historical local release record", + "method": "POST", + "path": "/universes/v1/{universeId}/places/{placeId}/versions", + "auth": [ + "API key" + ], + "scope": "universe-places:write", + "url": "https://create.roblox.com/docs/cloud/reference/features/places", + "limit": "Existing target IDs. OAuth not listed for this endpoint." + }, + { + "id": "universe-management", + "status": "documented", + "auth": [ + "API key", + "OAuth" + ], + "url": "https://create.roblox.com/docs/cloud/reference/features/universes", + "limit": "No complete new-universe provisioning route established in this audit; absence in this audit is not proof that every other route is impossible." + }, + { + "id": "assets", + "status": "documented; prior image import recorded", + "auth": [ + "API key", + "OAuth" + ], + "url": "https://create.roblox.com/docs/cloud/guides/usage-assets", + "limit": "Type-specific formats, quotas, moderation and permissions; upload is not a Marketplace listing." + }, + { + "id": "messaging", + "status": "documented", + "method": "POST", + "path": "/cloud/v2/universes/{universe_id}:publishMessage", + "auth": [ + "API key", + "OAuth" + ], + "url": "https://create.roblox.com/docs/cloud/guides/usage-messaging", + "limit": "Notify installed subscribers; does not install or replace running code." + }, + { + "id": "server-http", + "status": "documented; prior image-fetch implementation inspected", + "auth": "Server-side HttpService; enabled in experience", + "url": "https://create.roblox.com/docs/cloud-services/http-service", + "limit": "HTTP fetch plus application-specific validation; not arbitrary inbound HTTP hosting." + }, + { + "id": "web-stream", + "status": "documented", + "url": "https://create.roblox.com/docs/reference/engine/classes/HttpService", + "limit": "CreateWebStreamClient is Studio-only in the inspected reference." + }, + { + "id": "instances", + "status": "documented beta", + "auth": [ + "API key" + ], + "url": "https://create.roblox.com/docs/cloud/guides/instance", + "limit": "Script objects; collaborative session; excludes open and packaged scripts; 200 KB request body." + }, + { + "id": "luau-tasks", + "status": "documented", + "auth": [ + "API key" + ], + "url": "https://create.roblox.com/docs/cloud/reference/features/luau-execution", + "limit": "Specific place version; no physics; embedded scripts do not automatically run; task data-model changes cannot persist." + }, + { + "id": "oauth-identity", + "status": "documented", + "url": "https://create.roblox.com/docs/cloud/auth/oauth2-overview", + "limit": "13+ to authorize; developer ID verification to register and publish app; mobile PKCE required. Identity consent is not all API permissions." + }, + { + "id": "share-link", + "status": "documented", + "url": "https://create.roblox.com/docs/production/promotion/share-links", + "limit": "Creation routing; no authority from launchData; Dashboard creation documented, per-creation programmatic link provisioning not proven." + }, + { + "id": "teleport", + "status": "documented", + "url": "https://create.roblox.com/docs/projects/teleport", + "limit": "Access controls, third-party settings, failure handling; test in actual Roblox client." + } + ] +} diff --git a/papers/arxiv-whistlegraph-roblox/evidence.json b/papers/arxiv-whistlegraph-roblox/evidence.json new file mode 100644 index 0000000000..02fc8d0e82 --- /dev/null +++ b/papers/arxiv-whistlegraph-roblox/evidence.json @@ -0,0 +1,198 @@ +{ + "asOf": "2026-10-05", + "recordedAt": "2026-10-06T00:00:10.176444+00:00", + "repositoryBaseRevision": "f02e8f09dc0be8b69769b45e3f8cf3f08a0bf7e4", + "workingTree": "Contains unrelated changes; inspected file hashes specify the observed source.", + "localTests": { + "command": "node --test toolchain/robloxplorer/robloxplorer.test.mjs", + "passed": 4, + "failed": 0, + "scope": "CLI validation, offline plan, redacted errors, mocked publish; no live authenticated publication" + }, + "capabilities": { + "publishingCredentialConfigured": false, + "studioMCPBinaryAtDocumentedPath": false, + "studioMCPExercised": false, + "iphoneBinaryInspected": false, + "endToEndIntegrationTested": false + }, + "sourceFiles": [ + { + "path": "apple/walkieware/Sources/WalkiewareApp.swift", + "sha256": "350d8bf94a1d1d3b801afd1d9d9599fc15fcf4e66be38698143579b0f38a635f" + }, + { + "path": "apple/walkieware/Sources/WalkiewareScreen.swift", + "sha256": "165e77c333752777b714c800d811f8e9b36a3f05864cc84f23413054e7087d7d" + }, + { + "path": "apple/walkieware/Resources/Web/engine.mjs", + "sha256": "be385f9e49cda59cbf1d63e317e24e0113fb05253fb3785c1438a0f2b8060e54" + }, + { + "path": "apple/walkieware/Resources/Web/piece-versions.mjs", + "sha256": "1b468e65ad35af62eda4fc27027edb740a560aaf8645c54ec1532fd689b1a4ce" + }, + { + "path": "system/backend/walkieware.mjs", + "sha256": "ff86f8d8b4a4d5f31736bd76c46a38180ec7136b23afa40d5d618d53e751d203" + }, + { + "path": "system/netlify/functions/whistlegraph-mint.mjs", + "sha256": "e7947ea7ca633808310376f26a9dfb5fc296c198316a3de3c20b56257cbd615e" + }, + { + "path": "system/backend/whistlegraph-pack.mjs", + "sha256": "c33e4393dd123dcc00592df2e2fd77082f3228de090e2886d544843be37f9115" + }, + { + "path": "system/public/whistlegraph.app/index.html", + "sha256": "eb81fa32be11b0dde093d265b5c2179045030772258873fe5b8f4b1773399bcb" + }, + { + "path": "toolchain/robloxplorer/robloxplorer.mjs", + "sha256": "2b4c65351f5ca9ff4b67e25a1c7613c4a22831694e42e2a808d9401d8f77489c" + }, + { + "path": "toolchain/robloxplorer/robloxplorer.test.mjs", + "sha256": "56c5c4a9af0ef1b3b68852e674f6806ab0263341aea2fa28f5575f8b48a1e972" + }, + { + "path": "roblox/arena/evidence.json", + "sha256": "c6167b685d596adbc63759b08dc3d126c497a50747919ae25695ac0dbe3c04a1" + }, + { + "path": "roblox/notepat/evidence.json", + "sha256": "0aa52b98d76249d5e33b1f85f043f4fc281134f7ead6d6ce5aed8379450ddc3e" + }, + { + "path": "roblox/bridge/evidence.json", + "sha256": "11038129795c178b3bf299fd443e6ca092111692b9adde84dd6fafde328bb819" + }, + { + "path": "roblox/arena/src/server/MediaBridge.luau", + "sha256": "f7d333fd17c2bfa034367baf1ab8c75a070e9cf114afa758c9dbf3e215492d0f" + }, + { + "path": "roblox/arena/src/server/Updates.luau", + "sha256": "8c8b5835b6a36186287c9fa333042122c27eb9107756cf10c877095fa4f3bc0c" + } + ], + "documentationSnapshots": [ + { + "id": "http", + "sha256": "009cc53f56b33dcbafae47194a22d0652cf417977047e8debec92150e81a6e33", + "bytes": 18638 + }, + { + "id": "share", + "sha256": "1edbd5db20b75b9017607f7e42d1bdf18cda90e70686a96dc5c9518e543c23b3", + "bytes": 1793 + }, + { + "id": "publishing", + "sha256": "62ccd4a216058cf60c409c8834edfbc3fadef49bdbcdc55670bad51a5062ac21", + "bytes": 4217 + }, + { + "id": "luau", + "sha256": "6312c26979d92354558a3e9efc54ae7b56942d9ba4d771a3bcb9f088c117ecba", + "bytes": 20109 + }, + { + "id": "messaging", + "sha256": "33434c8af6fa62e73161cd8aa10fbeb2f28418c5b844e322054b9d599ca02967", + "bytes": 8142 + }, + { + "id": "oauth", + "sha256": "3d717407f6d3b93438faf2c48c0e8e510450a13823c06055692ce2b81555a828", + "bytes": 6742 + }, + { + "id": "universes", + "sha256": "c9752131f92f240862aaf72aca132cdd5fba747c9037f8eba12819f1c6764880", + "bytes": 86226 + }, + { + "id": "teleport", + "sha256": "805fb749be318afc04b03719f1b66866c9f2526a30658a8ad70659aec0607599", + "bytes": 12370 + }, + { + "id": "studio-mcp", + "sha256": "660968d0f78dcbd0cbca47919b82e5db0115f1c74168746221663abcbbd972f6", + "bytes": 10077 + }, + { + "id": "instances", + "sha256": "ca9771bee957a1c2f7a913c2ca0b1e77c1c19644b359a460ce56e9f06419bdf3", + "bytes": 15212 + }, + { + "id": "places", + "sha256": "1ed4f13985e52f295db8006200828ce9f547300e57e6a1a53098353522b3ed1c", + "bytes": 42132 + }, + { + "id": "ai", + "sha256": "e99a4eb1082f9485fdba834ef3125c4fd3abfaa76df6070efa2aa26827839b1b", + "bytes": 5338 + }, + { + "id": "assistant", + "sha256": "ef35aa8c1640e90f397f372b44735ca2cffe7096ad56ec4839a70bc46bb1d9aa", + "bytes": 15105 + } + ], + "publicProbes": [ + { + "label": "whistlegraph-profile", + "url": "https://users.roblox.com/v1/users/1366409382", + "status": 200, + "sha256": "823397b31b5278419b8370d59f71cb3af7a00e43d54f840d967d384ae7c5869f", + "observation": { + "userId": 1366409382, + "username": "whistlegraph" + } + }, + { + "label": "public-games", + "url": "https://games.roblox.com/v2/users/1366409382/games?accessFilter=Public&limit=50&sortOrder=Asc", + "status": 200, + "sha256": "ff7360ba2bc3f5adfb77d252a1cd082c27ce4ca248c3fd02c5254e5bd967c5cd", + "observation": { + "games": [ + { + "name": "Sky Pool", + "universeId": 1650467744 + } + ], + "privateInventoryNotInspected": true + } + }, + { + "label": "media-manifest", + "url": "https://assets.aesthetic.computer/roblox/manifest.json", + "errorType": "HTTPError", + "status": 403 + } + ], + "historicalRecords": { + "arena": { + "version": 18, + "headlessTaskRecordedState": "PROCESSING", + "lastCompletedPassReportedVersion": 16, + "lastCompletedPassReportedChecks": 68 + }, + "media": { + "imageAssetId": "85149608937004", + "recordedModerationState": "Approved", + "recordedClientLoaded": true + }, + "notepat": { + "published": false, + "localLogicChecksRecorded": 19 + } + } +} diff --git a/papers/arxiv-whistlegraph-roblox/example-room.json b/papers/arxiv-whistlegraph-roblox/example-room.json new file mode 100644 index 0000000000..b6bdb49cab --- /dev/null +++ b/papers/arxiv-whistlegraph-roblox/example-room.json @@ -0,0 +1,13 @@ +{ + "status": "illustrative-design-only", + "schemaVersion": 1, + "creationId": "butterfly-room", + "revision": 8, + "parentRevision": 7, + "requiredRuntimeVersion": 1, + "objects": [ + {"id": "bridge", "kind": "platform", "position": [0, 5, 0], "size": [20, 1, 5], "color": [180, 72, 135], "behavior": {"kind": "bounce", "strength": 30}}, + {"id": "goal", "kind": "goal", "position": [15, 5, 0], "size": [3, 1, 3]} + ], + "spawn": [0, 8, -5] +} diff --git a/papers/arxiv-whistlegraph-roblox/notes.md b/papers/arxiv-whistlegraph-roblox/notes.md new file mode 100644 index 0000000000..afa06bce64 --- /dev/null +++ b/papers/arxiv-whistlegraph-roblox/notes.md @@ -0,0 +1,21 @@ +# Consultation and scope + +Date: 5 October 2026. This paper follows the user's proposal to connect Whistlegraph's maker app to a Roblox game that exhibits and plays users' creations, including editing while playing. It is a design study with an API audit, not a report of a shipped integration. + +Consulted the repository's SCORE.md and ENVIRONMENT.md; papers/SCORE.md; papers/AESTHETIC-EYE.md; papers/sleuth/README.md and sources.json; the public index at https://papers.aesthetic.computer/; papers/whistlegraph-platter/README.md and the top-level structure of its manifest; the Whistlegraph paper and bibliography; the Robloxplorer paper, bibliography, notes and evidence; and the Pieces Not Programs source. Private archive files were not retrieved. The public index was consulted through its rendered HTML text; no browser screenshot of it is claimed. + +Inspected the public whistlegraph.app landing page, the internally named Walkieware Swift shell and web engine, the piece-version ledger, the shared thread backend, and the Whistlegraph mint/pack path. The app directory and plist still use Walkieware. The paper identifies that internal name rather than claiming an installed Whistlegraph binary was tested. The iPhone app was not launched in this study. + +Inspected Robloxplorer, the Arena release evidence, Notepat evidence, the asset bridge and its server reader, and the release-message subscriber. Evidence hashes in evidence.json identify the consulted contents independently of unrelated working-tree edits. The new paper does not change those components. + +Official Creator Hub pages and their advertised Markdown equivalents supplied the API audit. The Studio MCP guide carries last_updated 2026-10-02T21:48:56Z. Endpoint-specific authentication was inspected: the Places upload operation lists API-key authorization, despite generic reference-page prose mentioning OAuth. The Engine Instances guide is a beta collaborative-editing interface, not a generic public-server control channel. The HttpService reference explicitly limits CreateWebStreamClient to Studio. The Luau task model says physics does not run, embedded scripts do not automatically start, and data-model changes cannot persist; the prior Robloxplorer paper's older save-path discussion is therefore not reused as a current capability claim. + +The sources.json working download log was retained temporarily outside the paper. This deliverable contains bibliography URLs and snapshot hashes, not copied documentation. Source wordings are paraphrased; product architecture, revision protocol, evaluation targets, and rollout sequence are proposals. + +Four Robloxplorer tests were rerun and passed. Their authenticated upload is mocked. Public GET requests confirmed the account and public Sky Pool listing. The publishing credential was not configured in this checkout, and the Studio MCP binary was absent at its documented path. No authenticated API calls, game creation, configuration changes, paid uploads, Studio MCP executions, or publication took place. The separate current public-manifest probe and its result are recorded in evidence.json; the September bridge ledger remains historical evidence only. + +The study does not claim all players can authorize a third-party app: current Roblox OAuth documentation requires a 13+ account. It does not propose alternate pairing as a workaround for that restriction. The first user study is scoped to consenting adults until the intended audience and permissions are resolved. + +No participant measurements or revenue estimates are supplied. The three-second median and ten-second 95th-percentile patch-application objectives exclude model generation and asset moderation. They are explicit proposed acceptance targets, not observations. + +The embedded source bundle includes only the listed text artifacts. It excludes the public documentation downloads, the private archive manifest, credentials, raw prompts/recordings, unrelated working-tree changes, fonts, and generated PDFs. The repository's shared LaTeX style and font paths remain build dependencies. diff --git a/papers/arxiv-whistlegraph-roblox/references.bib b/papers/arxiv-whistlegraph-roblox/references.bib new file mode 100644 index 0000000000..ab99516337 --- /dev/null +++ b/papers/arxiv-whistlegraph-roblox/references.bib @@ -0,0 +1,181 @@ +@misc{whistlegraph, + author={{@jeffrey}}, + title={Whistlegraph: Drawing, Singing, and the Graphic Score as Viral Form}, + year={2026}, + url={https://papers.aesthetic.computer/whistlegraph-26-arxiv.pdf}, + note={Working paper; local source and bibliography consulted.} +} + +@misc{pieces, + author={{@jeffrey}}, + title={Pieces Not Programs: The Piece as a Versioned, Performable Unit of Creative Computing}, + year={2026}, + note={Working paper. Local source: papers/arxiv-pieces/pieces.tex.} +} + +@misc{robloxplorer, + author={{@jeffrey}}, + title={Robloxplorer: An API Path for the Aesthetic Network}, + year={2026}, + note={Working paper, 13 September. Local source: papers/arxiv-robloxplorer/robloxplorer.tex.} +} + +@misc{assistant, + author={{Roblox}}, + title={Assistant for Studio}, + year={2026}, + url={https://create.roblox.com/docs/assistant/guide}, + note={Inspected 5 October.} +} + +@misc{app, + author={{Whistlegraph}}, + title={Whistlegraph for iPhone}, + year={2026}, + url={https://whistlegraph.app/}, + note={Public early-access page; inspected 5 October.} +} + +@misc{mcp, + author={{Roblox}}, + title={Connect to the Roblox Studio MCP Server}, + year={2026}, + url={https://create.roblox.com/docs/studio/mcp}, + note={Inspected 5 October. Markdown metadata last updated 2 October.} +} + +@misc{publishing, + author={{Roblox}}, + title={Usage Guide for Place Publishing}, + year={2026}, + url={https://create.roblox.com/docs/cloud/guides/usage-place-publishing}, + note={Inspected 5 October.} +} + +@misc{places, + author={{Roblox}}, + title={Places: Cloud API Reference}, + year={2026}, + url={https://create.roblox.com/docs/cloud/reference/features/places}, + note={Inspected 5 October; endpoint-level authentication checked.} +} + +@misc{assets, + author={{Roblox}}, + title={Usage Guide for Assets}, + year={2026}, + url={https://create.roblox.com/docs/cloud/guides/usage-assets}, + note={Inspected 5 October.} +} + +@misc{messaging, + author={{Roblox}}, + title={Messaging Usage Guide}, + year={2026}, + url={https://create.roblox.com/docs/cloud/guides/usage-messaging}, + note={Inspected 5 October.} +} + +@misc{oauth, + author={{Roblox}}, + title={OAuth 2.0 Overview}, + year={2026}, + url={https://create.roblox.com/docs/cloud/auth/oauth2-overview}, + note={Inspected 5 October.} +} + +@misc{pkce, + author={{Roblox}}, + title={OAuth 2.0 App Implementation}, + year={2026}, + url={https://create.roblox.com/docs/cloud/auth/oauth2-develop}, + note={Inspected 5 October.} +} + +@misc{universes, + author={{Roblox}}, + title={Universes: Cloud API Reference}, + year={2026}, + url={https://create.roblox.com/docs/cloud/reference/features/universes}, + note={Inspected 5 October.} +} + +@misc{instances, + author={{Roblox}}, + title={Engine Instances}, + year={2026}, + url={https://create.roblox.com/docs/cloud/guides/instance}, + note={Inspected 5 October; beta restrictions.} +} + +@misc{luau, + author={{Roblox}}, + title={Luau Execution}, + year={2026}, + url={https://create.roblox.com/docs/cloud/reference/features/luau-execution}, + note={Inspected 5 October, including the Markdown task-model description.} +} + +@misc{httpclass, + author={{Roblox}}, + title={HttpService: Engine Reference}, + year={2026}, + url={https://create.roblox.com/docs/reference/engine/classes/HttpService}, + note={Inspected 5 October; Studio-only stream client.} +} + +@misc{http, + author={{Roblox}}, + title={In-game HTTP Requests}, + year={2026}, + url={https://create.roblox.com/docs/cloud-services/http-service}, + note={Inspected 5 October.} +} + +@misc{share, + author={{Roblox}}, + title={Share Links}, + year={2026}, + url={https://create.roblox.com/docs/production/promotion/share-links}, + note={Inspected 5 October.} +} + +@misc{teleport, + author={{Roblox}}, + title={Teleport Between Places}, + year={2026}, + url={https://create.roblox.com/docs/projects/teleport}, + note={Inspected 5 October.} +} + +@misc{eligibility, + author={{Roblox}}, + title={Create and Publish Games and Places}, + year={2026}, + url={https://create.roblox.com/docs/production/publishing/publish-games-and-places}, + note={Inspected 5 October; audience-dependent requirements.} +} + +@misc{safety, + author={{Roblox}}, + title={How You Can Help Us Make Roblox Safer}, + year={2026}, + url={https://create.roblox.com/docs/safety}, + note={Inspected 5 October.} +} + +@misc{ai, + author={{Roblox}}, + title={Games with Generative AI}, + year={2026}, + url={https://create.roblox.com/docs/generative-AI}, + note={Inspected 5 October.} +} + +@misc{rewards, + author={{Roblox}}, + title={Creator Rewards}, + year={2026}, + url={https://create.roblox.com/docs/creator-rewards}, + note={Inspected 5 October; no earnings forecast made.} +} diff --git a/papers/arxiv-whistlegraph-roblox/references.tex b/papers/arxiv-whistlegraph-roblox/references.tex new file mode 100644 index 0000000000..1019629558 --- /dev/null +++ b/papers/arxiv-whistlegraph-roblox/references.tex @@ -0,0 +1,27 @@ +\begin{thebibliography}{99} +\small +\setlength{\itemsep}{.3em} +\bibitem{whistlegraph} @jeffrey. \href{https://papers.aesthetic.computer/whistlegraph-26-arxiv.pdf}{Whistlegraph: Drawing, Singing, and the Graphic Score as Viral Form}. 2026. Working paper; local source and bibliography consulted. +\bibitem{pieces} @jeffrey. Pieces Not Programs: The Piece as a Versioned, Performable Unit of Creative Computing. 2026. Working paper. Local source: papers/arxiv-pieces/pieces.tex. +\bibitem{robloxplorer} @jeffrey. Robloxplorer: An API Path for the Aesthetic Network. 2026. Working paper, 13 September. Local source: papers/arxiv-robloxplorer/robloxplorer.tex. +\bibitem{assistant} Roblox. \href{https://create.roblox.com/docs/assistant/guide}{Assistant for Studio}. 2026. Inspected 5 October. +\bibitem{app} Whistlegraph. \href{https://whistlegraph.app/}{Whistlegraph for iPhone}. 2026. Public early-access page; inspected 5 October. +\bibitem{mcp} Roblox. \href{https://create.roblox.com/docs/studio/mcp}{Connect to the Roblox Studio MCP Server}. 2026. Inspected 5 October. Markdown metadata last updated 2 October. +\bibitem{publishing} Roblox. \href{https://create.roblox.com/docs/cloud/guides/usage-place-publishing}{Usage Guide for Place Publishing}. 2026. Inspected 5 October. +\bibitem{places} Roblox. \href{https://create.roblox.com/docs/cloud/reference/features/places}{Places: Cloud API Reference}. 2026. Inspected 5 October; endpoint-level authentication checked. +\bibitem{assets} Roblox. \href{https://create.roblox.com/docs/cloud/guides/usage-assets}{Usage Guide for Assets}. 2026. Inspected 5 October. +\bibitem{messaging} Roblox. \href{https://create.roblox.com/docs/cloud/guides/usage-messaging}{Messaging Usage Guide}. 2026. Inspected 5 October. +\bibitem{oauth} Roblox. \href{https://create.roblox.com/docs/cloud/auth/oauth2-overview}{OAuth 2.0 Overview}. 2026. Inspected 5 October. +\bibitem{pkce} Roblox. \href{https://create.roblox.com/docs/cloud/auth/oauth2-develop}{OAuth 2.0 App Implementation}. 2026. Inspected 5 October. +\bibitem{universes} Roblox. \href{https://create.roblox.com/docs/cloud/reference/features/universes}{Universes: Cloud API Reference}. 2026. Inspected 5 October. +\bibitem{instances} Roblox. \href{https://create.roblox.com/docs/cloud/guides/instance}{Engine Instances}. 2026. Inspected 5 October; beta restrictions. +\bibitem{luau} Roblox. \href{https://create.roblox.com/docs/cloud/reference/features/luau-execution}{Luau Execution}. 2026. Inspected 5 October, including the Markdown task-model description. +\bibitem{httpclass} Roblox. \href{https://create.roblox.com/docs/reference/engine/classes/HttpService}{HttpService: Engine Reference}. 2026. Inspected 5 October; Studio-only stream client. +\bibitem{http} Roblox. \href{https://create.roblox.com/docs/cloud-services/http-service}{In-game HTTP Requests}. 2026. Inspected 5 October. +\bibitem{share} Roblox. \href{https://create.roblox.com/docs/production/promotion/share-links}{Share Links}. 2026. Inspected 5 October. +\bibitem{teleport} Roblox. \href{https://create.roblox.com/docs/projects/teleport}{Teleport Between Places}. 2026. Inspected 5 October. +\bibitem{eligibility} Roblox. \href{https://create.roblox.com/docs/production/publishing/publish-games-and-places}{Create and Publish Games and Places}. 2026. Inspected 5 October; audience-dependent requirements. +\bibitem{safety} Roblox. \href{https://create.roblox.com/docs/safety}{How You Can Help Us Make Roblox Safer}. 2026. Inspected 5 October. +\bibitem{ai} Roblox. \href{https://create.roblox.com/docs/generative-AI}{Games with Generative AI}. 2026. Inspected 5 October. +\bibitem{rewards} Roblox. \href{https://create.roblox.com/docs/creator-rewards}{Creator Rewards}. 2026. Inspected 5 October; no earnings forecast made. +\end{thebibliography} diff --git a/papers/arxiv-whistlegraph-roblox/source-bundle.json b/papers/arxiv-whistlegraph-roblox/source-bundle.json new file mode 100644 index 0000000000..81e54a384d --- /dev/null +++ b/papers/arxiv-whistlegraph-roblox/source-bundle.json @@ -0,0 +1,3 @@ +{ + "include": ["references.tex", "notes.md", "api-audit.json", "evidence.json", "example-room.json", "README.md"] +} diff --git a/papers/arxiv-whistlegraph-roblox/whistlegraph-roblox-source-README.txt b/papers/arxiv-whistlegraph-roblox/whistlegraph-roblox-source-README.txt new file mode 100644 index 0000000000..e034c75848 --- /dev/null +++ b/papers/arxiv-whistlegraph-roblox/whistlegraph-roblox-source-README.txt @@ -0,0 +1,15 @@ +whistlegraph-roblox — embedded source bundle + +Included files +- api-audit.json (JSON source) +- evidence.json (JSON source) +- example-room.json (JSON source) +- notes.md (MD source) +- README.md (MD source) +- references.bib (BIB source) +- references.tex (TEX source) +- source-bundle.json (JSON source) +- whistlegraph-roblox.tex (TEX source) + +Purpose +This machine-readable bundle accompanies the rendered PDF for accessibility, audit, quotation, and reproducible editing. The PDF remains the canonical reading edition. The bundle is deliberately limited to text source files; private evidence, credentials, raw datasets, generated PDFs, and unlisted assets are excluded. diff --git a/papers/arxiv-whistlegraph-roblox/whistlegraph-roblox-source.zip b/papers/arxiv-whistlegraph-roblox/whistlegraph-roblox-source.zip new file mode 100644 index 0000000000000000000000000000000000000000..c1be34d2d63c3aa1f943e0b3db7b883f456c41db GIT binary patch literal 22779 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\RaggedRight\arraybackslash}X} +\newcommand{\pc}[1]{{\ttfamily\small\path{#1}}} +\setlength{\emergencystretch}{2em} +\setlength{\parskip}{.15em} +\setlength{\textfloatsep}{12pt} +\setlength{\floatsep}{10pt} +\begin{document} +\twocolumn[{ +\begin{center} +{\acbold\fontsize{27}{31}\selectfont Whistlegraph in Roblox}\par +\vspace{.35em}{\Large Making Games While Playing}\par +\vspace{.6em}\href{https://prompt.ac/@jeffrey}{@jeffrey}\quad\ac\quad 5 October 2026 +\end{center} +\begin{abstract} +Can a Roblox player become a game maker by speaking and drawing into Whistlegraph, then exhibit a playable creation inside a shared Roblox world? This design study evaluates that proposal against the current maker app, existing Roblox prototypes, and Roblox documentation inspected on 5 October 2026. Two integration paths emerge. A desktop companion can connect the phone to Roblox Studio's built-in Model Context Protocol server for editing, runtime inspection, and playtesting. A public experience can accept bounded creation data through a purpose-built Luau runtime, allowing selected changes while players remain in a session. These paths have different authority, persistence, and audience boundaries. We specify a revision protocol, a gallery of playable works, and an evaluation plan. Existing evidence establishes app-side versioning, place publication, and an image bridge; it does not establish the proposed integration, live-edit latency, or user outcomes. The recommended first experiment is one room edited from a phone, played by a second person, and restored from an acknowledged revision. +\end{abstract}\vspace{.6em} +}] + +\section{The question} +A person playing Roblox sees a room they would like to change. They open Whistlegraph on a phone, draw a bridge, and say: ``Make this bridge bounce when I land on it.'' The room changes, the person tries it, and a friend enters through a gallery showing the creator's work. Making, playing, and showing become one activity. + +This is the intended integration. It contains three separate commitments: a comprehensible authoring instrument; a playable translation into Roblox; and a social destination where the result remains attributable and discoverable. An image on a wall satisfies only the exhibition part. A generated script satisfies only part of authoring. A successful upload does not establish that another person can play. + +We use \emph{vibe coding} to mean requesting software changes through ordinary language and judging them through use. The design must preserve a recoverable artifact beneath that conversational surface. Its central question is therefore: what can a user change during play, what can be saved, and who is allowed to publish the result? + +\section{Context and method} +Whistlegraph's prior practice couples a drawing with the performance that produces it~\cite{whistlegraph}. This suggests an authoring interface where marks and sound remain meaningful inputs. The local piece research treats a small, versioned, performable work as a useful creative unit~\cite{pieces}. The proposed Roblox room applies that unit to a shared spatial setting. + +The earlier Robloxplorer study mapped discovery, place publishing, assets, and a possible art network~\cite{robloxplorer}. Its September account of unbuilt game directories is historical: the current repository contains Arena, Notepat, and an image bridge. This paper updates that baseline and asks a narrower question about creation during play. + +Roblox already supplies an AI Assistant in Studio for changing objects and scripts and inspecting results~\cite{assistant}. The contribution proposed here is a phone-based instrument and an exhibition network linked to those capabilities. We make no claim that natural-language Roblox development itself is new. + +The method combines source inspection, official documentation, four rerun local CLI tests, and bounded public reads. The source ledger records the repository revision, hashes of inspected files, documentation access dates, and limits of each observation. We distinguish \emph{observed now}, \emph{historically recorded}, \emph{documented}, and \emph{proposed}. Documentation establishes an available contract, not a successful account operation. + +\section{The existing seam} +The public \url{https://whistlegraph.app/} page describes an invitation-only iPhone maker using speech, typing, and drawing~\cite{app}. The inspected implementation remains internally named Walkieware. Its Swift shell uses \pc{WKWebView}; its web engine edits a JavaScript piece and previews it in the \ac{} runtime. A version ledger, failed-attempt recovery, and thread synchronization already exist. The Whistlegraph mint backend consumes the same thread collection. These links support treating the code as the relevant maker pipeline, but this study did not inspect an installed iPhone release. + +No Roblox exporter was found in the inspected app source. Its JavaScript piece and packaged HTML output are not Roblox place files. A Roblox target needs a new representation or generation backend; changing the destination URL is insufficient. + +The Arena evidence ledger records publication of place version 18 and a client observation of an imported Pals image. Its version-18 headless task remains recorded as processing; the last reported completed pass belongs to version 16. Notepat records 19 local logic checks and no Roblox publication or perceptual playtest. A current unauthenticated request to the bridge manifest returned HTTP 403 from the research host; present accessibility is not established. These are useful components with different verification levels, not an integrated product. + +The image bridge is the most direct precedent. Server Luau fetches a versioned manifest, accepts approved numeric asset IDs, and retains the last valid image after a fetch failure. It polls once per minute and displays one image. It has no creation catalog, user account linking, or live room editor. Its existing revision check allows equal revisions; immutable content and idempotent operations must be added for editing. + +\begin{figure}[t] +\centering +\begin{tikzpicture}[font=\sffamily\small, +box/.style={draw=acdark,rounded corners=2pt,align=center,inner sep=7pt}, +link/.style={-{Stealth[length=2mm]},thick,draw=acdark}] +\node[box,text width=6.8cm] (app) at (0,0) {Whistlegraph app\\Request + drawing + revision history}; +\node[box,text width=6.8cm] (route) at (0,-1.25) {Authenticated project service}; +\node[box,text width=3cm,fill=acpink!8] (studio) at (-1.95,-2.65) {Studio companion\\Scripts + playtest}; +\node[box,text width=3cm,fill=acpurple!8] (live) at (1.95,-2.65) {Public experience\\Bounded room data}; +\node[box,text width=6.8cm] (gallery) at (0,-4.05) {Reviewed creation in a shared gallery}; +\draw[link] (app)--(route); +\draw[thick,draw=acdark] (route.south)--(0,-1.95); +\draw[link] (0,-1.95)-|(studio.north); +\draw[link] (0,-1.95)-|(live.north); +\draw[thick,draw=acdark] (studio.south)|-(0,-3.45); +\draw[thick,draw=acdark] (live.south)|-(0,-3.45); +\draw[link] (0,-3.45)--(gallery.north); +\end{tikzpicture} +\caption{Proposed integration. Studio output reaches the gallery through a release; room data reaches it through catalog review. The two routes share an authoring surface but grant different capabilities.} +\label{fig:routes} +\end{figure} + +\section{Where the APIs are} +Figure~\ref{fig:routes} separates two execution environments. Table~\ref{tab:api} maps the relevant interfaces, their authentication, and their limits as inspected on 5 October 2026. Endpoint-level contracts take precedence over generic authentication boilerplate on reference pages. + +\subsection{Studio is an actionable route} +Roblox documents a built-in Studio MCP server, exposed locally through stdio. Its tools include script reads and edits, data-model inspection, Luau execution in Edit, Client, or Server contexts, viewport capture, play-mode control, and simulated inputs. Calls name a specific Studio instance. Script editing through \pc{multi_edit} targets the Edit data model~\cite{mcp}. + +That provides a documented foundation for a phone companion. An authenticated desktop bridge would connect Whistlegraph to the user's chosen Studio project. The phone could request a change and receive a screenshot, error, or test result. The app would not need to reproduce Studio's engine. This bridge is proposed; the documented MCP executable was absent from the inspected host, and no MCP session was exercised. + +\subsection{Cloud access is not uniform} +The place-version upload endpoint accepts RBXL or RBXLX with an API key and a target universe and place~\cite{publishing,places}. The audited endpoint does not list OAuth. Assets and messaging do document delegated OAuth support~\cite{assets,messaging}. An identity connection therefore cannot be advertised as universal publishing authority. + +Roblox requires a 13+ account to authorize OAuth applications, and an ID-verified developer to register and publish an OAuth app~\cite{oauth}. Mobile clients use authorization code flow with PKCE~\cite{pkce}. These are material limits to the phrase ``any Roblox player.'' A younger player's ability to play or create inside a permitted experience is distinct from permission to connect an external app. + +The current universe reference provides management and messaging endpoints, but this audit did not establish a documented Open Cloud route that provisions a new universe and all required publishing settings in one operation~\cite{universes}. Arena's recorded bootstrap used an authenticated browser endpoint. That historical operation is not a delegated mobile API contract. + +\subsection{Three tempting category errors} +First, the Engine Instances API is a beta interface for reading and updating script objects in collaborative sessions. It requires API-key authorization and excludes scripts open in Studio or inside packages~\cite{instances}. It is not a general editor of any running public server. + +Second, cloud Luau execution runs against a place version. The inspected model description says physics does not run, embedded scripts do not automatically start, and data-model changes cannot persist~\cite{luau}. It is useful for assertions. It is not the session where a player is currently jumping. The older Robloxplorer note about a possible save route should not be carried forward as current evidence. + +Third, \pc{HttpService:CreateWebStreamClient()} supports streaming protocols in Studio, but the current reference restricts it to Studio~\cite{httpclass}. We cannot assume that the app's streaming transport can simply be moved into a published game. The initial public runtime should use ordinary server HTTP requests and messaging notifications. + +\begin{table*}[t] +\centering\small +\caption{API boundary at 5 October 2026. All rows describe documented capabilities; proposed app integrations were not exercised. K = scoped API key; O = delegated OAuth; L = local Studio process; S = game-server code.} +\label{tab:api} +\begin{tabularx}{\textwidth}{@{}p{.21\textwidth}p{.085\textwidth}YY@{}} +\toprule +Interface & Access & Integration use & Boundary \\ +\midrule +Studio MCP~\cite{mcp} & L & Read/edit project; inspect and playtest & Desktop Studio session; runtime and saved edits differ. \\ +Place versions~\cite{places,publishing} & K & Publish a built place artifact & Existing IDs; OAuth not listed for this endpoint. \\ +Universe management~\cite{universes} & K, O & Read/update supported settings; restart servers & Permissions and audience eligibility still apply. \\ +Assets API~\cite{assets} & K, O & Import images, audio, supported models & Type-specific limits, moderation, and usage permissions. \\ +Cloud messaging~\cite{messaging} & K, O & Notify a subscribed server of a revision & Small message; durable content fetched separately. \\ +Server HTTP~\cite{http} & S & Fetch approved room data; report applied revision & Must be enabled; rate limits and failures need handling. \\ +Engine Instances~\cite{instances} & K & Read/update scripts in collaborative editing & Beta restrictions; not public-session hot reload. \\ +Cloud Luau tasks~\cite{luau} & K & Assert structure and logic on a place version & No physics; task changes do not persist. \\ +Share links~\cite{share} & Dashboard & Enter from app; carry a creation reference & Launch data is routing input, not proof of ownership. \\ +TeleportService~\cite{teleport} & S & Move visitors to published places or games & Destination access and cross-owner settings; client test required. \\ +\bottomrule +\end{tabularx} +\end{table*} + +\section{Making while playing} +There are two meanings of ``live'' that the interface must show separately: a change visible in the current simulation, and a saved change that survives the next session. A third state, public availability, requires its own decision. Conflating these states would make an apparently successful demo lose the user's work. + +\subsection{Desktop companion} +The companion path is intended for a creator with access to Studio. The phone submits a request with the project, selected object, and base revision. A desktop service authenticates the request, selects the explicit Studio instance, and invokes the appropriate tools. Its access is limited to the chosen project; a raw desktop MCP endpoint is not exposed to public app users. + +During play, changing an object's size or position in the server data model can provide immediate experimental feedback. The corresponding design change must also be recorded in the Edit model or in a replayable source operation. A live server adjustment alone is not a saved release. Changes that require scripts to reinitialize may require stopping and restarting the playtest. The product should show ``preview applied'' and ``saved'' as different acknowledgments. + +For example, ``make the bridge wider'' could update a selected part in the test session and record the same intended dimensions in the project. ``Replace the jumping system'' should enter a script-edit and retest cycle. A failed change restores the previous design revision; transient gameplay state needs a separate reset policy. No blanket guarantee of uninterrupted arbitrary code replacement follows from the MCP tool list. + +This path is the shortest credible experiment for the studio's own use. It depends on an available desktop and a new app-to-desktop connector. Hosting desktop Studio sessions for every phone user would be a separate infrastructure product; this paper does not assume that service exists or is authorized. + +\subsection{Public creation runtime} +The public path serves players inside a Whistlegraph-owned experience. A stable Luau interpreter constructs rooms from bounded data: authored curves, primitive objects, colors, spawn points, named goals, and selected behavior components. The maker generates that data instead of arbitrary executable source. Supported behaviors might include a moving platform, a musical trigger, a bounce surface, and a timed goal. + +This is expressive only within the grammar we implement. A request outside it must say what is unsupported or become a proposed runtime feature. A browser preview approximates the design; the Roblox client remains the authority for actual movement, collision, and multiplayer behavior. + +A first grammar should preserve the drawn mark. A stroke can define a walkway; its timing can define a sequence of notes; a closed shape can become an area to reach. The generator interprets these inputs, but the user can inspect and revise the resulting objects. A generic asset generator would not by itself preserve this relationship between gesture and play. + +Changes are scoped to the author's editable room. A global property such as \pc{Workspace.Gravity} affects the entire server: independent per-room gravity would require a custom movement implementation or separate servers. The initial grammar should omit that promise. Public visitors play a reviewed revision while the maker edits a draft session, so an experiment cannot unexpectedly alter every visitor's game. + +\subsection{Revision protocol} +The proposed revision tuple is +\[ +R=(c,r,p,v,h), +\] +where $c$ is the creation ID, $r$ the revision, $p$ its parent, $v$ the required runtime version, and $h$ a hash of canonical content. The backend owns authorship and visibility records. A user's uploaded JSON cannot declare itself approved or change its creator. + +An edit request also carries an idempotency key and the expected base revision. The service validates authorization, schema, references, object counts, coordinate bounds, and cost budgets before atomically advancing the draft pointer. A stale base returns a conflict; the system must not silently overwrite another edit. + +An Open Cloud message tells subscribed servers that a creation revision is available~\cite{messaging}. It carries a reference rather than the room. A server retrieves the authenticated snapshot, checks compatibility, and stages it away from active play. It applies the validated change at a safe boundary and reports the revision and outcome. The app labels a change applied only after that acknowledgment. Figure~\ref{fig:revision} shows the proposed sequence. + +\begin{figure}[t] +\centering +\begin{tikzpicture}[font=\sffamily\small,node distance=4mm, +box/.style={draw=acdark,rounded corners=2pt,text width=6.7cm,align=center,inner sep=6pt}, +link/.style={-{Stealth[length=2mm]},thick,draw=acdark}] +\node[box] (request) {Request against revision 7}; +\node[box,below=of request] (check) {Authorize + validate + save revision 8}; +\node[box,below=of check] (hint) {Notify server: revision 8 available}; +\node[box,below=of hint] (fetch) {Fetch snapshot + stage room}; +\node[box,below=of fetch,fill=acpink!8] (apply) {Apply at safe boundary + acknowledge}; +\draw[link] (request)--(check); +\draw[link] (check)--(hint); +\draw[link] (hint)--(fetch); +\draw[link] (fetch)--(apply); +\end{tikzpicture} +\caption{Proposed public-runtime edit protocol. Stored, delivered, applied, and publicly listed are distinct states. Lost notifications are recovered by fetching the durable revision pointer.} +\label{fig:revision} +\end{figure} + +Duplicate notifications must not repeat effects. Missing notifications trigger bounded polling with backoff. Unsupported runtime versions leave the current room intact. A rollback creates a new revision restoring earlier content, preserving history and monotonic revision numbers. A separate takedown flag overrides cached availability; known removal is never treated as an ordinary fetch failure. + +Object construction should be staged before replacing a room, with a known spawn and a recovery position for players affected by changed geometry. This does not make arbitrary physics transitions atomic. The implementation must define when an edit waits for the next round and when it may apply immediately. + +\subsection{What the phone sees} +A second-screen prototype is straightforward to specify: Roblox runs on a computer while Whistlegraph runs on a phone. Studio's documented capture tool can supply viewport evidence through the companion. No equivalent general-purpose public-server video stream was established by this audit. + +On one iPhone, the user would move between two apps. The integration must tolerate suspension, reconnection, and an expired session. The app should show its last acknowledged creation revision and pending edits, without pretending that a local thumbnail is the current Roblox framebuffer. Embedding or continuously streaming the Roblox player inside Whistlegraph is outside the verified API surface. + +\section{A world that shows their work} +The public destination is a gallery of playable creations. Each entry binds a creation ID, reviewed revision, creator credit, preview asset, and playable destination. A personal room can display several works; a shared directory can expose recent, curated, or explicitly remixed works. Ranking should begin with transparent curation rather than an untested popularity algorithm. + +For the first release, entering a creation can load its room inside the same place. The work remains a catalog entry, so a new user does not require a new Roblox universe. When isolation or scale demands it, a shared play place or separate published game can become a destination. Roblox's teleport service supports travel between places and games, but cross-owner settings, destination permissions, failure handling, and real-client tests matter~\cite{teleport}. + +An external share link can carry a creation reference into the experience~\cite{share}. The server resolves that reference against its approved catalog. Launch data must never establish authorship, grant edit access, or carry an account token. A separate identity flow binds an eligible app user to a stable Roblox user ID. Display names alone are not proof. + +Making a room inside Whistlegraph's experience gives a user authorship of a work within that product. It does not give them ownership of the Roblox experience, a separate discovery listing, an inventory item, or a revenue entitlement. These distinctions should be visible in the publication action and creator agreement. + +Exporting an independent game is a later capability. The author would need an authorized target and would still face Roblox's audience-dependent age checks, content questionnaire, and further all-ages publishing requirements~\cite{eligibility}. OAuth login cannot waive them. The proposed first release should say ``Publish to Whistlegraph World,'' with the destination named, rather than imply ownership of a new Roblox experience. + +\section{Implementation agenda} +Reuse the app's version history, but add a target field identifying an \ac{} piece, a Roblox room recipe, or a Studio project. Preserve the original input and its resulting artifact relationship. Reuse existing asset-import records and source hashes; do not pass arbitrary remote media URLs straight through to clients. + +The first public runtime needs a schema validator, a bounded object builder, behavior modules, a server-side edit authority, a catalog reader, and an acknowledgment channel. The service needs immutable creation snapshots, draft and public pointers, moderation state, and per-author resource limits. The app needs target selection, a diff the user can understand, a pending/applied/error state, and undo. + +The first Studio connector needs project pairing, explicit instance selection, a bounded request queue, tool-result handling, and a saved-versus-preview ledger. It should preserve the developer's source repository as a recoverable authority. This connector should be tested independently of the public room interpreter; each has a different failure surface. + +Maintain three version identities: app document revision, Roblox runtime release, and Roblox place version. A release record should bind the runtime source hash, generated artifact hash, selected target, returned place version, and test evidence. A room revision can change without a place upload only when its required behavior already exists in that runtime. New behavior modules require a tested code release. + +\section{Evidence and evaluation} +Table~\ref{tab:evidence} states what this study can currently establish. Public discovery still exposes Sky Pool for the Whistlegraph account. That read does not show the account's private inventory. The local credential check reported no configured publishing key, so this study performed no authenticated cloud test, upload, or configuration change. + +\begin{table}[t] +\centering\small +\caption{Evidence at the time of writing. A historical record is not a newly repeated test.} +\label{tab:evidence} +\begin{tabularx}{\columnwidth}{@{}>{\RaggedRight\arraybackslash}p{.29\columnwidth}Y@{}} +\toprule +Evidence & Supported conclusion \\ +\midrule +App source & Piece generation and revision infrastructure exist; Roblox target absent in inspected files. \\ +CLI checks & Four tests rerun and passed, including a mocked upload; no live publish tested. \\ +Arena ledger & Version 18 upload recorded; its headless task is not recorded complete. \\ +Image ledger & Approved image ID and prior client display recorded; current manifest probe returned 403. \\ +Public API & Whistlegraph profile and public Sky Pool listing resolve. \\ +Studio docs & Capabilities documented; no local MCP execution in this study. \\ +\bottomrule +\end{tabularx} +\end{table} + +The next experiment should answer a concrete question: can a novice change one playable room, see the intended effect, recover a prior version, and invite another player? Start with a trusted adult creator pilot. Do not recruit children or claim a broadly accessible public workflow before age, consent, moderation, and account requirements are resolved. + +The minimum demonstration has six observable events: create a room; apply a supported edit during play; acknowledge the exact revision; reject an invalid edit without damaging the room; restore earlier content; and let a second player enter the reviewed result. A video should show the phone request and the Roblox effect with synchronized timestamps. A log should bind those frames to the same revision, without recording speech or chat by default. + +Measure separate intervals for input processing, model generation, validation, delivery, application, and acknowledgment. Report median and tail latency, failures, and reconnect behavior. An initial engineering target might be a median under three seconds and a 95th percentile under ten seconds \emph{from accepted patch to visible application}, excluding model generation and asset moderation. These are proposed targets, not observed performance or a promise about total request time. + +Test dropped and duplicate notifications, expired authorization, stale parents, incompatible runtime versions, mid-edit disconnects, invalid object references, resource exhaustion, and takedown propagation. Test that one creator cannot edit another room. Human playtests must check touch controls, legible gallery entries, collision changes, and whether users understand when work is saved. Headless success cannot replace these observations. + +Only after the loop works should a study measure how often visitors become makers, whether makers publish a second revision, and whether friends return to creations. Report denominators and observation windows. The study currently has no participant results, retention measurements, revenue observations, or comparison against Studio alone. + +\section{Governance and sustainability} +Generated and imported works remain subject to Roblox's content rules. The operator needs review before public listing, reporting and removal tools, author attribution, and explicit remix permissions~\cite{safety}. The first experiment can use creator-owned shapes and preapproved audio, avoiding an assumption that every recording or drawing can be uploaded instantly. Audio and model use also require the appropriate asset permissions~\cite{assets}. + +Roblox's generative-AI guidance addresses content moderation, disclosure, and maturity classification; extended chatbot-style interactions receive additional restrictions~\cite{ai}. The app's off-platform generation and the in-game runtime must be assessed as the actual product flow. Moving an AI call to an external service is not an established exemption from the experience's obligations. + +Raw voice, drawings, prompts, and account-link records should remain private unless the creator intentionally publishes the relevant artifact. The gallery needs an approved output and attribution, not the conversation that produced it. An edit log can record creation ID, revision, action category, timing, and result without copying private input to analytics. Pairing must not become an alternate path around an OAuth age restriction. + +The ``turnstile'' hypothesis is a repeatable passage from making to showing to playing to revision. Its commercial value remains unmeasured. Costs include generation, asset handling, moderation, hosting, and support. Roblox Creator Rewards have qualification and engagement conditions~\cite{rewards}; they are not a payment for each catalog entry or click. Any creator revenue share would require its own accounting and terms. A gallery alone does not establish a sustainable business. + +\section{Limits and next decision} +This is a design and documentation study. It has not established end-to-end app integration, a working Studio connector, cross-platform preview fidelity, multiplayer edit consistency, or public publishing eligibility. API availability and policy can change after the inspection date. The host lacked the documented Studio MCP executable and an available publishing credential, limiting direct verification. + +The recommended sequence is to prove the desktop companion with one owned project, then prove one bounded public room and its gallery entry. The companion tests the immediate experience of speaking while playing; the room runtime tests whether the idea can serve players without requiring each to operate Studio. Success on either path does not certify the other. + +\section{Conclusion} +Roblox's current interfaces support a credible integration, provided its boundaries remain explicit. Studio MCP supplies a documented route for an AI client to inspect, edit, and playtest a creator's desktop project. A published Whistlegraph experience can support live creation through a runtime designed for versioned data. The phone app supplies the authoring instrument; the Roblox world supplies shared play and exhibition. The next proof is small: one authored room, one acknowledged live edit, one recoverable revision, and one other person playing the result. + +\input{references} +\end{document} diff --git a/roblox/rooms/README.md b/roblox/rooms/README.md new file mode 100644 index 0000000000..f21a7e4b52 --- /dev/null +++ b/roblox/rooms/README.md @@ -0,0 +1,15 @@ +# Whistlegraph Rooms + +Private prototype for Whistlegraph's Roblox Room ware. The default ware remains Aesthetic.Computer Piece. + +The app stores a separate room history, draws paths into platform segments, offers a bounded room tool to its brain, and exports a self-contained `.rbxlx` place. The plan is a drawing of the room, not a physics simulation. Supported objects are platforms and goals; a platform can bounce a character. No generated script is executed. + +`shared/roblox-room.mjs` is the app/service grammar. `src/server/Room.luau` validates the same grammar and constructs a staged Roblox model. `src/server/init.server.luau` fetches the pilot player's current room, replaces it, moves that player to spawn, and acknowledges the applied revision. Five-second polling is for a small private test, not a production fleet. Existing visitors are not managed through geometry changes; multiplayer editing remains unproven. + +To connect a private test, configure the service with `WHISTLEGRAPH_ROBLOX_PREVIEW_OWNER` (the AC account subject), `WHISTLEGRAPH_ROBLOX_PREVIEW_USER_ID`, `WHISTLEGRAPH_ROBLOX_SHARE_LINK` (a Creator Dashboard share link for this experience), and a random 32+ character `WHISTLEGRAPH_ROBLOX_BRIDGE_KEY`. Put the same bridge key in the experience's Secrets Store. It is never included in the phone bundle. The configured identities are an operator-owned pilot, not public account linking or an OAuth age workaround. + +Build with `rojo build roblox/rooms/default.project.json -o /tmp/whistlegraph-rooms.rbxlx`, then publish to an authorized private place. Deploy the room API and renamed Whistlegraph stream route before using the new phone build with cloud services. Existing `/api/walkieware` and `/api/walkieware-stream` clients remain supported. No share link or place ID is guessed. Until the connection is configured, Play reports that setup is missing; local editing and export work. + +Validate on a real phone and Roblox client: draw a path, save and launch, observe the exact applied revision, return to the app, change width or bounce, relaunch, and restore a saved version. Test app suspension, account changes, stale saves, failed fetches and reconnects. The share-link route may open a web landing page depending on the OS and app state. A direct Roblox-to-Whistlegraph callback is not implemented. + +Local checks: run `node apple/whistlegraph/bundle.mjs`, then `node --test roblox/rooms/room.test.mjs apple/whistlegraph/Tests/wares.test.mjs apple/whistlegraph/Tests/roblox-connection.test.mjs` and `node apple/whistlegraph/Tests/ware-bridge.test.cjs`. With the official Luau CLI installed, `LUAU_BIN=/path/to/luau node roblox/rooms/contract-test.mjs` runs the same valid and malformed rooms through both validators; `luau-compile --null roblox/rooms/src/server/*.luau` checks script compilation. These checks do not establish Roblox physics or mobile handoff behavior. diff --git a/roblox/rooms/contract-test.mjs b/roblox/rooms/contract-test.mjs new file mode 100644 index 0000000000..80ffbc6ebf --- /dev/null +++ b/roblox/rooms/contract-test.mjs @@ -0,0 +1,45 @@ +// Run with LUAU_BIN=/path/to/luau node roblox/rooms/contract-test.mjs. +// Executes the actual Luau validator against cases also checked by JavaScript. +import assert from 'node:assert/strict'; +import {mkdtemp,copyFile,writeFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {execFileSync} from 'node:child_process'; +import {starterRoom,validateRoom,drawPath} from '../../shared/roblox-room.mjs'; +const cases=[['starter',starterRoom(),true],['drawn path',drawPath(starterRoom(),[[0,10],[12,0],[0,-10]]),true]]; +for(const [name,change] of [ + ['script',r=>{r.script='print(1)';}], + ['empty',r=>{r.objects=[];}], + ['duplicate',r=>{r.objects.push({...r.objects[0]});}], + ['over budget',r=>{r.objects=Array.from({length:65},(_,i)=>({...r.objects[0],id:'item-'+i}));}], + ['negative size',r=>{r.objects[0].size[0]=-1;}], + ['fractional color',r=>{r.objects[0].color[0]=.5;}], + ['unknown behavior',r=>{r.objects[0].kind='script';}], + ['high bounce',r=>{r.objects[0].bounce=101;}], + ['yaw',r=>{r.objects[0].yaw=181;}], + ['infinite position',r=>{r.objects[0].position[0]=Infinity;}], + ['NaN position',r=>{r.objects[0].position[0]=NaN;}], + ['sparse vector',r=>{delete r.spawn[1];}], + ['sparse objects',r=>{delete r.objects[1];}], + ['invalid id',r=>{r.objects[0].id=']==]';}], +]){const room=starterRoom();change(room);cases.push([name,room,false]);} +function lua(value){ + if(typeof value==='number')return Number.isNaN(value)?'(0/0)':!Number.isFinite(value)?'math.huge':String(value); + if(value===undefined||value===null)return 'nil'; + if(Array.isArray(value))return '{'+Array.from(value,lua).join(',')+'}'; + if(typeof value==='object')return '{'+Object.entries(value).map(([k,v])=>'['+JSON.stringify(k)+']='+lua(v)).join(',')+'}'; + return JSON.stringify(value); +} +const dir=await mkdtemp(join(tmpdir(),'whistlegraph-luau-')); +try{ + await copyFile(new URL('src/server/Room.luau',import.meta.url),join(dir,'Room.luau')); + let script='local Room = require("./Room")\n'; + for(const [name,room,expected] of cases){ + let valid=true;try{validateRoom(room);}catch{valid=false;} + assert.equal(valid,expected,name+' JavaScript'); + script+=`assert(pcall(Room.validate, ${lua(room)}) == ${expected}, ${JSON.stringify(name+' Luau')})\n`; + } + script+=`print("PASS ${cases.length} shared JavaScript/Luau grammar cases")\n`; + await writeFile(join(dir,'contract.luau'),script); + process.stdout.write(execFileSync(process.env.LUAU_BIN||'luau',[join(dir,'contract.luau')],{encoding:'utf8'})); +}finally{await rm(dir,{recursive:true,force:true});} diff --git a/roblox/rooms/default.project.json b/roblox/rooms/default.project.json new file mode 100644 index 0000000000..c5e2265377 --- /dev/null +++ b/roblox/rooms/default.project.json @@ -0,0 +1,10 @@ +{ + "name": "Whistlegraph Rooms", + "tree": { + "$className": "DataModel", + "Workspace": {"$properties": {"FallenPartsDestroyHeight": -60}}, + "HttpService": {"$className": "HttpService", "$properties": {"HttpEnabled": true}}, + "ServerScriptService": {"WhistlegraphRooms": {"$path": "src/server"}}, + "Lighting": {"$properties": {"ClockTime": 14, "Brightness": 2}} + } +} diff --git a/roblox/rooms/room.test.mjs b/roblox/rooms/room.test.mjs new file mode 100644 index 0000000000..0533d5c157 --- /dev/null +++ b/roblox/rooms/room.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {starterRoom,validateRoom,drawPath,localRoomEdit} from '../../shared/roblox-room.mjs'; +import {createRoomHandler,validRobloxLaunch} from '../../system/backend/whistlegraph-roblox.mjs'; +test('room grammar rejects executable fields, duplicate IDs, nonfinite and excessive geometry',()=>{ + for(const mutate of [r=>r.script='return 1',r=>r.objects[1].id=r.objects[0].id,r=>r.objects[0].size[0]=NaN,r=>r.objects[0].bounce=101,r=>r.objects[0].source='print(1)',r=>r.objects=Array(65).fill(r.objects[0])]){const r=starterRoom();mutate(r);assert.throws(()=>validateRoom(r));} + const r=starterRoom(),copy=validateRoom(r);copy.spawn[0]=20;assert.equal(r.spawn[0],0); +}); +test('drawing creates finite, bounded platform segments; edits preserve unrelated objects',()=>{ + const room=starterRoom(),next=drawPath(room,[[0,0],[8,8],[16,8]]); + assert.equal(next.objects.length,6);assert.equal(room.objects.length,4);assert.equal(next.objects[4].yaw,45); + assert.throws(()=>drawPath(room,[[0,0],[99,0]])); + const bounced=localRoomEdit(room,'make the bridge bounce');assert.equal(bounced.objects[1].bounce,55);assert.deepEqual(bounced.objects[0],room.objects[0]); + assert.equal(localRoomEdit(room,'make a racing simulator'),null); +}); +function fixture(){ + let row=null; + const db={read:async()=>row,save:async(owner,base,room,id)=>{if(row?.requestId===id)return row;if((row?.revision||0)!==base)return null;return row={room,revision:base+1,requestId:id};},applied:async(owner,revision)=>{if(row?.revision===revision)row.appliedRevision=revision;}}; + const config={owner:'maker',playerId:'123',bridgeKey:'x'.repeat(32),launchURL:'https://www.roblox.com/share?code=fixture&type=ExperienceDetails'}; + const handler=createRoomHandler({authenticate:async h=>h.authorization==='Bearer maker'?'maker':h.authorization==='Bearer other'?'other':null,store:async()=>db,config:()=>config}); + const call=async(method,body,headers={Authorization:'Bearer maker'},queryStringParameters={})=>{const r=await handler({httpMethod:method,body:body&&JSON.stringify(body),headers,queryStringParameters});return {status:r.statusCode,...JSON.parse(r.body)};}; + return {call,config}; +} +test('private pilot authenticates both ends, rejects impersonation and conflicts, and acknowledges only the current revision',async()=>{ + const {call}=fixture();const save={action:'save',expectedRevision:0,requestId:'11111111-1111-4111-8111-111111111111',room:starterRoom()}; + assert.equal((await call('POST',save,{})).status,401);assert.equal((await call('POST',save,{Authorization:'Bearer other'})).status,403); + assert.equal((await call('POST',{...save,playerId:'999'})).status,400); + assert.equal((await call('POST',save)).revision,1);assert.equal((await call('POST',save)).revision,1); + assert.equal((await call('POST',{...save,requestId:'22222222-2222-4222-8222-222222222222'})).status,409); + assert.equal((await call('GET',null,{'x-whistlegraph-bridge-key':'wrong'},{playerId:'123'})).status,401); + const headers={'x-whistlegraph-bridge-key':'x'.repeat(32)}; + assert.equal((await call('GET',null,headers,{playerId:'999'})).status,404); + assert.equal((await call('GET',null,headers,{playerId:'123'})).revision,1); + await call('POST',{action:'applied',playerId:'123',revision:1},headers); + assert.equal((await call('GET')).appliedRevision,1); +}); +test('unconfigured pilots do not fabricate launch URLs',async()=>{ + const {call,config}=fixture();config.launchURL='';assert.equal((await call('GET')).available,false); + for(const url of ['javascript:alert(1)','https://www.roblox.com.evil/share?code=x','https://evil@www.roblox.com/share?code=x','https://www.roblox.com/games/123'])assert.equal(validRobloxLaunch(url),false); +}); diff --git a/roblox/rooms/src/server/Room.luau b/roblox/rooms/src/server/Room.luau new file mode 100644 index 0000000000..f8c44db98b --- /dev/null +++ b/roblox/rooms/src/server/Room.luau @@ -0,0 +1,84 @@ +-- A bounded room interpreter. User data never becomes executable Luau. +local Room = {} +local function number(n, low, high) + return type(n) == "number" and n == n and n >= low and n <= high +end +local function vector(v, low, high, integer) + if type(v) ~= "table" or #v ~= 3 then return false end + for k in v do + if not number(k, 1, 3) or k % 1 ~= 0 then return false end + end + for i = 1, 3 do + local n = v[i] + if not number(n, low, high) or (integer and n % 1 ~= 0) then return false end + end + return true +end +local function keys(value, allowed) + if type(value) ~= "table" then return false end + for k in value do if not table.find(allowed, k) then return false end end + return true +end +function Room.validate(data) + assert(keys(data, {"schemaVersion", "spawn", "objects"}) and data.schemaVersion == 1, "Unsupported room") + assert(vector(data.spawn, -64, 64), "Invalid spawn") + assert(type(data.objects) == "table" and #data.objects >= 1 and #data.objects <= 64, "Object limit") + local ids, count = {}, 0 + for index, object in data.objects do + count += 1 + assert(number(index, 1, #data.objects) and index % 1 == 0, "Invalid object array") + assert(keys(object, {"id", "kind", "position", "size", "color", "yaw", "bounce"}), "Invalid object fields") + assert(type(object.id) == "string" and #object.id <= 40 and string.match(object.id, "^[a-z][a-z0-9%-]*$") and not ids[object.id], "Invalid object ID") + ids[object.id] = true + assert(object.kind == "platform" or object.kind == "goal", "Unsupported behavior") + assert(vector(object.position, -64, 64) and vector(object.size, 0.5, 64) and vector(object.color, 0, 255, true), "Invalid geometry") + assert(number(object.yaw, -180, 180) and number(object.bounce, 0, 100), "Invalid behavior value") + end + assert(count == #data.objects, "Sparse object array") + return data +end +function Room.build(data, offset, ownerId) + Room.validate(data) + local model = Instance.new("Model") + model.Name = "WhistlegraphRoom" + if ownerId then model:SetAttribute("CreatorUserId", ownerId) end + local ok, failure = pcall(function() + for _, object in data.objects do + local part = Instance.new("Part") + part.Name, part.Anchored = object.id, true + part.Size = Vector3.new(table.unpack(object.size)) + part.CFrame = CFrame.new(offset + Vector3.new(table.unpack(object.position))) * CFrame.Angles(0, math.rad(object.yaw), 0) + part.Color = Color3.fromRGB(table.unpack(object.color)) + part.TopSurface, part.BottomSurface = Enum.SurfaceType.Smooth, Enum.SurfaceType.Smooth + part.Parent = model + if object.bounce > 0 or object.kind == "goal" then + local touched = setmetatable({}, {__mode = "k"}) + part.Touched:Connect(function(hit) + local character = hit:FindFirstAncestorOfClass("Model") + local humanoid = character and character:FindFirstChildOfClass("Humanoid") + local root = character and character:FindFirstChild("HumanoidRootPart") + if not humanoid or not root or humanoid.Health <= 0 then return end + local now = os.clock() + if now - (touched[character] or -1) < 0.4 then return end + touched[character] = now + if object.bounce > 0 then + local velocity = root.AssemblyLinearVelocity + root.AssemblyLinearVelocity = Vector3.new(velocity.X, object.bounce, velocity.Z) + end + if object.kind == "goal" then + character:SetAttribute("WhistlegraphGoal", object.id) + part.Material = Enum.Material.Neon + end + end) + end + end + local spawn = Instance.new("SpawnLocation") + spawn.Name, spawn.Anchored, spawn.Neutral = "RoomSpawn", true, true + spawn.Size, spawn.Transparency, spawn.CanCollide = Vector3.new(4, 1, 4), 1, false + spawn.Position = offset + Vector3.new(table.unpack(data.spawn)) - Vector3.new(0, 3, 0) + spawn.Parent = model + end) + if not ok then model:Destroy(); error(failure) end + return model, CFrame.new(offset + Vector3.new(table.unpack(data.spawn))) +end +return Room diff --git a/roblox/rooms/src/server/init.server.luau b/roblox/rooms/src/server/init.server.luau new file mode 100644 index 0000000000..994d3af00f --- /dev/null +++ b/roblox/rooms/src/server/init.server.luau @@ -0,0 +1,63 @@ +local Players = game:GetService("Players") +local Http = game:GetService("HttpService") +local Room = require(script.Room) +local endpoint = "https://aesthetic.computer/api/whistlegraph-roblox" +local slots = {} +local function request(player, body) + local response = Http:RequestAsync({ + Url = endpoint .. "?playerId=" .. tostring(player.UserId), + Method = body and "POST" or "GET", + Headers = { ["x-whistlegraph-bridge-key"] = Http:GetSecret("WHISTLEGRAPH_ROBLOX_BRIDGE_KEY"), ["Content-Type"] = "application/json" }, + Body = body and Http:JSONEncode(body) or nil, + }) + assert(response.Success and #response.Body <= 65536, "Room service unavailable") + return Http:JSONDecode(response.Body) +end +local function join(player) + local index = 0 + while slots[index] do index += 1 end + slots[index] = player + local offset = Vector3.new(index * 200, 0, 0) + local spawn = CFrame.new(offset + Vector3.new(0, 4, 20)) + local model = Room.build({schemaVersion=1, spawn={0,4,20},objects={{id="waiting",kind="platform",position={0,0,20},size={16,1,12},color={103,164,187},yaw=0,bounce=0}}}, offset, player.UserId) + model.Parent = workspace + local revision, acknowledged = 0, 0 + local function position(character) + local root = character:WaitForChild("HumanoidRootPart", 10) + if root and player.Parent then character:PivotTo(spawn) end + end + local added = player.CharacterAdded:Connect(position) + if player.Character then task.spawn(position, player.Character) end + task.spawn(function() + while player.Parent do + local ok, failure = pcall(function() + local snapshot = request(player) + assert(type(snapshot.revision) == "number" and snapshot.revision % 1 == 0 and snapshot.revision >= 1, "Invalid revision") + if not player.Parent then return end + if snapshot.revision > revision then + -- Construction is staged before the old room is removed. + local nextModel, nextSpawn = Room.build(snapshot.room, offset, player.UserId) + nextModel.Parent = workspace + local previous = model + model, spawn, revision = nextModel, nextSpawn, snapshot.revision + if player.Character then player.Character:PivotTo(spawn) end + previous:Destroy() + player:SetAttribute("WhistlegraphRoomRevision", revision) + end + if acknowledged < revision then + request(player,{action="applied",playerId=tostring(player.UserId),revision=revision}) + acknowledged = revision + end + player:SetAttribute("WhistlegraphRoomStatus", "Applied") + end) + if not ok then + player:SetAttribute("WhistlegraphRoomStatus", "Waiting for room") + -- Keep the last good room; never log credentials or responses. + end + task.wait(ok and 5 or 15) + end + added:Disconnect(); model:Destroy(); slots[index] = nil + end) +end +Players.PlayerAdded:Connect(join) +for _, player in Players:GetPlayers() do task.spawn(join, player) end diff --git a/shared/roblox-room.mjs b/shared/roblox-room.mjs new file mode 100644 index 0000000000..daa22bb6e1 --- /dev/null +++ b/shared/roblox-room.mjs @@ -0,0 +1,61 @@ +// Room grammar shared by the maker and its service. No executable user source. +export const ROOM_LIMITS=Object.freeze({objects:64,coordinate:64,size:64,history:128}); +const keys=(value,allowed)=>value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).every(k=>allowed.includes(k)); +const vector=(v,min,max,integer=false)=>Array.isArray(v)&&v.length===3&&Array.from(v).every(n=>typeof n==='number'&&Number.isFinite(n)&&n>=min&&n<=max&&(!integer||Number.isInteger(n))); +export function validateRoom(value){ + if(!keys(value,['schemaVersion','spawn','objects'])||value.schemaVersion!==1||!vector(value.spawn,-64,64)||!Array.isArray(value.objects)||!value.objects.length||value.objects.length>ROOM_LIMITS.objects)throw Error('Invalid room: use a spawn and 1–64 objects'); + const ids=new Set(); + for(const item of value.objects){ + if(!keys(item,['id','kind','position','size','color','yaw','bounce'])||typeof item.id!=='string'||!/^[a-z][a-z0-9-]{0,39}$/.test(item.id)||ids.has(item.id)||!['platform','goal'].includes(item.kind)||!vector(item.position,-64,64)||!vector(item.size,0.5,64)||!vector(item.color,0,255,true)||!Number.isFinite(item.yaw)||Math.abs(item.yaw)>180||!Number.isFinite(item.bounce)||item.bounce<0||item.bounce>100)throw Error('Invalid room object'); + ids.add(item.id); + } + return JSON.parse(JSON.stringify(value)); +} +export function starterRoom(){return {schemaVersion:1,spawn:[0,4,20],objects:[ + {id:'start',kind:'platform',position:[0,0,20],size:[16,1,12],color:[103,164,187],yaw:0,bounce:0}, + {id:'bridge',kind:'platform',position:[0,0,0],size:[6,1,28],color:[180,72,135],yaw:0,bounce:0}, + {id:'finish',kind:'platform',position:[0,0,-20],size:[16,1,12],color:[103,164,187],yaw:0,bounce:0}, + {id:'goal',kind:'goal',position:[0,1,-20],size:[4,1,4],color:[229,207,86],yaw:0,bounce:0}, +]};} +export function changeObject(room,id,patch){ + if(!room.objects.some(o=>o.id===id))throw Error('Select an object first'); + return validateRoom({...room,objects:room.objects.map(o=>o.id===id?{...o,...patch}:o)}); +} +export function drawPath(room,points){ + if(!Array.isArray(points)||points.length<2||points.length>128||points.some(p=>!Array.isArray(p)||p.length!==2||p.some(v=>!Number.isFinite(v)||Math.abs(v)>48)))throw Error('Draw inside the room'); + const objects=[...room.objects];let next=1; + const used=new Set(objects.map(o=>o.id)); + let last=points[0]; + for(const point of points.slice(1)){ + const dx=point[0]-last[0],dz=point[1]-last[1],length=Math.hypot(dx,dz); + if(length<2)continue; + if(length>60)throw Error('Draw shorter path segments'); + while(used.has('path-'+next))next++; + const id='path-'+next;used.add(id); + objects.push({id,kind:'platform',position:[(last[0]+point[0])/2,0,(last[1]+point[1])/2],size:[4,1,length+1],color:[180,72,135],yaw:Math.atan2(dx,dz)*180/Math.PI,bounce:0}); + last=point; + } + if(objects.length===room.objects.length)throw Error('Draw a longer path'); + return validateRoom({...room,objects}); +} +export function localRoomEdit(room,text,selected='bridge'){ + const t=String(text).trim().toLowerCase().replace(/[.!]+$/,''); + const object=room.objects.find(o=>o.id===selected); + if(!object)return null; + if(/^(?:make (?:it|this|the bridge) )?(?:bounce|bouncy)$/.test(t))return changeObject(room,selected,{bounce:55}); + if(/^(?:stop bouncing|remove the bounce)$/.test(t))return changeObject(room,selected,{bounce:0}); + if(/^make (?:it|this|the bridge) (?:wider|narrower)$/.test(t))return changeObject(room,selected,{size:[Math.max(.5,Math.min(64,object.size[0]+(t.endsWith('narrower')?-2:2))),...object.size.slice(1)]}); + const colors={pink:[180,72,135],blue:[103,164,187],yellow:[229,207,86],green:[94,176,127],red:[218,83,80],white:[238,236,229]}; + const color=t.match(/^(?:make (?:it|this|the bridge) )?(pink|blue|yellow|green|red|white)$/)?.[1]; + return color?changeObject(room,selected,{color:colors[color]}):null; +} +export const ROOM_SCHEMA={type:'object',additionalProperties:false,required:['schemaVersion','spawn','objects'],properties:{ + schemaVersion:{type:'integer',enum:[1]},spawn:{type:'array',minItems:3,maxItems:3,items:{type:'number',minimum:-64,maximum:64}}, + objects:{type:'array',minItems:1,maxItems:64,items:{type:'object',additionalProperties:false,required:['id','kind','position','size','color','yaw','bounce'],properties:{ + id:{type:'string',pattern:'^[a-z][a-z0-9-]{0,39}$'},kind:{type:'string',enum:['platform','goal']}, + position:{type:'array',minItems:3,maxItems:3,items:{type:'number',minimum:-64,maximum:64}}, + size:{type:'array',minItems:3,maxItems:3,items:{type:'number',minimum:.5,maximum:64}}, + color:{type:'array',minItems:3,maxItems:3,items:{type:'integer',minimum:0,maximum:255}}, + yaw:{type:'number',minimum:-180,maximum:180},bounce:{type:'number',minimum:0,maximum:100}, + }}}, +}}; diff --git a/slab/bin/whistlegraph.mjs b/slab/bin/whistlegraph.mjs new file mode 100755 index 0000000000..bd692293f5 --- /dev/null +++ b/slab/bin/whistlegraph.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// Agent-friendly access to a running Whistlegraph thread. Tokens never enter argv. +import {ACSession,USER_AGENT} from '../../aesel/src/ac-session.mjs'; +import {WebSocket} from 'ws'; +import {createHash,randomUUID} from 'node:crypto'; +import {readFileSync} from 'node:fs'; +const [command='list',code,...words]=process.argv.slice(2); +if(!['list','inspect','watch','ask','undo','edit','layout'].includes(command))throw Error('Usage: whistlegraph.mjs list | inspect CODE | watch CODE | ask CODE WORDS | undo CODE | edit CODE FILE BASE_VERSION BASE_HASH | layout CODE CSS_FILE'); +const replacement=['edit','layout'].includes(command)?readFileSync(words[0],'utf8'):null; +if(command==='edit'&&(!/^\d+$/.test(words[1]||'')||!/^[a-f0-9]{64}$/.test(words[2]||'')))throw Error('An edit requires the version and SHA-256 from your inspected source'); +const session=new ACSession(),token=await session.token(); +if(!token)throw Error('Sign in with ac-login first'); +const origin=process.env.WHISTLEGRAPH_ORIGIN||process.env.WALKIE_ORIGIN||'https://aesthetic.computer'; +if(command==='list'||command==='inspect') { + const response=await fetch(origin+'/api/whistlegraph'+(code?'?code='+encodeURIComponent(code):''),{headers:{Authorization:`Bearer ${token}`,'User-Agent':USER_AGENT}}); + if(!response.ok)throw Error(`Thread request failed (${response.status})`); + console.log(JSON.stringify(await response.json(),null,2)); +}else { + const ws=new WebSocket(origin.replace(/^http/,'ws')+'/api/whistlegraph-stream',{headers:{'User-Agent':USER_AGENT}}); + const timer=command==='watch'?null:setTimeout(()=>{console.error('Timed out; inspect before retrying');ws.close();process.exitCode=1;},185000); + ws.on('open',()=>ws.send(JSON.stringify({type:'authenticate',role:'agent',token,code}))); + ws.on('message',raw=>{ + const m=JSON.parse(raw); + if(command==='watch'){console.log(JSON.stringify(m));return;} + if(m.type==='ready') { + if(!m.online){console.error('Device offline');process.exitCode=1;ws.close();return;} + const v=m.thread.ledger?.versions.find(v=>v.id===m.thread.ledger.head); + if(!v){console.error('No synchronized version');process.exitCode=1;ws.close();return;} + ws.send(JSON.stringify({type:'command',id:randomUUID(),action:command,text:command==='edit'?'Remote source edit':words.join(' '),source:command==='edit'?replacement:null,css:command==='layout'?replacement:undefined,baseVersion:command==='edit'?Number(words[1]):v.id,baseHash:command==='edit'?words[2]:createHash('sha256').update(v.source).digest('hex')})); + } + if(['result','error','accepted'].includes(m.type))console.log(JSON.stringify(m)); + if(m.type==='result'||m.type==='error'){if(m.type==='error'||!m.ok)process.exitCode=1;ws.close();} + }); + ws.on('error',()=>{console.error('Whistlegraph connection failed');process.exitCode=1;}); + ws.on('close',()=>clearTimeout(timer)); +} diff --git a/slab/bin/ww.mjs b/slab/bin/ww.mjs index 0d56d0ef71..3559d368f7 100644 --- a/slab/bin/ww.mjs +++ b/slab/bin/ww.mjs @@ -1,36 +1,3 @@ #!/usr/bin/env node -// Agent-friendly access to a running Walkieware thread. Tokens never enter argv. -import {ACSession,USER_AGENT} from '../../aesel/src/ac-session.mjs'; -import {WebSocket} from 'ws'; -import {createHash,randomUUID} from 'node:crypto'; -import {readFileSync} from 'node:fs'; -const [command='list',code,...words]=process.argv.slice(2); -if(!['list','inspect','watch','ask','undo','edit','layout'].includes(command))throw Error('Usage: ww.mjs list | inspect CODE | watch CODE | ask CODE WORDS | undo CODE | edit CODE FILE BASE_VERSION BASE_HASH | layout CODE CSS_FILE'); -const replacement=['edit','layout'].includes(command)?readFileSync(words[0],'utf8'):null; -if(command==='edit'&&(!/^\d+$/.test(words[1]||'')||!/^[a-f0-9]{64}$/.test(words[2]||'')))throw Error('An edit requires the version and SHA-256 from your inspected source'); -const session=new ACSession(),token=await session.token(); -if(!token)throw Error('Sign in with ac-login first'); -const origin=process.env.WALKIE_ORIGIN||'https://aesthetic.computer'; -if(command==='list'||command==='inspect') { - const response=await fetch(origin+'/api/walkieware'+(code?'?code='+encodeURIComponent(code):''),{headers:{Authorization:`Bearer ${token}`,'User-Agent':USER_AGENT}}); - if(!response.ok)throw Error(`Thread request failed (${response.status})`); - console.log(JSON.stringify(await response.json(),null,2)); -}else { - const ws=new WebSocket(origin.replace(/^http/,'ws')+'/api/walkieware-stream',{headers:{'User-Agent':USER_AGENT}}); - const timer=command==='watch'?null:setTimeout(()=>{console.error('Timed out; inspect before retrying');ws.close();process.exitCode=1;},185000); - ws.on('open',()=>ws.send(JSON.stringify({type:'authenticate',role:'agent',token,code}))); - ws.on('message',raw=>{ - const m=JSON.parse(raw); - if(command==='watch'){console.log(JSON.stringify(m));return;} - if(m.type==='ready') { - if(!m.online){console.error('Device offline');process.exitCode=1;ws.close();return;} - const v=m.thread.ledger?.versions.find(v=>v.id===m.thread.ledger.head); - if(!v){console.error('No synchronized version');process.exitCode=1;ws.close();return;} - ws.send(JSON.stringify({type:'command',id:randomUUID(),action:command,text:command==='edit'?'Remote source edit':words.join(' '),source:command==='edit'?replacement:null,css:command==='layout'?replacement:undefined,baseVersion:command==='edit'?Number(words[1]):v.id,baseHash:command==='edit'?words[2]:createHash('sha256').update(v.source).digest('hex')})); - } - if(['result','error','accepted'].includes(m.type))console.log(JSON.stringify(m)); - if(m.type==='result'||m.type==='error'){if(m.type==='error'||!m.ok)process.exitCode=1;ws.close();} - }); - ws.on('error',()=>{console.error('Walkieware connection failed');process.exitCode=1;}); - ws.on('close',()=>clearTimeout(timer)); -} +// Compatibility shortcut for Whistlegraph. +import './whistlegraph.mjs'; diff --git a/system/backend/easel-musical-jev.mjs b/system/backend/easel-musical-jev.mjs index 7e410378d2..f675fac159 100644 --- a/system/backend/easel-musical-jev.mjs +++ b/system/backend/easel-musical-jev.mjs @@ -22,7 +22,7 @@ export function createMusicalHandler({authenticate,budget,evaluate=evaluateChoic if(!event.headers?.authorization)return reply(401,{error:'Sign in first'}); if(typeof event.body!=='string'||event.body.length>2048)return reply(400,{error:'Invalid observation'}); let body,request; - try{body=JSON.parse(event.body);if(body.schema!=='walkieware-input/v1'||! /^[a-f0-9-]{36}$/i.test(body.sessionId)||!Number.isInteger(body.sequence)||body.sequence<1||body.sequence>1000||Object.keys(body).some(k=>!['schema','sessionId','sequence','features'].includes(k)))throw Error();request=musicalDecisionRequest(body.features);}catch{return reply(400,{error:'Invalid observation'});} + try{body=JSON.parse(event.body);if(!['whistlegraph-input/v1','walkieware-input/v1'].includes(body.schema)||! /^[a-f0-9-]{36}$/i.test(body.sessionId)||!Number.isInteger(body.sequence)||body.sequence<1||body.sequence>1000||Object.keys(body).some(k=>!['schema','sessionId','sequence','features'].includes(k)))throw Error();request=musicalDecisionRequest(body.features);}catch{return reply(400,{error:'Invalid observation'});} let subject;try{subject=trustedSubject??await authenticate(event.headers);}catch{return reply(503,{error:'Account check unavailable'});} if(!subject)return reply(401,{error:'A valid account with a handle is required'}); if(busy.has(subject)||busy.size>=8)return reply(429,{error:'Decision already running'}); @@ -34,7 +34,7 @@ export function createMusicalHandler({authenticate,budget,evaluate=evaluateChoic const result=await evaluate(request,{signal:signal?AbortSignal.any([signal,deadline]):deadline}); const answer=result.answers?.mapping, confidence=answer?.probabilities?.[answer.choice]; if(!Object.hasOwn(MUSICAL_CHOICES,answer?.choice)||!Number.isFinite(confidence)||confidence<0||confidence>1)throw Error('Invalid answer'); - return reply(200,{schema:'walkieware-decision/v1',sessionId:body.sessionId,sequence:body.sequence,choice:answer.choice,confidence,elapsedMs:Math.round(performance.now()-started)}); + return reply(200,{schema:body.schema.replace('-input/','-decision/'),sessionId:body.sessionId,sequence:body.sequence,choice:answer.choice,confidence,elapsedMs:Math.round(performance.now()-started)}); }catch{return reply(503,{error:'Musical advice unavailable; continue without it'});} finally{busy.delete(subject);} }; diff --git a/system/backend/walkieware.mjs b/system/backend/walkieware.mjs index 99c27fb387..14302f9a7e 100644 --- a/system/backend/walkieware.mjs +++ b/system/backend/walkieware.mjs @@ -1,65 +1,3 @@ -import {randomInt, createHash} from 'node:crypto'; - -export const validID = value => typeof value === 'string' && /^[a-f0-9-]{36}$/i.test(value); -// New Whistlegraph threads use wg; persisted ww identifiers remain readable. -export const THREAD_CODE_PREFIX = 'wg'; -export const validCode = value => typeof value === 'string' && /^(?:wg|ww)[a-z]{5,12}$/i.test(value); -export function pronounceableCode() { - const consonants='bdfghklmnprstvz', vowels='aeiou'; - let word=''; - for (let i=0;i<5;i++) word+=(i%2?vowels:consonants)[randomInt(i%2?vowels.length:consonants.length)]; - return THREAD_CODE_PREFIX+word[0].toUpperCase()+word.slice(1); -} -export const sourceHash = source => createHash('sha256').update(source).digest('hex'); -export function validateLedger(ledger) { - if(ledger?.format!==1 || !Array.isArray(ledger.versions) || !ledger.versions.length || ledger.versions.length>256) throw Error('Invalid version history'); - if(Buffer.byteLength(JSON.stringify(ledger))>8_000_000) throw Error('Version history exceeds 8 MB'); - const ids=new Set(); - for(const v of ledger.versions) { - if(!Number.isSafeInteger(v.id)||v.id<0||ids.has(v.id)||typeof v.source!=='string'||Buffer.byteLength(v.source)>500_000||!(v.parent===null||ids.has(v.parent))||!(v.request==null||typeof v.request==='string'&&v.request.length<=20000)) throw Error('Invalid version'); - ids.add(v.id); - } - if(!ids.has(ledger.head)) throw Error('Missing head'); - return {format:1,head:ledger.head,versions:ledger.versions.map(({id,parent,source,request,createdAt,layers})=>({id,parent,source,request:request??null,createdAt:typeof createdAt==='string'?createdAt:'',layers:Number.isInteger(layers)?layers:0}))}; -} -// A single Mongo document makes head + immutable version additions atomic. -export function mongoWalkiewareStore(collection, {name=pronounceableCode}={}) { - let indexes; - const ready=()=>indexes??=collection.createIndex({codeKey:1},{unique:true}); - return { - async open(owner,id) { - if(!validID(id))throw Error('Invalid thread ID'); - await ready(); - const existing=await collection.findOne({_id:id,owner}); - if(existing)return existing; - for(let attempt=0;attempt<100;attempt++) { - const code=name(), row={_id:id,owner,code,codeKey:code.toLowerCase(),revision:0,ledger:null,updatedAt:new Date().toISOString()}; - try {await collection.insertOne(row);return row;} catch(error) { - if(error.code!==11000)throw error; - const claimed=await collection.findOne({_id:id}); - if(claimed){if(claimed.owner!==owner)throw Error('Thread unavailable');return claimed;} - } - } - throw Error('Unable to reserve a name'); - }, - async read(owner,code) {if(!validCode(code))return null;return collection.findOne({owner,codeKey:code.toLowerCase()});}, - async list(owner) {return collection.find({owner},{projection:{owner:0,ledger:0}}).sort({updatedAt:-1}).limit(100).toArray();}, - async state(owner,id,state){await collection.updateOne({_id:id,owner},{$set:{diagnostics:state}});}, - async save(owner,id,revision,ledger) { - ledger=validateLedger(ledger); - const previous=await collection.findOne({_id:id,owner,revision}); - if(!previous)return null; - for(const v of previous.ledger?.versions||[]) { - const next=ledger.versions.find(n=>n.id===v.id); - if(!next||JSON.stringify(next)!==JSON.stringify(v))throw Error('Saved versions are immutable'); - } - const result=await collection.updateOne({_id:id,owner,revision},{$set:{ledger,updatedAt:new Date().toISOString()},$inc:{revision:1}}); - return result.modifiedCount?{...previous,ledger,revision:revision+1}:null; - } - }; -} -export function publicThread(row) { - if(!row)return null; - const head=row.ledger?.versions.find(v=>v.id===row.ledger.head); - return {id:row._id,code:row.code,revision:row.revision,ledger:row.ledger,head:head?.id,sourceHash:head?sourceHash(head.source):null,updatedAt:row.updatedAt,diagnostics:row.diagnostics}; -} +// Compatibility import; all new callers use whistlegraph.mjs. +export * from './whistlegraph.mjs'; +export {mongoWhistlegraphStore as mongoWalkiewareStore} from './whistlegraph.mjs'; diff --git a/system/backend/whistlegraph-roblox.mjs b/system/backend/whistlegraph-roblox.mjs new file mode 100644 index 0000000000..26c950aba4 --- /dev/null +++ b/system/backend/whistlegraph-roblox.mjs @@ -0,0 +1,66 @@ +import {timingSafeEqual} from 'node:crypto'; +import {validateRoom} from '../../shared/roblox-room.mjs'; + +export function validRobloxLaunch(value){ + try{const u=new URL(value);return u.protocol==='https:'&&u.hostname==='www.roblox.com'&&u.pathname==='/share'&&!u.username&&!u.password&&!u.port&&!!u.searchParams.get('code');}catch{return false;} +} +function sameSecret(a,b){return typeof a==='string'&&typeof b==='string'&&b.length>=32&&Buffer.byteLength(a)===Buffer.byteLength(b)&&timingSafeEqual(Buffer.from(a),Buffer.from(b));} +export function mongoRoomStore(collection){return { + read:owner=>collection.findOne({_id:owner}), + async save(owner,expectedRevision,room,requestId){ + const previous=await collection.findOne({_id:owner}); + if(previous?.requestId===requestId)return previous; + const revision=previous?.revision||0; + if(revision!==expectedRevision)return null; + const next={_id:owner,revision:revision+1,room,requestId,updatedAt:new Date().toISOString()}; + if(!previous){try{await collection.insertOne(next);return next;}catch(e){if(e.code===11000)return null;throw e;}} + const result=await collection.replaceOne({_id:owner,revision},next); + return result.modifiedCount?next:null; + }, + async applied(owner,revision){return collection.updateOne({_id:owner,revision},{$set:{appliedRevision:revision,appliedAt:new Date().toISOString()}});}, +};} + +// A private, operator-configured pilot. It is NOT public OAuth account linking. +// Only the configured AC subject can write; only the configured Roblox user can +// receive the room from an authenticated game server. No ID comes from the app. +export function createRoomHandler({authenticate,store,config}){ + return async event=>{ + const headers={'Content-Type':'application/json','Cache-Control':'no-store','Access-Control-Allow-Origin':'*','Access-Control-Allow-Headers':'Authorization, Content-Type','Access-Control-Allow-Methods':'GET, POST, OPTIONS'}; + const reply=(statusCode,value)=>({statusCode,headers,body:JSON.stringify(value)}); + if(event.httpMethod==='OPTIONS')return reply(204,{}); + if(!['GET','POST'].includes(event.httpMethod))return reply(405,{error:'Use GET or POST'}); + if(typeof event.body==='string'&&Buffer.byteLength(event.body)>65536)return reply(413,{error:'Room is too large'}); + const settings=config(); + const available=!!settings.owner&&/^[1-9]\d{0,19}$/.test(settings.playerId||'')&&validRobloxLaunch(settings.launchURL)&&typeof settings.bridgeKey==='string'&&settings.bridgeKey.length>=32; + const incoming=Object.fromEntries(Object.entries(event.headers||{}).map(([k,v])=>[k.toLowerCase(),v])); + try { + if(incoming['x-whistlegraph-bridge-key']!==undefined){ + if(!available||!sameSecret(incoming['x-whistlegraph-bridge-key'],settings.bridgeKey))return reply(401,{error:'Game server authorization required'}); + if(event.httpMethod==='GET'){ + if(event.queryStringParameters?.playerId!==settings.playerId)return reply(404,{error:'No room for this player'}); + const row=await (await store()).read(settings.owner); + return row?reply(200,{revision:row.revision,room:row.room}):reply(404,{error:'No room saved yet'}); + } + let body;try{body=JSON.parse(event.body||'');}catch{return reply(400,{error:'Invalid JSON'});} + if(body.action!=='applied'||body.playerId!==settings.playerId||!Number.isSafeInteger(body.revision)||body.revision<1)return reply(400,{error:'Invalid acknowledgment'}); + await (await store()).applied(settings.owner,body.revision);return reply(200,{ok:true}); + } + const owner=await authenticate(incoming); + if(!owner)return reply(401,{error:'Sign in to Whistlegraph'}); + if(!available)return reply(200,{available:false}); + if(owner!==settings.owner)return reply(403,{error:'This Roblox connection is a private pilot.'}); + if(event.httpMethod==='GET'){ + const row=await (await store()).read(owner); + return reply(200,{available:true,revision:row?.revision||0,appliedRevision:row?.appliedRevision||null}); + } + let body,room; + try{ + body=JSON.parse(event.body||''); + if(body.action!=='save'||Object.keys(body).some(k=>!['action','room','requestId','expectedRevision'].includes(k))||!Number.isSafeInteger(body.expectedRevision)||body.expectedRevision<0||typeof body.requestId!=='string'||!/^[a-f0-9-]{36}$/i.test(body.requestId))throw Error(); + room=validateRoom(body.room); + }catch{return reply(400,{error:'Invalid room save'});} + const row=await (await store()).save(owner,body.expectedRevision,room,body.requestId); + return row?reply(200,{revision:row.revision,launchURL:settings.launchURL}):reply(409,{error:'The saved Roblox room changed on another session. Export your local room before reconnecting.'}); + }catch{return reply(503,{error:'Room service unavailable. Your local room is still saved.'});} + }; +} diff --git a/system/backend/whistlegraph.mjs b/system/backend/whistlegraph.mjs new file mode 100644 index 0000000000..517a8d6420 --- /dev/null +++ b/system/backend/whistlegraph.mjs @@ -0,0 +1,65 @@ +import {randomInt, createHash} from 'node:crypto'; + +export const validID = value => typeof value === 'string' && /^[a-f0-9-]{36}$/i.test(value); +// New Whistlegraph threads use wg; persisted ww identifiers remain readable. +export const THREAD_CODE_PREFIX = 'wg'; +export const validCode = value => typeof value === 'string' && /^(?:wg|ww)[a-z]{5,12}$/i.test(value); +export function pronounceableCode() { + const consonants='bdfghklmnprstvz', vowels='aeiou'; + let word=''; + for (let i=0;i<5;i++) word+=(i%2?vowels:consonants)[randomInt(i%2?vowels.length:consonants.length)]; + return THREAD_CODE_PREFIX+word[0].toUpperCase()+word.slice(1); +} +export const sourceHash = source => createHash('sha256').update(source).digest('hex'); +export function validateLedger(ledger) { + if(ledger?.format!==1 || !Array.isArray(ledger.versions) || !ledger.versions.length || ledger.versions.length>256) throw Error('Invalid version history'); + if(Buffer.byteLength(JSON.stringify(ledger))>8_000_000) throw Error('Version history exceeds 8 MB'); + const ids=new Set(); + for(const v of ledger.versions) { + if(!Number.isSafeInteger(v.id)||v.id<0||ids.has(v.id)||typeof v.source!=='string'||Buffer.byteLength(v.source)>500_000||!(v.parent===null||ids.has(v.parent))||!(v.request==null||typeof v.request==='string'&&v.request.length<=20000)) throw Error('Invalid version'); + ids.add(v.id); + } + if(!ids.has(ledger.head)) throw Error('Missing head'); + return {format:1,head:ledger.head,versions:ledger.versions.map(({id,parent,source,request,createdAt,layers})=>({id,parent,source,request:request??null,createdAt:typeof createdAt==='string'?createdAt:'',layers:Number.isInteger(layers)?layers:0}))}; +} +// A single Mongo document makes head + immutable version additions atomic. +export function mongoWhistlegraphStore(collection, {name=pronounceableCode}={}) { + let indexes; + const ready=()=>indexes??=collection.createIndex({codeKey:1},{unique:true}); + return { + async open(owner,id) { + if(!validID(id))throw Error('Invalid thread ID'); + await ready(); + const existing=await collection.findOne({_id:id,owner}); + if(existing)return existing; + for(let attempt=0;attempt<100;attempt++) { + const code=name(), row={_id:id,owner,code,codeKey:code.toLowerCase(),revision:0,ledger:null,updatedAt:new Date().toISOString()}; + try {await collection.insertOne(row);return row;} catch(error) { + if(error.code!==11000)throw error; + const claimed=await collection.findOne({_id:id}); + if(claimed){if(claimed.owner!==owner)throw Error('Thread unavailable');return claimed;} + } + } + throw Error('Unable to reserve a name'); + }, + async read(owner,code) {if(!validCode(code))return null;return collection.findOne({owner,codeKey:code.toLowerCase()});}, + async list(owner) {return collection.find({owner},{projection:{owner:0,ledger:0}}).sort({updatedAt:-1}).limit(100).toArray();}, + async state(owner,id,state){await collection.updateOne({_id:id,owner},{$set:{diagnostics:state}});}, + async save(owner,id,revision,ledger) { + ledger=validateLedger(ledger); + const previous=await collection.findOne({_id:id,owner,revision}); + if(!previous)return null; + for(const v of previous.ledger?.versions||[]) { + const next=ledger.versions.find(n=>n.id===v.id); + if(!next||JSON.stringify(next)!==JSON.stringify(v))throw Error('Saved versions are immutable'); + } + const result=await collection.updateOne({_id:id,owner,revision},{$set:{ledger,updatedAt:new Date().toISOString()},$inc:{revision:1}}); + return result.modifiedCount?{...previous,ledger,revision:revision+1}:null; + } + }; +} +export function publicThread(row) { + if(!row)return null; + const head=row.ledger?.versions.find(v=>v.id===row.ledger.head); + return {id:row._id,code:row.code,revision:row.revision,ledger:row.ledger,head:head?.id,sourceHash:head?sourceHash(head.source):null,updatedAt:row.updatedAt,diagnostics:row.diagnostics}; +} diff --git a/system/netlify/functions/walkieware.mjs b/system/netlify/functions/walkieware.mjs index c250de39e3..b21030ec07 100644 --- a/system/netlify/functions/walkieware.mjs +++ b/system/netlify/functions/walkieware.mjs @@ -1,20 +1,2 @@ -import {connect} from '../../backend/database.mjs'; -import {authenticateMusical} from './easel-musical-jev.mjs'; -import {mongoWalkiewareStore,publicThread} from '../../backend/walkieware.mjs'; -export {authenticateMusical as authenticateWalkieware}; -let pending; -export const walkiewareStore=()=>pending??=(async()=>{const {db}=await connect();return mongoWalkiewareStore(db.collection('walkieware-threads'));})().catch(error=>{pending=null;throw error;}); -export async function handler(event) { - const headers={'Content-Type':'application/json','Cache-Control':'no-store','Access-Control-Allow-Origin':'*','Access-Control-Allow-Headers':'Authorization, Content-Type','Access-Control-Allow-Methods':'GET, OPTIONS'}; - const reply=(statusCode,value)=>({statusCode,headers,body:JSON.stringify(value)}); - if(event.httpMethod==='OPTIONS')return reply(204,{}); - if(event.httpMethod!=='GET')return reply(405,{error:'Use GET; edits use the authenticated live socket'}); - try { - const owner=await authenticateMusical(event.headers||{}); - if(!owner)return reply(401,{error:'Sign in to your AC account'}); - const store=await walkiewareStore(),code=event.queryStringParameters?.code; - if(!code)return reply(200,{threads:(await store.list(owner)).map(publicThread)}); - const row=await store.read(owner,code); - return row?reply(200,publicThread(row)):reply(404,{error:'Thread unavailable'}); - }catch{return reply(503,{error:'Thread storage unavailable'});} -} +// Compatibility route for installed clients; Whistlegraph is canonical. +export {handler,authenticateWhistlegraph as authenticateWalkieware,whistlegraphStore as walkiewareStore} from './whistlegraph.mjs'; diff --git a/system/netlify/functions/whistlegraph-roblox.mjs b/system/netlify/functions/whistlegraph-roblox.mjs new file mode 100644 index 0000000000..8dc02f1b75 --- /dev/null +++ b/system/netlify/functions/whistlegraph-roblox.mjs @@ -0,0 +1,8 @@ +import {connect} from '../../backend/database.mjs'; +import {authenticateMusical} from './easel-musical-jev.mjs'; +import {createRoomHandler,mongoRoomStore} from '../../backend/whistlegraph-roblox.mjs'; +let pending; +export const handler=createRoomHandler({authenticate:authenticateMusical, + store:()=>pending??=(async()=>{const {db}=await connect();return mongoRoomStore(db.collection('whistlegraph-roblox-rooms'));})().catch(error=>{pending=null;throw error;}), + config:()=>({owner:process.env.WHISTLEGRAPH_ROBLOX_PREVIEW_OWNER,playerId:process.env.WHISTLEGRAPH_ROBLOX_PREVIEW_USER_ID,launchURL:process.env.WHISTLEGRAPH_ROBLOX_SHARE_LINK,bridgeKey:process.env.WHISTLEGRAPH_ROBLOX_BRIDGE_KEY}), +}); diff --git a/system/netlify/functions/whistlegraph.mjs b/system/netlify/functions/whistlegraph.mjs new file mode 100644 index 0000000000..adb111a29e --- /dev/null +++ b/system/netlify/functions/whistlegraph.mjs @@ -0,0 +1,20 @@ +import {connect} from '../../backend/database.mjs'; +import {authenticateMusical} from './easel-musical-jev.mjs'; +import {mongoWhistlegraphStore,publicThread} from '../../backend/whistlegraph.mjs'; +export {authenticateMusical as authenticateWhistlegraph}; +let pending; +export const whistlegraphStore=()=>pending??=(async()=>{const {db}=await connect();return mongoWhistlegraphStore(db.collection('walkieware-threads'));})().catch(error=>{pending=null;throw error;}); +export async function handler(event) { + const headers={'Content-Type':'application/json','Cache-Control':'no-store','Access-Control-Allow-Origin':'*','Access-Control-Allow-Headers':'Authorization, Content-Type','Access-Control-Allow-Methods':'GET, OPTIONS'}; + const reply=(statusCode,value)=>({statusCode,headers,body:JSON.stringify(value)}); + if(event.httpMethod==='OPTIONS')return reply(204,{}); + if(event.httpMethod!=='GET')return reply(405,{error:'Use GET; edits use the authenticated live socket'}); + try { + const owner=await authenticateMusical(event.headers||{}); + if(!owner)return reply(401,{error:'Sign in to your AC account'}); + const store=await whistlegraphStore(),code=event.queryStringParameters?.code; + if(!code)return reply(200,{threads:(await store.list(owner)).map(publicThread)}); + const row=await store.read(owner,code); + return row?reply(200,publicThread(row)):reply(404,{error:'Thread unavailable'}); + }catch{return reply(503,{error:'Thread storage unavailable'});} +} diff --git a/system/public/aesthetic.computer/disks/ww.mjs b/system/public/aesthetic.computer/disks/ww.mjs index 66153127db..771e56cfde 100644 --- a/system/public/aesthetic.computer/disks/ww.mjs +++ b/system/public/aesthetic.computer/disks/ww.mjs @@ -1,14 +1,14 @@ -// Walkieware, 26.09.30 -// Open your running Walkieware piece by its ww code. +// Whistlegraph, 26.09.30 +// Open your running Whistlegraph piece by its code. let socket,child,api,problem='Connecting…',generation=0,stopped=false; export function boot($) {api=$;void connect($).catch(error=>problem=error.message);} async function connect($) { api=$;const code=$.params[0]; - if(!/^ww[a-z]{5,12}$/i.test(code||'')){problem='Enter a ww code';return;} + if(!/^(?:wg|ww)[a-z]{5,12}$/i.test(code||'')){problem='Enter a Whistlegraph code';return;} let token;try{token=await $.authorize?.();}catch{} if(stopped)return; - if(!token){problem='Sign in to open your Walkieware piece';return;} - socket=new WebSocket('wss://aesthetic.computer/api/walkieware-stream'); + if(!token){problem='Sign in to open your Whistlegraph piece';return;} + socket=new WebSocket('wss://aesthetic.computer/api/whistlegraph-stream'); socket.onopen=()=>socket.send(JSON.stringify({type:'authenticate',role:'agent',token,code})); let current=''; socket.onmessage=async event=>{ diff --git a/system/public/aesthetic.computer/lib/parse.mjs b/system/public/aesthetic.computer/lib/parse.mjs index e2feafb631..9c1f467e57 100644 --- a/system/public/aesthetic.computer/lib/parse.mjs +++ b/system/public/aesthetic.computer/lib/parse.mjs @@ -131,8 +131,8 @@ function parse(text, location = self?.location) { .pop(); } text = text.trim(); // Clear any spaces. - // Walkieware codes retain their stable identity when opened from the prompt or URL. - if (/^ww[a-z]{5,12}$/i.test(text)) text = "ww~" + text; + // Whistlegraph codes retain their stable identity when opened from the prompt or URL. + if (/^(?:wg|ww)[a-z]{5,12}$/i.test(text)) text = "ww~" + text; // 🚨 Special case for prompt~ slugs - ALWAYS route to prompt piece // This prevents prompt~(wipe blue) from being treated as kidlisp function call diff --git a/system/public/llms-full.txt b/system/public/llms-full.txt index d7f4e86c20..e49ad5e27b 100644 --- a/system/public/llms-full.txt +++ b/system/public/llms-full.txt @@ -587,7 +587,7 @@ prompt invocation. - [wobbo](https://aesthetic.computer/wobbo) ([source](https://github.com/whistlegraph/aesthetic-computer/blob/main/system/public/aesthetic.computer/disks/wobbo.mjs)): wobbo, 26.07.12 - [word](https://aesthetic.computer/word) ([source](https://github.com/whistlegraph/aesthetic-computer/blob/main/system/public/aesthetic.computer/disks/word.mjs)): Word, 23.02.11.13.38 - [words](https://aesthetic.computer/words) ([source](https://github.com/whistlegraph/aesthetic-computer/blob/main/system/public/aesthetic.computer/disks/words.mjs)): A Word Munchers-style educational word game. -- [ww](https://aesthetic.computer/ww) ([source](https://github.com/whistlegraph/aesthetic-computer/blob/main/system/public/aesthetic.computer/disks/ww.mjs)): Walkieware, 26.09.30 +- [ww](https://aesthetic.computer/ww) ([source](https://github.com/whistlegraph/aesthetic-computer/blob/main/system/public/aesthetic.computer/disks/ww.mjs)): Whistlegraph, 26.09.30 - [x](https://aesthetic.computer/x) ([source](https://github.com/whistlegraph/aesthetic-computer/blob/main/system/public/aesthetic.computer/disks/x.mjs)): x.mjs - The letter X - [y](https://aesthetic.computer/y) ([source](https://github.com/whistlegraph/aesthetic-computer/blob/main/system/public/aesthetic.computer/disks/y.mjs)): y.mjs - The letter Y - [z](https://aesthetic.computer/z) ([source](https://github.com/whistlegraph/aesthetic-computer/blob/main/system/public/aesthetic.computer/disks/z.mjs)): z.mjs - The letter Z diff --git a/system/tests/easel-braincells.test.mjs b/system/tests/easel-braincells.test.mjs index 4ede69fb64..0007cacae8 100644 --- a/system/tests/easel-braincells.test.mjs +++ b/system/tests/easel-braincells.test.mjs @@ -65,7 +65,7 @@ test('the six open models are allowed, flash is the default, and workspace turns test('a reasoning object passes through in the documented shapes only',()=>{ const messages=[{role:'user',content:'hi'}]; assert.equal(inferenceRequest({messages}).reasoning,null,'absent stays absent'); - assert.deepEqual(inferenceRequest({messages,reasoning:{effort:'none'}}).reasoning,{effort:'none'},'Walkieware turns thinking off'); + assert.deepEqual(inferenceRequest({messages,reasoning:{effort:'none'}}).reasoning,{effort:'none'},'Whistlegraph turns thinking off'); assert.deepEqual(inferenceRequest({messages,reasoning:{effort:'low',max_tokens:512,exclude:true,enabled:true,tone:'x'}}).reasoning,{effort:'low',max_tokens:512,enabled:true,exclude:true},'unknown keys are dropped'); assert.throws(()=>inferenceRequest({messages,reasoning:{effort:'maximum'}}),/reasoning\.effort/); assert.throws(()=>inferenceRequest({messages,reasoning:{max_tokens:-1}}),/reasoning\.max_tokens/); @@ -76,7 +76,7 @@ test('a reasoning object passes through in the documented shapes only',()=>{ test('Anthropic-style thinking passes through as disabled or a bounded budget',()=>{ const messages=[{role:'user',content:'hi'}]; assert.equal(inferenceRequest({messages}).thinking,null); - assert.deepEqual(inferenceRequest({messages,thinking:{type:'disabled',extra:1}}).thinking,{type:'disabled'},'Walkieware turns thinking off'); + assert.deepEqual(inferenceRequest({messages,thinking:{type:'disabled',extra:1}}).thinking,{type:'disabled'},'Whistlegraph turns thinking off'); assert.deepEqual(inferenceRequest({messages,thinking:{type:'enabled',budget_tokens:2048}}).thinking,{type:'enabled',budget_tokens:2048}); assert.throws(()=>inferenceRequest({messages,thinking:{type:'enabled',budget_tokens:10}}),/budget_tokens/); assert.throws(()=>inferenceRequest({messages,thinking:{type:'sometimes'}}),/thinking\.type/);