From c139ba9f877df4bd8fc217f50f2f15408bfc696a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sat, 10 Oct 2026 01:06:02 -0700 Subject: [PATCH] Whistlegraph: pieces made on the knot show in Your pieces and open on the phone A thread made elsewhere under the same handle (a turn run from a stand-in, another phone) never reached the phone: the list was the phone's own store and a push's code matched nothing. The listing route now carries a summary per thread (head, version count, last words) instead of a ledgerless row; the engine lists knot threads beside local ones, marked remote, and opens one by fetching the thread and mounting it whole, the open piece parked first. Opening by code works too, so a tapped push opens a piece that is not yet on the phone. --- aesel/src/whistlegraph-thread.mjs | 2 +- apple/whistlegraph/Resources/Web/engine.mjs | 41 +++++++++++++++++-- .../Sources/WhistlegraphApp.swift | 5 ++- .../Sources/WhistlegraphHeader.swift | 11 +++-- system/backend/whistlegraph.mjs | 10 ++++- system/netlify/functions/whistlegraph.mjs | 4 +- 6 files changed, 61 insertions(+), 12 deletions(-) diff --git a/aesel/src/whistlegraph-thread.mjs b/aesel/src/whistlegraph-thread.mjs index 8e3bb3fc38..42025c234a 100644 --- a/aesel/src/whistlegraph-thread.mjs +++ b/aesel/src/whistlegraph-thread.mjs @@ -1,5 +1,5 @@ // 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 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)); diff --git a/apple/whistlegraph/Resources/Web/engine.mjs b/apple/whistlegraph/Resources/Web/engine.mjs index 27abd38c33..e0b65b6df5 100644 --- a/apple/whistlegraph/Resources/Web/engine.mjs +++ b/apple/whistlegraph/Resources/Web/engine.mjs @@ -16,7 +16,7 @@ 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 {WhistlegraphThread,verifyThreadRevision} from '/easel/src/whistlegraph-thread.mjs'; +import {WhistlegraphThread,verifyThreadRevision,ledgerText} 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'; @@ -70,6 +70,16 @@ let turnHandle='',turnModel='',activeModel='',braincells=null,braincellsError='' // until the wallet grows past that or a turn goes through, so the icon says // so instead of a row that fails quietly each time. let braincellsEmptyAt=null; +// Pieces this handle made on the knot that are not on this phone: a turn run +// from elsewhere, another phone, a stand-in. Listed beside the local ones and +// opened by fetching the thread. +let knotPieces=[]; +async function refreshKnotPieces(){ + if(!token){knotPieces=[];postPieces?.();return;} + try{const r=await fetch('https://aesthetic.computer/api/whistlegraph',{headers:{Authorization:'Bearer '+token},signal:AbortSignal.timeout(10000)});if(!r.ok)return;const value=await r.json();if(Array.isArray(value.threads))knotPieces=value.threads.filter(t=>t&&typeof t.id==='string'&&typeof t.code==='string');} + catch{return;} + postPieces?.(); +} function noteBraincells(message){if(/out of braincells/i.test(String(message||'')))braincellsEmptyAt=braincells&&braincells.unlimited!==true?braincells.remaining+braincells.purchased:0;} function braincellsEmpty(){return braincellsEmptyAt!==null||(!!braincells&&braincells.unlimited!==true&&braincells.remaining+braincells.purchased<=0);} function selectedModel(handle=accountHandle){try{return localStorage.getItem('whistlegraph-model-'+handle)||'';}catch{return '';}} @@ -108,7 +118,7 @@ function accountIdentity(value, notice='', force=false){ if(!account||accountToken!==value)return; accountHandle=account.handle;syncAIConsent(); if(!accountHandle){signIn.textContent='Set handle';nativeSnapshot();return;} - paintHandle(accountHandle);void refreshBraincells(); + paintHandle(accountHandle);void refreshBraincells();void refreshKnotPieces(); const handle=accountHandle,revision=accountConnection.revision; void fetchPersonalAccess(value).then(access=>{if(accountToken===value&&accountConnection.revision===revision){personalAccess=access;nativeSnapshot();}}); void fetchHandleColors('@'+handle).then(colors=>{if(accountToken===value&&accountConnection.revision===revision)paintHandle(handle,colors);}).catch(()=>{}); @@ -958,13 +968,38 @@ if(versions&&!window.__whistlegraphSequence&&!window.__whistlegraphBenchmark&&!w const key=localStorage.key(i);if(!key?.startsWith(ARCHIVE))continue; try{const saved=JSON.parse(localStorage.getItem(key));if(saved?.identity?.id&&saved.identity.id!==thread.identity.id)list.push(pieceSummary(saved.identity.id,saved.identity,saved.ledger,false));}catch{} } + for(const t of knotPieces){ + if(list.some(p=>p.id===t.id||(p.code&&p.code===t.code)))continue; + list.push({id:t.id,code:t.code,utterance:String(t.utterance||'').slice(0,160),versions:Number(t.versions)||0,updatedAt:t.updatedAt||'',current:false,remote:true}); + } return list.sort((a,b)=>(b.current-a.current)||(Date.parse(b.updatedAt)||0)-(Date.parse(a.updatedAt)||0)).slice(0,256); } + // A piece from the knot lands on this phone the way a turn's result does: + // the thread is fetched and mounted whole, the open piece parked first. + async function openKnotPiece(t){ + let cloud; + try{const r=await fetch('https://aesthetic.computer/api/whistlegraph?code='+encodeURIComponent(t.code),{headers:{Authorization:'Bearer '+token},signal:AbortSignal.timeout(15000)});cloud=await r.json();} + catch{phase('Could not reach the knot for that piece');return;} + if(!cloud?.ledger?.versions||cloud.id!==t.id){phase('That piece is not on the knot any more');void refreshKnotPieces();return;} + const head=cloud.ledger.versions.find(v=>v.id===cloud.ledger.head); + const parked=archiveCurrentPiece();if(parked.accepted===false)return; + try{ + localStorage.setItem(storageKey,head?.source||''); + localStorage.setItem(storageKey+'-versions',JSON.stringify(cloud.ledger)); + localStorage.setItem(storageKey+'-thread',JSON.stringify({id:cloud.id,code:cloud.code})); + localStorage.setItem(storageKey+'-cloud-revision',String(cloud.revision)); + localStorage.setItem(storageKey+'-cloud-ledger',ledgerText(cloud.ledger)); + }catch{phase('This phone has no room to open that piece');location.reload();return;} + location.reload(); + } postPieces=()=>post({action:'pieces',pieces:pieceList()}); window.whistlegraphVersionSource=id=>versions?.value.versions.find(v=>v.id===id)?.source??null; window.whistlegraphOpenPiece=id=>{ - if(busy||id===thread.identity.id)return {accepted:false,reason:'busy'}; + if(busy||id===thread.identity.id||id===thread.identity.code)return {accepted:false,reason:'busy'}; let saved;try{saved=JSON.parse(localStorage.getItem(ARCHIVE+id));}catch{} + // A push names the piece by code; so may a knot piece that is not on this phone. + if(!saved){for(let i=0;ik.id===id||k.code===id);if(t){phase('Fetching /'+t.code+' from the knot…');void openKnotPiece(t);return {accepted:true};}} if(!saved?.identity?.id||!saved.ledger){phase('That piece is no longer on this phone');postPieces();return {accepted:false,reason:'notReady'};} const parked=archiveCurrentPiece();if(parked.accepted===false)return parked; try{ diff --git a/apple/whistlegraph/Sources/WhistlegraphApp.swift b/apple/whistlegraph/Sources/WhistlegraphApp.swift index 76850bdc3e..d98423cc7d 100644 --- a/apple/whistlegraph/Sources/WhistlegraphApp.swift +++ b/apple/whistlegraph/Sources/WhistlegraphApp.swift @@ -547,8 +547,9 @@ final class WhistlegraphSession: NSObject, ObservableObject, WKScriptMessageHand func openFromNotification(_ url: String) { let piece = String(url.drop(while: { $0 == "/" }).prefix(64)) DeviceActionLog.shared.record(.notifications, .presented) - guard !piece.isEmpty, pieces.contains(where: { $0.id == piece }) else { return } - if pieces.contains(where: { $0.id == piece && $0.current }) { return } + // The push names the piece by code. One not on this phone is fetched from the knot. + guard !piece.isEmpty else { return } + if pieces.contains(where: { ($0.id == piece || $0.code == piece) && $0.current }) { return } command("openPiece", piece: piece) } /// Drops the Keychain sign-in and tells the engine, which parks its sockets. diff --git a/apple/whistlegraph/Sources/WhistlegraphHeader.swift b/apple/whistlegraph/Sources/WhistlegraphHeader.swift index 7bc4e8d5e7..c9e42d438f 100644 --- a/apple/whistlegraph/Sources/WhistlegraphHeader.swift +++ b/apple/whistlegraph/Sources/WhistlegraphHeader.swift @@ -10,6 +10,8 @@ struct PieceSummary: Decodable, Identifiable { let versions: Int let updatedAt: String let current: Bool + /// Made on the knot and not yet on this phone; opening fetches it. + var remote: Bool? = nil var date: Date? { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -81,13 +83,15 @@ struct PiecesSheet: View { Text(detail(piece)).font(.footnote).foregroundStyle(.secondary) }.frame(maxWidth: .infinity, alignment: .trailing) if piece.current { Image(systemName: "checkmark").foregroundStyle(.secondary).accessibilityLabel("Open now") } + else if piece.remote == true { Image(systemName: "icloud.and.arrow.down").foregroundStyle(.secondary).accessibilityLabel("On the knot") } } } .disabled(disabled && !piece.current) .accessibilityIdentifier("piece-" + piece.title) // Deleting frees this phone's 5 MB piece store; the open piece stays. .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if !piece.current { + // A knot piece is not in this phone's store; nothing here to delete. + if !piece.current && piece.remote != true { Button(role: .destructive) { ButtonSounds.play(.pop); delete(piece.id) } label: { Label("Delete", systemImage: "trash") } .disabled(disabled).accessibilityIdentifier("piece-delete-" + piece.title) } @@ -105,8 +109,9 @@ struct PiecesSheet: View { } private func detail(_ piece: PieceSummary) -> String { let versions = piece.versions == 1 ? "1 version" : "\(piece.versions) versions" - guard let date = piece.date else { return versions } - return versions + " · " + date.formatted(.relative(presentation: .named)) + let where_ = piece.remote == true ? "on the knot · " : "" + guard let date = piece.date else { return where_ + versions } + return where_ + versions + " · " + date.formatted(.relative(presentation: .named)) } } diff --git a/system/backend/whistlegraph.mjs b/system/backend/whistlegraph.mjs index 5ac9fbd744..524614a8dc 100644 --- a/system/backend/whistlegraph.mjs +++ b/system/backend/whistlegraph.mjs @@ -44,7 +44,9 @@ export function mongoWhistlegraphStore(collection, {name=pronounceableCode}={}) 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,receipts:0}}).sort({updatedAt:-1}).limit(100).toArray();}, + // A listing carries each thread's shape, not its ledger: the head, how + // many versions were made, and the words of the last one. + async list(owner) {return collection.find({owner},{projection:{code:1,revision:1,updatedAt:1,published:1,head:'$ledger.head',versions:{$subtract:[{$size:{$ifNull:['$ledger.versions',[]]}},1]},lastRequest:{$arrayElemAt:['$ledger.versions.request',-1]},lastAt:{$arrayElemAt:['$ledger.versions.createdAt',-1]}}}).sort({updatedAt:-1}).limit(100).toArray();}, async receipt(owner,id,value) { const receipt=validateReceipt(value); // First write wins; retries are idempotent within the retained history. @@ -95,6 +97,12 @@ export function mongoWhistlegraphStore(collection, {name=pronounceableCode}={}) } }; } +// One row of a listing, as the phone's piece list shows it. +export function threadSummary(row) { + if(!row)return null; + const versions=Math.max(0,Number(row.versions)||0); + return {id:row._id,code:row.code,revision:row.revision,updatedAt:row.lastAt||row.updatedAt,head:row.head??0,versions,utterance:versions>0&&row.lastRequest?String(captionOf(row.lastRequest)).slice(0,160):'',published:row.published===true}; +} export function publicThread(row) { if(!row)return null; const head=row.ledger?.versions.find(v=>v.id===row.ledger.head); diff --git a/system/netlify/functions/whistlegraph.mjs b/system/netlify/functions/whistlegraph.mjs index d016d7cdfb..fca134905e 100644 --- a/system/netlify/functions/whistlegraph.mjs +++ b/system/netlify/functions/whistlegraph.mjs @@ -1,6 +1,6 @@ import {connect} from '../../backend/database.mjs'; import {authenticateMusical} from './easel-musical-jev.mjs'; -import {mongoWhistlegraphStore,publicThread} from '../../backend/whistlegraph.mjs'; +import {mongoWhistlegraphStore,publicThread,threadSummary} 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;}); @@ -17,7 +17,7 @@ export async function handler(event) { if(!code||event.queryStringParameters?.receipts!=='1')return reply(400,{error:'Specify a thread code and receipts=1'}); return await store.clearReceipts(owner,code)?reply(200,{cleared:true}):reply(404,{error:'Thread unavailable'}); } - if(!code)return reply(200,{threads:(await store.list(owner)).map(publicThread)}); + if(!code)return reply(200,{threads:(await store.list(owner)).map(threadSummary)}); 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'});} -- 2.51.2