From 2387a29009d73721028f9c00d138901a982ec379 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sat, 10 Oct 2026 00:10:37 -0700 Subject: [PATCH] Whistlegraph turns: the phone gets the knot's steps and the model's words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worker keeps a step list — claimed by whom, asked which model, thinking, writing, each round's tokens, what it wrote, painted or not, checking the picture, the review's first finding, repairing, saving — and the model's latest sentence, and sends them with the heartbeat. The engine folds them into the snapshot as progress (phase, round, worker, start time, steps, said), for turns on the knot and, from its own phases, for turns run on the phone. --- apple/whistlegraph/Resources/Web/engine.mjs | 13 ++++++-- lith/whistlegraph-worker.mjs | 33 ++++++++++++--------- system/backend/whistlegraph-turns.mjs | 4 ++- 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/apple/whistlegraph/Resources/Web/engine.mjs b/apple/whistlegraph/Resources/Web/engine.mjs index 99920960f7..877b4bba12 100644 --- a/apple/whistlegraph/Resources/Web/engine.mjs +++ b/apple/whistlegraph/Resources/Web/engine.mjs @@ -128,7 +128,13 @@ function log(text) { $('live-events').replaceChildren(...events.map(text=>{const li=document.createElement('li');li.textContent=text;return li;})); } function threadUpdate(){nativeSnapshot();clearTimeout(threadTimer);threadTimer=setTimeout(()=>{try{if(lastAttempt)localStorage.setItem(storageKey+'-attempt',JSON.stringify(lastAttempt));}catch{}thread?.sync();thread?.update();},100);} -function phase(text) { $('live-phase').textContent = text; updateFeed(); threadUpdate(); } +let turnSteps=[],turnProgress=null; +function phase(text) { + $('live-phase').textContent = text; + // A turn's steps, kept for the phone's progress panel; a turn on the knot brings its own. + if((busy||remoteJob)&&!remoteJob&&text&&turnSteps.at(-1)?.text!==text)turnSteps.push({at:new Date().toISOString(),text}); + updateFeed(); threadUpdate(); +} function musicalData(request) { try{return JSON.parse(request.split('\nINPUT DATA:\n')[1]);}catch{return null;} } @@ -177,7 +183,7 @@ 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={ware:'piece',inference:inferenceSnapshot(),output:outputStream,caption,code:thread?.identity.code||'',handle:accountHandle,colors:accountPalette,head:displayVersion?.id||0,hasPiece:!!source.trim(),hasPreview:!!source.trim()||!!provisional.trim(),busy:busy||!!remoteJob,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,draft:(d=>d?{request:d.request.slice(0,1000),error:d.error||'',createdAt:d.createdAt||'',characters:d.source.length}:null)(readDraft())}; + const snapshot={ware:'piece',inference:inferenceSnapshot(),output:outputStream,caption,code:thread?.identity.code||'',handle:accountHandle,colors:accountPalette,head:displayVersion?.id||0,hasPiece:!!source.trim(),hasPreview:!!source.trim()||!!provisional.trim(),busy:busy||!!remoteJob,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,progress:(busy||remoteJob)?(remoteJob?turnProgress:{phase:phaseText,round:0,worker:'this phone',startedAt:lastAttempt?.startedAt||'',steps:turnSteps.slice(-16),said:''}):null,draft:(d=>d?{request:d.request.slice(0,1000),error:d.error||'',createdAt:d.createdAt||'',characters:d.source.length}:null)(readDraft())}; const serialized=JSON.stringify(snapshot);if(serialized===nativeLast&&!historyChanged)return;nativeLast=serialized; if(historyChanged)snapshot.versions=nativeRevisions; post({action:'snapshot',snapshot}); @@ -450,7 +456,7 @@ async function ask(text,displayText=text,advice=null,starter=null,localText=text // and the piece follows the ledger. A recovered checkpoint still finishes here. if(remoteTurns()&&!recovered?.checkpoint&&thread?.identity.code){await submitRemoteTurn({text,displayText,drawing:inputData(text)?.drawing||null});return;} let noChange=false; - validationChecks=[];runtimeErrors.length=0;turnNotes=[]; + validationChecks=[];runtimeErrors.length=0;turnNotes=[];turnSteps=[{at:new Date().toISOString(),text:'started here'}];turnProgress=null; try{ await accountVerification;if(turnCancelled)throw Error('Stopped');turnHandle=accountToken===token?accountHandle:'';turnPersonalAccess=!!turnHandle&&hasPersonalAccess(personalAccess);turnModel=selectedModel(turnHandle); activeReceipt=new AttemptReceipt({requestID:activeAttempt.id,parent:turnParent,parentHash:await hashSource(previous),path:'compiled',model:window.__whistlegraphModel||profile().model,journal:receipts}); @@ -678,6 +684,7 @@ async function watchRemoteTurn(job){ if(state.status==='queued'||state.status==='running'){ // The knot's phase and the code it is writing, shown as if the turn ran here. const p=state.progress; + turnProgress={phase:state.status==='queued'?'Waiting for a worker…':(p?.phase||'Working on the knot…'),round:p?.round||0,worker:state.claimedBy||p?.worker||'',startedAt:state.claimedAt||state.createdAt||'',steps:[{at:state.createdAt||'',text:'queued on the knot'},...(p?.steps||[])].slice(-16),said:p?.said||''}; if(state.status==='queued')phase('Waiting for a worker…'); else if(p?.phase){phase(p.phase+(p.round>1?' · round '+p.round:''));if(p.tail){outputStream=streamedCode(p.tail,'write_piece').slice(-6000);$('live-code').textContent=outputStream;$('live-details').open=true;}} nativeSnapshot(); diff --git a/lith/whistlegraph-worker.mjs b/lith/whistlegraph-worker.mjs index 8152b9e6f1..38eaf9fd3e 100644 --- a/lith/whistlegraph-worker.mjs +++ b/lith/whistlegraph-worker.mjs @@ -62,18 +62,21 @@ export async function runTurn({job, thread, pool, onCheckpoint = () => {}, onPro const settingsFor = repair => generationProfile('', {repair, model: job.model || '', image: !!job.request.drawing}); let completed = false, error = '', server = null, checkpointAt = 0, renders = 0, lastPainted = null, repairs = 0, feedback = null, lastProof = null, round = 0, progressAt = 0; const checks = [], notes = []; - let tail = '', phaseNow = 'Waiting for model…'; - const progress = (phase, {force = false} = {}) => { phaseNow = phase; const now = Date.now(); if (force || now - progressAt > 2500) { progressAt = now; void onProgress({phase, round, streamed: tail.length, tail: tail.slice(-3000)}); } }; + let tail = '', phaseNow = 'Waiting for model…', said = '', steps = []; + const step = text => { if (steps.at(-1)?.text !== text) steps.push({at: new Date().toISOString(), text: String(text).slice(0, 160)}); }; + step('claimed by ' + WORKER); + const progress = (phase, {force = false} = {}) => { phaseNow = phase; const now = Date.now(); if (force || now - progressAt > 2500) { progressAt = now; void onProgress({phase, round, streamed: tail.length, tail: tail.slice(-3000), steps: steps.slice(-16), said}); } }; // Every write is painted at once and the result handed back to the model // as runtime feedback, as the phone does: the edit contract tells it to // inspect ac_preview, and without that it rewrote one piece twelve rounds // running (85,000 characters) until the round cap stopped it, 2026-10-10. const paint = async () => { if (!pool) return null; - progress('Painting…', {force: true}); + step(`wrote ${source.length.toLocaleString('en-US')} chars`); progress('Painting…', {force: true}); const proof = await pool.render({source, renderID: ++renders}); proof.logs = proof.logs.map(l => ({...l, level: runtimeLevel(l)})); lastProof = proof; + step(proof.rendered ? 'painted' + (proof.logs.some(l => l.level === 'error') ? ' with runtime errors' : '') : 'did not paint' + (proof.error ? ': ' + proof.error : '')); feedback = {rendered: proof.rendered, sourceHash: proof.sourceHash, revision: proof.sourceHash, requestID: proof.renderID, logs: proof.logs, updatedAt: new Date().toISOString()}; if (proof.rendered && sourceChecks(source).length === 0) lastPainted = {source, proof}; return proof; @@ -86,17 +89,17 @@ export async function runTurn({job, thread, pool, onCheckpoint = () => {}, onPro developerInstructions: GENERATION_INSTRUCTIONS + '\n' + WARE_INSTRUCTIONS}); server.runtimeFeedback = () => feedback; completed = false; error = ''; - let streamed = 0, lastNote = Date.now(), said = ''; - tail = ''; progress(repair ? 'Repairing…' : 'Waiting for model…', {force: true}); + let streamed = 0, lastNote = Date.now(), thinking = false, writing = false, saying = ''; + tail = ''; said = ''; step(repair ? 'repair round started' : 'asked the model (' + settings.model.split('/').pop() + ')'); progress(repair ? 'Repairing…' : 'Waiting for model…', {force: true}); server.on('notification', ({method, params}) => { if (method === 'turn/completed') { completed = !params.turn.error && params.turn.status === 'completed'; error = params.turn.error ? String(params.turn.error.message || params.turn.error) : ''; } - if (method === 'item/reasoning/delta') progress('Thinking…'); - if (method === 'item/modelCode/delta') { streamed += params.delta?.length || 0; tail = (tail + (params.delta || '')).slice(-6000); progress(repair ? 'Repairing…' : 'Writing…'); if (Date.now() - lastNote > 20000) { lastNote = Date.now(); log('streaming', job.code, streamed, 'chars', repair ? '(repair)' : ''); } } + if (method === 'item/reasoning/delta') { if (!thinking) { thinking = true; step('thinking'); } progress('Thinking…'); } + if (method === 'item/modelCode/delta') { if (!writing) { writing = true; step(repair ? 'rewriting' : 'writing'); } streamed += params.delta?.length || 0; tail = (tail + (params.delta || '')).slice(-6000); progress(repair ? 'Repairing…' : 'Writing…'); if (Date.now() - lastNote > 20000) { lastNote = Date.now(); log('streaming', job.code, streamed, 'chars', repair ? '(repair)' : ''); } } if (method === 'turn/started') round += 1; - if (method === 'turn/usage') log('usage', job.code, params.usage?.output_tokens ?? 0, 'output tokens'); + if (method === 'turn/usage') { log('usage', job.code, params.usage?.output_tokens ?? 0, 'output tokens'); step(`round ${round}: ${(params.usage?.output_tokens ?? 0).toLocaleString('en-US')} tokens out`); thinking = false; writing = false; } if (method === 'item/started') log('tool', job.code, params.item?.tool || params.item?.type || '?'); - if (method === 'item/agentMessage/delta') { said += params.delta || ''; } - if (method === 'turn/completed') { if (said.trim()) log('model said', job.code, JSON.stringify(said.trim().slice(0, 400))); said = ''; } + if (method === 'item/agentMessage/delta') { saying += params.delta || ''; said = saying.trim().slice(-280); progress(phaseNow); } + if (method === 'turn/completed') { if (saying.trim()) { log('model said', job.code, JSON.stringify(saying.trim().slice(0, 400))); step('model: ' + saying.trim().slice(0, 140)); } saying = ''; } }); signal?.addEventListener('abort', () => server?.interrupt(), {once: true}); await server.startTurn(chalk ? [{type: 'text', text: task}, chalk] : task); @@ -136,24 +139,26 @@ export async function runTurn({job, thread, pool, onCheckpoint = () => {}, onPro request: inferenceRequest(job.request.text), history: selectedBranch(thread.ledger), drawing: chalk, model: settingsFor(false).model, token: 'worker', fetch: workerFetch(job.owner, settingsFor(false)), signal}); let acceptance = 'unreviewed'; try { - progress('Checking picture…', {force: true}); + step('checking the picture'); progress('Checking picture…', {force: true}); let verdict = await review(candidate, proof); checks.push(verdict.passed ? 'visual-pass' : 'visual-fail'); log('picture review', job.code, verdict.passed ? 'passed' : 'findings: ' + verdict.findings.join('; ').slice(0, 200)); + step(verdict.passed ? 'picture passed' : 'review: ' + (verdict.findings[0] || 'findings').slice(0, 140)); if (!verdict.passed && repairs === 0 && candidate === source) { - repairs = 1; log('repairing picture', job.code); progress('Repairing picture…', {force: true}); + repairs = 1; log('repairing picture', job.code); step('repairing the picture'); progress('Repairing picture…', {force: true}); const before = {source: candidate, proof}; const ok = await generate(prompt + REPAIR_NOTE(verdict.findings), true); const again = ok ? await inspect() : null; - if (again?.passed) { candidate = source; proof = again.proof; progress('Checking picture…', {force: true}); verdict = await review(candidate, proof); checks.push(verdict.passed ? 'visual-pass' : 'visual-fail'); } + if (again?.passed) { candidate = source; proof = again.proof; step('checking the picture again'); progress('Checking picture…', {force: true}); verdict = await review(candidate, proof); checks.push(verdict.passed ? 'visual-pass' : 'visual-fail'); step(verdict.passed ? 'picture passed' : 'review: ' + (verdict.findings[0] || 'findings').slice(0, 140)); } else { candidate = before.source; proof = before.proof; notes.push('The repair did not paint; kept the picture before it.'); } } acceptance = verdict.passed ? 'reviewed' : 'reviewed-with-notes'; if (!verdict.passed) notes.push(...verdict.findings.slice(0, 4)); } catch (reviewError) { if (signal?.aborted) throw reviewError; - checks.push('visual-unreviewed'); notes.push('Review unavailable: ' + String(reviewError.message || reviewError).slice(0, 160)); + checks.push('visual-unreviewed'); notes.push('Review unavailable: ' + String(reviewError.message || reviewError).slice(0, 160)); step('review unavailable; keeping it unreviewed'); log('picture review unavailable', job.code, reviewError.message); } + step('saving'); progress('Saving…', {force: true}); return {source: candidate, findings, repairs, notes, acceptance, checks}; } finally { rmSync(cwd, {recursive: true, force: true}); } } diff --git a/system/backend/whistlegraph-turns.mjs b/system/backend/whistlegraph-turns.mjs index 49e63fb5de..45ae517110 100644 --- a/system/backend/whistlegraph-turns.mjs +++ b/system/backend/whistlegraph-turns.mjs @@ -93,7 +93,9 @@ export function mongoTurnQueue(collection, {now = () => new Date()} = {}) { const update = {$set: {heartbeatAt: now().toISOString()}}; if (typeof checkpoint === 'string') update.$set.checkpoint = checkpoint; // What the phone shows while it waits: the phase, the round, the tail of the code being written. - if (progress && typeof progress === 'object') update.$set.progress = {phase: String(progress.phase || '').slice(0, 80), round: Number(progress.round) || 0, streamed: Number(progress.streamed) || 0, tail: String(progress.tail || '').slice(-3000), at: now().toISOString()}; + if (progress && typeof progress === 'object') update.$set.progress = {phase: String(progress.phase || '').slice(0, 80), round: Number(progress.round) || 0, streamed: Number(progress.streamed) || 0, tail: String(progress.tail || '').slice(-3000), + steps: (Array.isArray(progress.steps) ? progress.steps : []).slice(-16).map(s => ({at: String(s.at || ''), text: String(s.text || '').slice(0, 160)})), + said: String(progress.said || '').slice(0, 280), worker, at: now().toISOString()}; const row = await collection.findOneAndUpdate({_id: id, status: 'running', claimedBy: worker}, update, {returnDocument: 'after'}); if (!row) return false; return row.cancelRequested ? 'cancel' : true; -- 2.54.0