Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847import {verifyAccount, requireHandle} from "/easel/src/account-access.mjs";// The headless half of a phone session: everything that is not a renderer.//// `app.mjs` draws the browser UI and `apple/aesel` draws a SwiftUI one. Both// want the same things to happen — mount a piece, run a turn through the shared// bridge, publish what the model writes, report progress — so that lives here// and the hosts only render.//// This is the same division Oskiewar makes. Its engine emits a triangle stream// and canvas, D3D and Metal each draw it; `MetalSceneView.swift` says so in its// header. Aesel's equivalent of that stream already existed before anyone// needed a second renderer: `AcServer` emits six notification kinds, and the// desktop TUI is just one thing that reads them. A SwiftUI view is another.//// Nothing here touches the DOM, so it runs the same in a visible page and in// the off-screen WKWebView the iOS app keeps for exactly this purpose.
import { AcServer, DEFAULT_AC_MODEL } from "/easel/src/ac-server.mjs";import { fetchHandleColors, handleCharacterColors } from "/easel/src/handle-colors.mjs";import { isTransientNetworkError, withNetworkDeadline, httpError } from "/easel/src/network.mjs";import { publishPiece } from "/easel/src/publish.mjs";import * as vfs from "/easel/phone/shim/fs.mjs";import { createCredits } from "./credits.mjs";import {revisionSummary} from "/easel/src/revision-summary.mjs";import {migrateRevisionCheckpoints} from "./revision-history.mjs";import {validatePieceSource} from "./shim/revisions.mjs";import { NativeProvider } from "./native-provider.mjs";
export const SITE = "https://aesthetic.computer";export const AUTH_DOMAIN = "hi.aesthetic.computer";export const MODEL_CHOICES = []; // Braincell routing is managed by AC.
// Fetched rather than bundled, at the paths the bridge's own `bundledContext()`// builds, so editing a guide reaches the phone on reload.const GUIDES = [ "/easel/context/pieces.md", "/easel/context/screen.md", "/easel/context/hand.md", "/easel/context/kidlisp.md", "/easel/context/api.json",];
const STARTER = `// A new piece.export function paint({ wipe }) { wipe("purple");}`;
// A slug a person can say out loud.export function freshSlug() { const vowels = "aeiou"; const consonants = "bcdfghjklmnprstvwz"; let out = ""; for (let i = 0; i < 3; i += 1) { out += consonants[Math.floor(Math.random() * consonants.length)]; out += vowels[Math.floor(Math.random() * vowels.length)]; } return out;}
// `publish.mjs` sets a `User-Agent` header, which is right for Node and wrong// in a webview: it is a forbidden header name, so a browser is supposed to drop// it. Chrome does, silently — the browser client publishes fine. WebKit leaks// it into the CORS preflight instead, and `/presigned-upload-url` answers// `Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With`// with no `user-agent`, so the preflight is refused and the whole publish comes// back as the uninformative "Load failed".//// Stripping it here rather than in `publish.mjs` keeps the shared file correct// for the desktop, which genuinely wants to identify itself.const FORBIDDEN = ["user-agent"];
function browserFetch(input, init = {}) { if (!init.headers) return fetch(input, init); const headers = {}; for (const [name, value] of Object.entries(init.headers)) { if (FORBIDDEN.includes(name.toLowerCase())) continue; headers[name] = value; } return fetch(input, { ...init, headers });}
// Storage is injected because the two hosts keep a session in different places// — localStorage in a browser, the app's Documents container on iOS — and that// is the only thing about persistence they disagree on.const memoryStore = () => { const map = new Map(); return { get: (key) => map.get(key) ?? null, set: (key, value) => void map.set(key, value), };};
export function createSession({ storage = memoryStore(), emit = () => {}, hostRPC = null, renewToken = null, retryOptions = {}, accountFetch = (...args) => fetch(...args) } = {}) { const state = { accountVerified: false, token: "", handle: "", slug: "", file: "", server: null, busy: false, publishing: null, dirty: false, published: false, id: "", medium: "piece", transcript: [], engine: null, title: "", owner: "", model: DEFAULT_AC_MODEL, composer: "", provider: "ac", providerModels: {}, hostOperation: null, revisions: [], selectedRevision: null, publication: null, autoPublish: true, }; Object.defineProperty(state,"version",{enumerable:true,get:()=>state.revisions.at(-1)?.version ?? 0}); let hostProviders = []; let mcpAutoAllow = true, supportsApprovalPolicy = false; let accountEpoch = 0; const credits = createCredits({ token: () => state.token, emit, site: SITE });
const read = () => { try { return JSON.parse(storage.get("session") || "{}"); } catch { return {}; } };
const write = (patch) => { try { storage.set("session", JSON.stringify({ ...read(), ...patch })); } catch { // A full or unavailable store should cost persistence, not the session. } };
// Credentials stay in the separate account record, never in a thread. const readThreads = () => { try { const value = JSON.parse(storage.get("threads") || "{}"); return value.schema === 1 && Array.isArray(value.items) ? value.items : []; } catch { return []; } }; let saveTimer; let pendingRevision = null; const say = (type, payload = {}) => { const event = { type, ...payload }; const ephemeral = type === "bridge" && ["turn/progress", "item/modelCode/delta"].includes(payload.method); if (state.id && !ephemeral && ["you", "note", "bad", "bridge"].includes(type)) { const last = state.transcript.at(-1); if (type === "bridge" && payload.method === "item/agentMessage/delta" && last?.method === payload.method && !state.revisions.some(r=>r.transcriptEnd === state.transcript.length)) { last.params.delta += payload.params?.delta || ""; } else { state.transcript.push(JSON.parse(JSON.stringify(event))); } if (type === "bridge" && payload.method === "turn/completed" && pendingRevision !== null) { const revision = state.revisions.find(r=>r.version === pendingRevision); if (revision) revision.transcriptEnd = state.transcript.length; pendingRevision = null; } clearTimeout(saveTimer); saveTimer = setTimeout(saveCurrent, 250); } emit(event); };
function history() { return readThreads().sort((a, b) => b.savedAt.localeCompare(a.savedAt)).map(item => ({ id: item.id, title: item.title || item.slug, medium: item.medium, route: item.handle ? `@${item.handle}/${item.slug}` : item.slug, updatedAt: item.savedAt, })); }
function saveCurrent() { clearTimeout(saveTimer); if (!state.id || !state.file) return; const engine = state.server && state.provider === "ac" ? { threadId: state.server.threadId || "", messages: state.server.messages || [], turns: state.server.turns || 0, model: state.model, } : state.engine; const item = { id: state.id, title: state.title || state.slug, medium: state.medium, model: state.model, savedAt: new Date().toISOString(), handle: state.owner || state.handle, slug: state.slug, source: vfs.readFileSync(state.file), published: state.published, version:state.version, events: state.transcript, engine, composer: state.composer, revisionSchema: 2, revisions: state.revisions, publication: state.publication, autoPublish: state.autoPublish, selectedRevision: state.selectedRevision, provider: state.provider, providerModels: state.providerModels, hostOperation: state.hostOperation, }; const items = readThreads().filter(entry => entry.id !== state.id); items.push(item); try { const encoded = JSON.stringify({schema: 1, items}); if (encoded.length > 32 * 1024 * 1024) throw new Error("Saved threads exceed 32 MB."); storage.set("threads", encoded); write({ threadID: state.id }); emit({ type: "history", items: history() }); } catch (error) { emit({type: "bad", text: `Could not save this thread: ${error.message}`}); } }
async function settleCurrent() { if (state.hostOperation && !state.busy) throw new Error("Reconnect to resolve the current host turn before changing threads."); stop(); const deadline = Date.now() + 15000; while (state.busy || state.publishing) { if (Date.now() > deadline) throw new Error("Wait for the current turn or upload to finish before switching threads."); await new Promise(resolve => setTimeout(resolve, 50)); } saveCurrent(); }
function loadThread(item) { state.selectedRevision = null; pendingRevision = null; state.id = item.id; state.owner = item.handle || state.handle; state.medium = item.medium || "piece"; state.title = item.title || item.slug; state.transcript = Array.isArray(item.events) ? item.events : []; state.engine = item.engine || null; state.provider = ["ac", "claude", "codex"].includes(item.provider) ? item.provider : "ac"; state.providerModels = item.providerModels || {}; state.hostOperation = item.hostOperation || null; state.model = state.provider === "ac" ? DEFAULT_AC_MODEL : item.model || ""; state.composer = typeof item.composer === "string" ? item.composer : ""; state.revisions = Array.isArray(item.revisions) ? item.revisions : []; state.publication = item.publication || null; if(item.revisionSchema !== 2 && state.revisions.length) { state.revisions=state.revisions.map(record=>({...record,version:Math.max(0,record.version-1)})); if(state.publication)state.publication={...state.publication,version:Math.max(0,state.publication.version-1)}; } if(!state.revisions.length && Number.isInteger(item.version) && item.version>=0) { state.revisions=[{version:item.version,source:item.source || STARTER,reason:"opened",summary:item.version===0?"First version.":"Saved version.",at:item.savedAt || new Date().toISOString()}]; } state.revisions = migrateRevisionCheckpoints(state.revisions, state.transcript); state.autoPublish = item.autoPublish !== false; mountPiece(item.slug, item.source || STARTER); recordRevision(item.source || STARTER, "opened"); state.published = Boolean(item.published && (!item.handle || item.handle === state.handle)); write({ threadID: state.id, published: state.published }); emit({type: "thread", id: state.id, medium: state.medium, events: state.transcript, composer: state.composer}); reportProvider(); reportRevisions(); if (state.published && state.handle) say("preview", {url: pieceUrl()}); else if (item.source && item.source !== STARTER) say("source", {source: item.source}); say("status", {text: state.token ? "ready" : "signed out", kind: "idle"}); if (Number.isInteger(item.selectedRevision) && state.revisions.some(r=>r.version === item.selectedRevision) && !state.hostOperation) selectRevision(item.selectedRevision); }
async function resumeSession(id) { let item = readThreads().find(entry => entry.id === id); if (!item) throw new Error("That saved thread could not be found."); if (item.medium !== "piece") throw new Error("This medium is not supported on iPhone yet."); await settleCurrent(); item = readThreads().find(entry => entry.id === id); loadThread(item); emit({type: "history", items: history()}); await prepublishBlank(); }
let blankPublication = null; function prepublishBlank() { if (!state.accountVerified || !state.token || !state.handle || !state.id || !state.autoPublish || state.published || state.selectedRevision !== null || vfs.readFileSync(state.file) !== STARTER) return Promise.resolve(); // Account restoration and opening a notebook can finish in either order. // Coalesce both paths, including verification before the upload starts. if (!blankPublication) { blankPublication = publish().finally(() => { blankPublication = null; }); } return blankPublication; }
async function newSession(medium = "piece") { if (medium !== "piece") throw new Error("This medium is not supported on iPhone yet."); await settleCurrent(); const slug = freshSlug(); loadThread({id: `${Date.now()}-${slug}`, slug, medium, source: STARTER, events: [], published: false}); saveCurrent(); await prepublishBlank(); }
function setDraft(text, threadID = state.id) { requireCurrentRevision(); if (!threadID || threadID !== state.id) throw new Error("The draft belongs to another thread."); if (typeof text !== "string" || new TextEncoder().encode(text).length > 32768) throw new Error("Drafts are limited to 32 KB."); if (state.composer === text) return; state.composer = text; saveCurrent(); }
function exportNotebook() { const notebook={format:"aesel-notebook",schema:1,revisionStart:0,medium:"piece",title:state.title,source:vfs.readFileSync(state.file), composer:state.composer,revisions:state.revisions.map(({version,source,reason,at,summary,transcriptEnd})=>({version,source,reason,at,summary, ...(Number.isInteger(transcriptEnd)?{transcriptEnd:state.transcript.slice(0,transcriptEnd).filter(e=>e.type==="you" || (e.type==="bridge" && e.method==="item/agentMessage/delta")).length}:{})})), transcript:state.transcript.filter(e=>e.type==="you" || (e.type==="bridge" && e.method==="item/agentMessage/delta")) .map(e=>({role:e.type==="you"?"user":"assistant",text:e.text || e.params?.delta || ""}))}; const json=JSON.stringify(notebook,null,2); if(new TextEncoder().encode(json).length>8*1024*1024)throw new Error("Notebook export exceeds 8 MB."); say("export",{json});return json; }
async function importNotebook(text) { if(typeof text!=="string" || new TextEncoder().encode(text).length>8*1024*1024)throw new Error("Notebook import exceeds 8 MB."); const value=JSON.parse(text); if(value.format!=="aesel-notebook" || value.schema!==1 || value.medium!=="piece")throw new Error("Choose an Aesel piece notebook."); const check=async source=>{ if(typeof source!=="string" || new TextEncoder().encode(source).length>100000)throw new Error("Imported source exceeds 100 KB."); await validatePieceSource(source,"piece.mjs"); }; await check(value.source); if(!Array.isArray(value.revisions) || value.revisions.length>50)throw new Error("Invalid revision history."); const revisions=[]; for(const record of value.revisions){ if(!Number.isInteger(record.version) || record.version<0 || record.version<=(revisions.at(-1)?.version ?? -1))throw new Error("Invalid revision order."); await check(record.source); revisions.push({version:record.version,source:record.source,...(Number.isInteger(record.transcriptEnd) && record.transcriptEnd>=0 && record.transcriptEnd <= (value.transcript?.length ?? 0)?{transcriptEnd:record.transcriptEnd}:{}),summary:String(record.summary || "").slice(0,160),reason:String(record.reason || "imported").slice(0,100),at:String(record.at || "").slice(0,40)}); } const composer=typeof value.composer==="string"?value.composer:""; if(new TextEncoder().encode(composer).length>32768)throw new Error("Imported draft exceeds 32 KB."); const events=[]; for(const entry of Array.isArray(value.transcript)?value.transcript:[]){ if(!['user','assistant'].includes(entry.role) || typeof entry.text!=="string" || entry.text.length>100000)throw new Error("Invalid transcript."); events.push(entry.role==="user"?{type:"you",text:entry.text}:{type:"bridge",method:"item/agentMessage/delta",params:{delta:entry.text}}); } await settleCurrent(); const slug=freshSlug(); loadThread({id:`${Date.now()}-${slug}`,slug,title:String(value.title || slug).slice(0,120),medium:"piece",source:value.source, composer,revisions,revisionSchema:value.revisionStart===0?2:1,events,autoPublish:false,published:false}); saveCurrent();return state.id; }
function route() { return state.handle ? `@${state.handle}/${state.slug}` : state.slug; }
function pieceUrl() { if (!state.handle) return ""; // Cache-busted: the URL is stable and the bytes behind it are not. return `${SITE}/@${state.handle}/${state.slug}?nolabel=true&nogap=true&autoreload=true#${Date.now()}`; }
function mountPiece(slug, source) { state.slug = slug; state.file = `/piece/${slug}.mjs`; state.server = null; // a new piece is a new conversation vfs.mount(state.file, source); state.published = false; write({ slug, source, published: false }); say("piece", { route: route(), slug, source, version:state.version }); }
function recordRevision(source, reason) { if (state.revisions.at(-1)?.source === source) return; const version = (state.revisions.at(-1)?.version ?? -1) + 1; const restoredFrom=/^restored v(\d+)$/.exec(reason)?.[1]; state.revisions.push({version, source, reason, at:new Date().toISOString(), transcriptEnd:state.transcript.length, summary:revisionSummary(state.revisions.at(-1)?.source,source,restoredFrom!==undefined?{restoredFrom:Number(restoredFrom)}:{})}); while (state.revisions.length > 50) state.revisions.shift(); if (state.busy) pendingRevision = version; }
function reportRevisions() { say("revisions", {items:state.revisions.map(({source,...item})=>item), current:state.revisions.at(-1)?.version || 0, published:state.publication?.version ?? null,autoPublish:state.autoPublish}); }
function editable(threadID) { requireCurrentRevision(); if(threadID !== state.id) throw new Error("This edit belongs to another thread."); if(state.busy || state.hostOperation) throw new Error("Finish the current turn before editing source."); }
async function editSource(source, threadID = state.id, reason = "edited") { editable(threadID); if(typeof source !== "string" || new TextEncoder().encode(source).length > 100000) throw new Error("Source is limited to 100 KB."); await validatePieceSource(source,state.file); editable(threadID); vfs.mount(state.file,source); onWritten(state.file,source,reason); }
function previewRevision(version,threadID = state.id) { if(threadID!==state.id)throw new Error("This version belongs to another thread."); const revision=state.revisions.find(item=>item.version===version); if(!revision)throw new Error("This version is no longer saved."); say("revisionPreview",{threadID,version,source:revision.source,summary:revision.summary || revision.reason}); }
function requireCurrentRevision() { if (state.selectedRevision !== null) throw new Error(`Return to v${state.version} to make changes.`); }
function selectRevision(version, threadID = state.id) { if (threadID !== state.id) throw new Error("This version belongs to another thread."); if (state.busy || state.publishing || state.hostOperation) throw new Error("Wait for the current turn and upload before viewing history."); const revision = state.revisions.find(r=>r.version === version); if (!revision) throw new Error("This version is no longer saved."); const current = version === state.version; state.selectedRevision = current ? null : version; const available = current || Number.isInteger(revision.transcriptEnd); emit({type:"revisionSelection", threadID, version, current:state.version, source:current ? vfs.readFileSync(state.file) : revision.source, events:current ? state.transcript : available ? state.transcript.slice(0, revision.transcriptEnd) : [], transcriptAvailable:available}); saveCurrent(); }
async function restoreRevision(version,threadID = state.id) { editable(threadID); const revision=state.revisions.find(item=>item.version===version); if(!revision)throw new Error("This revision is no longer saved."); return editSource(revision.source,threadID,`restored v${version}`); }
function setAutoPublish(enabled) { if(typeof enabled!=="boolean")throw new Error("Invalid publication setting"); state.autoPublish=enabled;saveCurrent();reportRevisions(); }
function onWritten(path, source, reason = "generated") { if (path !== state.file) return; recordRevision(source,reason); state.published = state.publication?.source === source && state.publication?.handle === state.handle; write({ slug: state.slug, source, published:state.published }); saveCurrent();reportRevisions(); state.dirty = true; say("source", { source, version:state.version }); say("publication",{url:state.published?state.publication.url:null}); if(state.autoPublish)void publish(); }
async function publish() { try { await verifyWorkAccount(); } catch (error) { say("notice", {scope:"account",text:error.message,action:"signIn"}); return; } requireCurrentRevision(); if (state.publishing) return state.publishing; if (!state.token || !state.handle) { say("notice", {scope:"publish", text: "Sign in with an Aesthetic.Computer @handle to publish.", action:"signIn" }); return; } state.dirty = false; const snapshot={source:vfs.readFileSync(state.file),handle:state.handle,token:state.token, version:state.revisions.at(-1)?.version ?? 0,threadID:state.id,file:state.file,slug:state.slug}; let lastStep = "starting"; state.publishing = (async () => { try { say("notice", {scope:"publish", text:"Publishing…", working:true}); const result=await publishPiece({version:null,source:snapshot.source, file:snapshot.file,slug:snapshot.slug,cwd:"/piece",site:SITE, session:{handle:snapshot.handle,signedIn:true,token:async()=>snapshot.token},fetch:browserFetch, retryOptions, onRetry:({attempt})=>say("notice",{scope:"publish",text:`Connection interrupted—retrying upload (${attempt}/2)…`,working:true}), onStep:step=>{lastStep=step;}, }); if(!result.verified)throw new Error("Upload sent, but its public bytes could not be verified. Retry Publish to verify the saved source."); if(state.id!==snapshot.threadID)return; state.publication={source:snapshot.source,handle:snapshot.handle,version:snapshot.version,url:result.route,verifiedAt:new Date().toISOString()}; state.published=state.handle===snapshot.handle && vfs.readFileSync(state.file)===snapshot.source; state.owner=snapshot.handle; write({published:state.published});saveCurrent();reportRevisions(); say("notice",{scope:"publish",text:""}); say("publication",{url:state.published?result.route:null}); if(state.published) { say("status",{text:"live",kind:"live"}); say("preview",{url:pieceUrl()}); } } catch(error) { if(state.id!==snapshot.threadID)return; say("diagnostic",{operation:"publish",step:lastStep,message:error.message}); const auth=error.status===401 || error.status===403; const text=isTransientNetworkError(error) ? "Upload paused. Your piece is saved here." : auth ? "Sign in again to publish. Your piece is saved here." : lastStep==="verifying" ? "Your piece is saved here; publication could not be verified." : "Couldn’t publish: "+error.message; say("notice",{scope:"publish",text,action:auth?"signIn":"publish"}); } finally { state.publishing=null; if(state.dirty && state.autoPublish)void publish(); } })(); return state.publishing; }
function handoff() { return state.transcript.filter(e=>e.type==="you" || (e.type==="bridge" && e.method==="item/agentMessage/delta")) .map(e=>(e.type==="you"?"User: "+e.text:"Assistant: "+(e.params?.delta || ""))) .join("\n").slice(-16000); }
function buildServer() { if (state.provider !== "ac") { if (!hostRPC) throw new Error("Connect Aesel Host before using this provider."); const server = new NativeProvider({rpc:hostRPC,sessionID:state.id,provider:state.provider,model:state.model, source:()=>vfs.readFileSync(state.file),onSource:source=>vfs.writeFileSync(state.file,source), operation:state.hostOperation,context:handoff(), onOperation:operation=>{ const changed=JSON.stringify(state.hostOperation)!==JSON.stringify(operation); state.hostOperation=operation?{...operation}:null; if(changed) { saveCurrent(); say("hostOperation",{operation:state.hostOperation}); } }}); server.on("notification",event=>say("bridge",event)); server.on("approval",approval=>say("approval",{approval})); return server; } const server = new AcServer({ cwd: "/piece", model: state.model, piece: { file: state.file }, token: () => currentToken(), site: SITE, // Stored and called as `this.fetch(…)`. Node tolerates that; a browser // throws "Illegal invocation" unless window.fetch is bound to window. fetch: globalThis.fetch.bind(globalThis), }); if (!state.engine && state.transcript.length > 1) server.messages=[{role:"user",content:"Prior visible conversation:\n"+handoff()}]; if (state.engine) { server.threadId = state.engine.threadId || ""; server.messages = JSON.parse(JSON.stringify(state.engine.messages || [])); server.turns = state.engine.turns || 0; } server.on("notification", ({ method, params }) => { if (method === "model/reported") say("model", {requested: params.requested, reported: params.reported}); if(method==="turn/completed" && params.turn?.error) { const error=params.turn.error; say("diagnostic",{operation:"inference",message:error.message}); say("notice",{scope:"inference",text:error.network ? "Connection interrupted. Your saved changes are safe. Send a message to continue." : error.message}); params={...params,turn:{...params.turn,error:undefined}}; } say("bridge", { method, params }); }); return server; }
function setModel(input) { if (state.busy || state.publishing || state.hostOperation) throw new Error("Wait for this turn and upload to finish before changing models."); if (state.provider !== "ac") { const choice=hostProviders.find(p=>p.id===state.provider); if (!choice?.available || !choice.models?.some(m=>m.id===input)) throw new Error("That model is not available from the connected host."); state.model=input;state.providerModels[state.provider]=input;state.server=null; reportProvider();saveCurrent();return input; } if (input !== DEFAULT_AC_MODEL) throw new Error("Braincell models are managed automatically."); state.model = DEFAULT_AC_MODEL; if (state.server) state.server.model = state.model; if (state.engine) state.engine.model = state.model; say("model", {requested: state.model, choices: MODEL_CHOICES}); saveCurrent(); return state.model; }
function reportProvider() { const providers=[{id:"ac",available:true,models:[{id:DEFAULT_AC_MODEL,title:"Automatic"}]}, ...["claude","codex"].map(id=>hostProviders.find(p=>p.id===id)||{id,available:false,models:[],notice:"Connect Aesel Host on your Mac."})]; say("providers",{selected:state.provider,choices:providers,mcpAutoAllow,supportsApprovalPolicy}); const selected=providers.find(p=>p.id===state.provider); say("model",{requested:state.model,choices:selected.models}); say("hostOperation",{operation:state.hostOperation}); }
async function refreshProviders() { if (hostRPC) { try { const capabilities=await hostRPC("capabilities",{}); hostProviders=capabilities.providers || []; supportsApprovalPolicy=typeof capabilities.mcpAutoAllow === "boolean"; mcpAutoAllow=capabilities.mcpAutoAllow !== false; } catch(error) { supportsApprovalPolicy=false;hostProviders=["claude","codex"].map(id=>({id,available:false,models:[],notice:error.message})); } } reportProvider(); }
async function setMcpAutoAllow(value) { if (!hostRPC || !supportsApprovalPolicy) throw new Error("Update Aesel Host to change MCP permissions."); const result=await hostRPC("approvalPolicy",{mcpAutoAllow:value}); mcpAutoAllow=result.mcpAutoAllow; reportProvider(); }
function setProvider(id) { if (state.busy || state.publishing || state.hostOperation) throw new Error("Finish or reconnect the current turn before changing providers."); const choice=hostProviders.find(p=>p.id===id); if (id!=="ac" && !choice?.available) throw new Error("This provider is not connected."); if (id===state.provider) return; state.providerModels[state.provider]=state.model; state.provider=id;state.model=id==="ac"?DEFAULT_AC_MODEL:state.providerModels[id]??choice.model??""; state.server?.close?.();state.server=null;state.engine=null; reportProvider();saveCurrent(); }
async function resumeTurn() { await verifyWorkAccount(); if (!state.hostOperation || state.busy) return; state.busy=true;say("busy",{busy:true}); try { if(!state.server)state.server=buildServer();await state.server.follow(); } catch(error) { say("bad",{text:error.message}); } finally { state.busy=false;say("busy",{busy:false});say("approval",{approval:null});saveCurrent(); } }
async function respondToApproval(id, decision) { if (decision !== "decline") await verifyWorkAccount(); try { if (!state.server?.respond) throw new Error("This approval is no longer available."); await state.server.respond(id,decision); } finally { say("approval",{approval:null}); } if (decision === "always") await refreshProviders(); }
async function ask(text, artifactContext = "") { requireCurrentRevision(); const command = text.trim().match(/^\/model(?:\s+(.+))?$/i); if (command) { requireAccountReady(); if (command[1]) setModel(command[1]); else say("note", {text:"Braincell models are managed automatically."}); return; } if (!text.trim() || state.busy) return; if (state.hostOperation) throw new Error("Reconnect to resolve the previous host turn before sending another request."); state.busy = true; say("busy", {busy:true}); try { await verifyWorkAccount(); if (state.provider !== "ac" && !hostProviders.some(p => p.id === state.provider && p.available)) { throw new Error("This provider is not connected. Open Settings to check its availability."); } if (!state.title || state.title === state.slug) state.title = text.trim().slice(0, 120); say("notice", {scope:"inference",text:""}); say("you", {text}); if (!state.server) state.server = buildServer(); await state.server.startTurn(artifactContext ? `${artifactContext}\n\nUser request: ${text}` : text); } catch (error) { say("bad", {text:error.message}); say("status", {text:"failed",kind:"failed"}); } finally { state.busy = false; say("busy", {busy:false}); say("approval", {approval:null}); saveCurrent(); if (state.accountVerified && state.handle) void credits.refresh(); } }
function stop() { Promise.resolve(state.server?.interrupt?.()).catch(error=>say("bad",{text:error.message})); state.server?.controller?.abort?.(); }
// Two calls, because a token is worth nothing here without the @handle that // says where a piece goes. async function resolveHandle(token) { return (await verifyAccount(token, {fetch:accountFetch})).handle; }
function requireAccountReady() { if (!state.accountVerified || !state.token || !state.handle) { throw new Error("Sign in to Aesthetic Computer and claim an @handle to use Aesel."); } }
async function currentToken(force = false) { if (!renewToken) return state.token; const epoch=accountEpoch; const token=await renewToken({force}); if(epoch!==accountEpoch)throw new Error("Your AC account changed. Try again."); if(!token)throw new Error("Sign in again to renew your AC session. Your work is saved."); if(token!==state.token){state.token=token;write({token});} return token; }
// Retry account verification only. Never replay inference or an upload after // an auth failure: those may already have performed paid or public work. async function verifiedToken() { let token=await currentToken(); try { return {token,account:await verifyAccount(token,{fetch:accountFetch})}; } catch(error) { if(error.status!==401 || !renewToken)throw error; token=await currentToken(true); return {token,account:await verifyAccount(token,{fetch:accountFetch})}; } }
async function verifyWorkAccount() { const epoch = accountEpoch; try { const {account,token} = await verifiedToken(); if (epoch !== accountEpoch || token !== state.token) throw new Error("Your AC account changed. Sign in again."); state.accountVerified = true; state.handle = account.handle; write({handle:account.handle}); say("signedIn", {handle:account.handle}); requireHandle(account); return account; } catch (error) { if (epoch === accountEpoch) { state.accountVerified = false; say("accountRequired", {signedIn:error.code === "no-handle", text:error.message}); stop(); } throw error; } }
async function loadHandleColors(handle) { let colors = handleCharacterColors(`@${handle}`); try { colors = await fetchHandleColors(`@${handle}`); } catch { } if (state.handle !== handle) return; say("handleColors", {handle, colors: colors.map(rgb => "#" + rgb.map(n => n.toString(16).padStart(2, "0")).join(""))}); }
async function adoptToken(token) { const epoch = ++accountEpoch; const handle = await resolveHandle(token); if (epoch !== accountEpoch) return ""; state.accountVerified = true; state.token = token; state.handle = handle; write({ token, handle }); say("signedIn", { handle }); void loadHandleColors(handle); void credits.refresh(); await prepublishBlank(); return handle; }
// Resolves to true when a stored session was still good. async function restore() { const saved = read(); const epoch = ++accountEpoch; if (!saved.token) return false; state.accountVerified = false; state.token = saved.token; state.handle = ""; try { const {account} = await verifiedToken(); const handle = account.handle; if (epoch !== accountEpoch) return false; state.accountVerified = true; state.handle = handle; write({ handle: state.handle }); say("signedIn", { handle: state.handle }); void loadHandleColors(state.handle); void credits.refresh(); await prepublishBlank(); return true; } catch (error) { if (epoch !== accountEpoch) return false; if (error.status !== 401 && error.status !== 403) { // A connection failure is not evidence that the saved login expired. state.accountVerified = false; say("accountRequired", {signedIn:false, text:"Connect to verify your AC account, then try again."}); return false; } state.token = ""; state.handle = ""; write({ token: "", handle: "" }); say("signedOut"); credits.clear(); return false; } }
function signOut() { accountEpoch++; saveCurrent(); stop(); state.accountVerified = false; state.token = ""; state.handle = ""; state.server = null; write({ token: "", handle: "" }); say("signedOut"); credits.clear(); }
async function open() { const saved = read(); const prior = readThreads().find(item => item.id === saved.threadID); if (prior) { loadThread(prior); } else { const slug = saved.slug || freshSlug(); loadThread({id: `${Date.now()}-${slug}`, slug, source: saved.source || STARTER, published: Boolean(saved.published), handle: saved.handle || state.handle, medium: "piece", events: []}); saveCurrent(); } emit({type: "history", items: history()}); await prepublishBlank(); }
async function begin() { vfs.setWriteHandler(onWritten); const missing = await vfs.preload(GUIDES); if (missing.length) say("note", { text: `Guides missing: ${missing.join(", ")}` }); say("model", { requested: state.model, choices: MODEL_CHOICES }); say("ready", { signedIn: Boolean(state.token) }); void refreshProviders(); }
return { state, begin, restore, signOut, adoptToken, open, ask, stop, publish, exportNotebook: (...args) => { requireAccountReady(); return exportNotebook(...args); }, importNotebook: (...args) => { requireAccountReady(); return importNotebook(...args); }, editSource: (...args) => { requireAccountReady(); return editSource(...args); }, restoreRevision: (...args) => { requireAccountReady(); return restoreRevision(...args); }, selectRevision: (...args) => { requireAccountReady(); return selectRevision(...args); }, previewRevision: (...args) => { requireAccountReady(); return previewRevision(...args); }, setAutoPublish: (...args) => { requireAccountReady(); return setAutoPublish(...args); }, newPiece: (...args) => { requireAccountReady(); return newSession(...args); }, newSession: (...args) => { requireAccountReady(); return newSession(...args); }, resumeSession: (...args) => { requireAccountReady(); return resumeSession(...args); }, saveCurrent, setDraft: (...args) => { requireAccountReady(); return setDraft(...args); }, setModel: (...args) => { requireAccountReady(); return setModel(...args); }, setProvider: (...args) => { requireAccountReady(); return setProvider(...args); }, refreshProviders, setMcpAutoAllow: (...args) => { requireAccountReady(); return setMcpAutoAllow(...args); }, resumeTurn, respondToApproval, history, refreshCredits: async () => { try { await verifyWorkAccount(); return await credits.refresh(); } catch(error) { say("notice",{scope:"account",text:error.message,action:"signIn"}); } }, buyCredits: credits.buy, checkCheckout: credits.check, route, pieceUrl, };}