Campaign Objects

Every item changes how the AI office can work.
Choose an object, then click the floor to place it.
Selected object
🏛 Parliamentary Campaign HQ
Objective: Build campaign capacity
Staff: 6
Runtime: checking…
🌐 Web: enabled
Build: SIM-1.8.3
ITEMS REQUIRING APPROVAL
Nothing is waiting for approval.
Move mode
💬 Live Agent Chat
Channel: Full Team · checking…
Voice ready · click mic to talk
async function refreshApprovalQueue(){ const box=document.getElementById("approvalList");if(!box)return; try{ const r=await fetch(API_BASE+"/api/approvals");if(!r.ok)throw new Error(await r.text()); const d=await r.json(),items=d.items||[]; box.innerHTML=items.length?items.map(x=>'
'+escapeHtml(x.agent||"Campaign Office")+'
'+renderRichText(x.summary||"Approval required")+'
'+(x.destination?'
Destination: '+escapeHtml(x.destination)+'
':'')+(x.cost?'
Cost: '+escapeHtml(String(x.cost))+'
':'')+(x.status==="pending"?'':'
Authorisation saved
')+'
').join(""):'
Nothing is waiting for approval.
'; box.querySelectorAll('[data-approval-save]').forEach(btn=>btn.onclick=async()=>{ const id=btn.dataset.approvalSave,check=box.querySelector('[data-approval-check="'+id+'"]'); if(!check?.checked){term("Tick the authorisation box first");return;} const rr=await fetch(API_BASE+"/api/approvals/"+id+"/authorise",{method:"POST"}); if(!rr.ok){term("Could not save authorisation");return;} term("Human authorisation saved");if(typeof refreshApprovalQueue==="function")refreshApprovalQueue(); }); }catch(e){box.innerHTML='
Approval queue unavailable.
';} } document.getElementById("refreshApprovalsBtn")?.addEventListener("click",refreshApprovalQueue); syncAffordanceMeta().then(refreshAllRoleVisuals); document.addEventListener("change",e=>{ const el=e.target; if(el?.id==="staffRole"||el?.dataset?.staffRoleSelector==="1"){ const id=el.dataset.agentId||selectedStaffId||selectedAgentId; if(id&&el.value)transitionAgentRole(id,el.value); } }); async function migrateLegacyFurnitureToBuildMode(){ let legacy=[]; try{legacy=JSON.parse(localStorage.getItem("campaignHQ.furniture")||"[]")}catch(e){legacy=[]} if(!Array.isArray(legacy)||!legacy.length)return; const keys=new Set(officeObjects.map(o=>`${o.object_type}:${Number(o.x).toFixed(2)}:${Number(o.z).toFixed(2)}`)); let migrated=0; for(const f of legacy){ const type=f.type==="table"?"coffee_table":f.type; if(!FUNCTIONAL_OBJECT_CATALOG[type])continue; const x=Number(f.x||0),z=Number(f.z||0),key=`${type}:${x.toFixed(2)}:${z.toFixed(2)}`; if(keys.has(key))continue; const meta=FUNCTIONAL_OBJECT_CATALOG[type]; const obj={id:"migrated-"+(f.id||Date.now()+"-"+Math.random().toString(36).slice(2,7)),object_type:type,name:meta.name,x:x,y:0,z:z,rotation:Number(f.rotation||0),room:"",assigned_agent_id:"",state:{migrated_from_legacy:true}}; officeObjects.push(obj);addOfficeObjectToScene(obj);await persistOfficeObject(obj);keys.add(key);migrated++; } localStorage.removeItem("campaignHQ.furniture"); if(typeof furniture!=="undefined"&&Array.isArray(furniture))furniture.length=0; if(typeof furnitureGroups!=="undefined"&&furnitureGroups?.forEach){furnitureGroups.forEach(g=>scene.remove(g));furnitureGroups.clear()} document.getElementById("furnitureList")?.replaceChildren(); if(migrated)term(`Migrated ${migrated} legacy furniture item(s) into functional Build Mode.`); } async function migrateBuiltInScenePropsToBuildMode(){ if(!Array.isArray(props))return; const byId=new Map(officeObjects.map(o=>[o.id,o])); let created=0; for(const g of [...props]){ const type=g?.userData?.affordanceType; const id=g?.userData?.builtinPropId; if(!type||!id||!FUNCTIONAL_OBJECT_CATALOG[type])continue; let existing=byId.get(id); if(!existing){ existing={ id:id, object_type:type, name:g.userData.affordanceName||FUNCTIONAL_OBJECT_CATALOG[type].name, x:g.position.x,y:g.position.y||0,z:g.position.z, rotation:g.rotation.y||0, room:g.userData.affordanceRoom||"", assigned_agent_id:"", state:{builtin:true} }; officeObjects.push(existing);byId.set(id,existing); await persistOfficeObject(existing);addOfficeObjectToScene(existing);created++; } scene.remove(g); } props.length=0; if(created)term(`Converted ${created} built-in objects into functional Build/Buy objects.`); } async function ensurePrintingRoomNode(){ let room=officeObjects.find(o=>o.id==="builtin-printing-room"); if(!room){ room={id:"builtin-printing-room",object_type:"printing_room",name:"Printing Room",x:-7.3,y:0,z:12.5,rotation:0,room:"",assigned_agent_id:"",state:{builtin_room:true,mailbox:true}}; officeObjects.push(room);await persistOfficeObject(room);addOfficeObjectToScene(room); } if(typeof printingRoomLegacyLabel!=="undefined"&&printingRoomLegacyLabel.parent)scene.remove(printingRoomLegacyLabel); roomPositions.PRINT.set(room.x,0,room.z); if(roomTargets.PRINT){ roomTargets.PRINT.target=[room.x,1.2,room.z]; roomTargets.PRINT.camera=[room.x+5.3,6,room.z+4.5]; } } let printMailBox="inbox"; let printMailMessages=[]; let selectedPrintMailId=null; function formatPrintMailDate(v){ if(!v)return ""; try{return new Date(v.replace(" ","T")+"Z").toLocaleString([], {month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}catch(e){return v} } function renderPrintMailList(){ const wrap=document.getElementById("printMailList");if(!wrap)return; if(!printMailMessages.length){wrap.innerHTML='
No messages.
';return;} wrap.innerHTML=printMailMessages.map(m=>`
${escapeHtml(formatPrintMailDate(m.created_at))}
${escapeHtml(printMailBox==="inbox"?m.sender:m.recipient)}
${escapeHtml(m.subject||"Campaign work")}
${escapeHtml((m.body||"").replace(/\\s+/g," ").slice(0,110))}
`).join(""); } function renderPrintMailReader(){ const wrap=document.getElementById("printMailReader");if(!wrap)return; const m=printMailMessages.find(x=>String(x.id)===String(selectedPrintMailId)); if(!m){wrap.innerHTML='
Select a message.
';return;} wrap.innerHTML=`
${escapeHtml(m.subject||"Campaign work")}
${escapeHtml(m.sender)} → ${escapeHtml(m.recipient)} · ${escapeHtml(formatPrintMailDate(m.created_at))}
${escapeHtml(m.body||"")}
${m.related_document_id?``:""}`; } async function loadPrintMailbox(box="inbox"){ printMailBox=box; document.querySelectorAll("[data-print-mail-box]").forEach(b=>b.classList.toggle("active",b.dataset.printMailBox===box)); const r=await fetch(API_BASE+"/api/print-mail?box="+encodeURIComponent(box)); if(!r.ok)throw new Error(await r.text()); const d=await r.json();printMailMessages=d.messages||[]; const unread=printMailMessages.filter(x=>x.status==="unread").length; const c=document.getElementById("printInboxCount");if(c)c.textContent=box==="inbox"&&unread?`(${unread})`:""; if(selectedPrintMailId&&!printMailMessages.some(x=>String(x.id)===String(selectedPrintMailId)))selectedPrintMailId=null; renderPrintMailList();renderPrintMailReader(); } async function openPrintMailbox(box="inbox"){ selectedPrintMailId=null; document.getElementById("printMailboxModal")?.classList.add("open"); try{await loadPrintMailbox(box)}catch(e){term("Printing Room mail failed: "+e.message)} } document.addEventListener("click",async e=>{ const boxBtn=e.target.closest("[data-print-mail-box]"); if(boxBtn){selectedPrintMailId=null;await loadPrintMailbox(boxBtn.dataset.printMailBox);return} const row=e.target.closest("[data-print-mail-id]"); if(row){ selectedPrintMailId=row.dataset.printMailId; const msg=printMailMessages.find(x=>String(x.id)===String(selectedPrintMailId)); if(msg&&msg.status==="unread"){msg.status="read";fetch(API_BASE+"/api/print-mail/"+encodeURIComponent(msg.id)+"/read",{method:"POST"}).catch(()=>{});} renderPrintMailList();renderPrintMailReader();return } if(e.target.closest("#printMailCloseBtn")){document.getElementById("printMailboxModal")?.classList.remove("open");return} if(e.target.closest("#printMailDocumentsBtn")){ document.getElementById("printMailboxModal")?.classList.remove("open");openPrintRoom();return } if(e.target.closest("#objectMailboxBtn")){openPrintMailbox("inbox");return} const docBtn=e.target.closest("[data-open-mail-document]"); if(docBtn){ selectedPrintDocId=String(docBtn.dataset.openMailDocument); document.getElementById("printMailboxModal")?.classList.remove("open"); openPrintRoom();return } }); renderer.domElement.addEventListener("dblclick",e=>{ const id=pickOfficeObject(e.clientX,e.clientY);if(!id)return; const o=officeObjects.find(x=>x.id===id);if(!o)return; if(["printing_room","printing_press"].includes(o.object_type)){ openObjectInspector(id); openPrintMailbox("inbox"); e.preventDefault();e.stopImmediatePropagation(); } },true); let currentBrainTab="persona",currentBrainData=null; function brainSelectedAgent(){return staff.find(x=>x.id===selectedId)||null} function brainPersonaFields(){return ["identity","role_identity","biography","temperament","communication_style","reasoning_style","working_preferences","values","strengths","blind_spots","quirks","boundaries","development_goals"]} function prettyBrainKey(k){return k.replaceAll("_"," ").replace(/\b\w/g,c=>c.toUpperCase())} function renderAgentBrain(){ const wrap=document.getElementById("agentBrainPanel");if(!wrap)return;const a=brainSelectedAgent(); if(!a||!currentBrainData){wrap.innerHTML='
No brain data loaded.
';return} let body=""; if(currentBrainTab==="persona"){ body='
Durable identity, separate from functional role.
'+ brainPersonaFields().map(k=>``).join("")+ '
'; }else if(currentBrainTab==="habits"){ const h=currentBrainData.habits||[];body=h.length?h.map(x=>`
${escapeHtml(x.name)}${escapeHtml(x.status)}
Trigger: ${escapeHtml(x.trigger||"—")}
Pattern: ${escapeHtml(x.behaviour||"")}
Evidence: ${x.evidence_count||1}
`).join(""):'
No habits yet. Habits emerge only after repeated evidence.
'; }else{ const n=currentBrainData.notes||[];body=n.length?n.map(x=>`
${escapeHtml(x.topic)}${escapeHtml(x.type)}
${escapeHtml(x.summary||"")}
${escapeHtml(x.created_at||"")}
`).join(""):'
No durable understanding notes yet.
'; } wrap.innerHTML=`
${body}`; } async function refreshAgentBrain(){ const a=brainSelectedAgent(),status=document.getElementById("brainStatus");if(!a)return; status.textContent="Loading "+a.name+"'s brain…"; try{const r=await fetch(API_BASE+"/api/brain/agent/"+encodeURIComponent(a.id));if(!r.ok)throw new Error(await r.text());currentBrainData=await r.json();renderAgentBrain();const sr=await fetch(API_BASE+"/api/brain/status");const sd=sr.ok?await sr.json():{};status.textContent=`${(currentBrainData.notes||[]).length} notes · ${(currentBrainData.habits||[]).length} habits · ${sd.vault_path||"Obsidian vault"}`;}catch(e){status.textContent="Brain unavailable: "+e.message} } window.refreshAgentBrain=refreshAgentBrain; document.addEventListener("click",async e=>{ const tab=e.target.closest("[data-brain-tab]");if(tab){currentBrainTab=tab.dataset.brainTab;renderAgentBrain();return} if(e.target.closest("#savePersonaBtn")){const a=brainSelectedAgent();if(!a)return;const persona={};document.querySelectorAll("[data-persona-key]").forEach(x=>persona[x.dataset.personaKey]=x.value.trim());const r=await fetch(API_BASE+"/api/brain/agent/"+encodeURIComponent(a.id)+"/persona",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({persona})});if(r.ok){currentBrainData.persona=persona;term("Persona saved for "+a.name);renderAgentBrain()}return} const hs=e.target.closest("[data-habit-status]");if(hs){const [id,state]=hs.dataset.habitStatus.split(":");const ta=document.querySelector(`[data-habit-instruction="${CSS.escape(id)}"]`);const r=await fetch(API_BASE+"/api/brain/habits/"+encodeURIComponent(id),{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({status:state,user_instruction:ta?.value||""})});if(r.ok){await refreshAgentBrain();term("Habit updated: "+state)}} }); document.getElementById("refreshBrainBtn")?.addEventListener("click",refreshAgentBrain); document.getElementById("openBrainBtn")?.addEventListener("click",()=>fetch(API_BASE+"/api/brain/open",{method:"POST"})); document.getElementById("openBrainVaultSettingsBtn")?.addEventListener("click",()=>fetch(API_BASE+"/api/brain/open",{method:"POST"})); document.getElementById("saveBrainVaultBtn")?.addEventListener("click",async()=>{const path=document.getElementById("brainVaultPath").value.trim(),s=document.getElementById("brainVaultSettingsStatus");try{const r=await fetch(API_BASE+"/api/brain/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({vault_path:path})});if(!r.ok)throw new Error(await r.text());const d=await r.json();s.textContent="Saved: "+d.vault_path;}catch(e){s.textContent="Could not save vault: "+e.message}}); async function refreshBrainSettings(){try{const r=await fetch(API_BASE+"/api/brain/status");if(!r.ok)return;const d=await r.json();const i=document.getElementById("brainVaultPath");if(i)i.value=d.vault_path||"";const s=document.getElementById("brainVaultSettingsStatus");if(s)s.textContent=`${d.notes||0} notes · ${d.habits||0} habits`;}catch(e){}} refreshBrainSettings(); async function refreshServerOpenAIStatus(){ const s=document.getElementById("serverOpenAIStatus");if(!s)return; try{ const r=await fetch(API_BASE+"/api/integrations/openai/status",{cache:"no-store"}); if(!r.ok){s.textContent="Could not read server API status.";return} const d=await r.json(); s.textContent=d.configured?"Secure AI gateway connected":"Browser runtime active — AI gateway not connected."; }catch(e){s.textContent="Server-side API configuration."} } refreshServerOpenAIStatus(); let voiceRecorder=null,voiceStream=null,voiceChunks=[],voiceRecording=false,voiceProcessing=false; // Voice state is initialised above the agent runtime. Speech is optional and defaults off. function setVoiceStatus(text){ const a=document.getElementById("voiceStatus"),b=document.getElementById("mainVoiceStatus"); if(a)a.textContent=text;if(b)b.textContent=text; } function setMicVisual(){ for(const id of ["voiceMicBtn","mainVoiceMicBtn"]){ const b=document.getElementById(id);if(!b)continue; b.classList.toggle("recording",voiceRecording);b.classList.toggle("processing",voiceProcessing); b.textContent=voiceRecording?"⏹":id==="voiceMicBtn"?"🎙":"🎙 Talk"; } } function stopCurrentVoice(){ if(currentVoiceAudio){try{currentVoiceAudio.pause();currentVoiceAudio.src=""}catch(e){}currentVoiceAudio=null} voiceSpeakQueue=Promise.resolve();setVoiceStatus("Voice ready · click mic to talk"); } async function speakAgentReply(text,agentId=null,role=null,name="Agent"){ if(!voiceRepliesEnabled||!String(text||"").trim())return; const clean=String(text).replace(/⚠️ REQUIRES HUMAN AUTHENTICATION/g,"").trim(); if(!clean)return; voiceSpeakQueue=voiceSpeakQueue.then(async()=>{ setVoiceStatus(name+" is speaking…"); try{ const r=await fetch(API_BASE+"/api/voice/speak",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({text:clean,agent_id:agentId,role:role})}); if(!r.ok)throw new Error(await r.text()); const blob=await r.blob(),url=URL.createObjectURL(blob),audio=new Audio(url);currentVoiceAudio=audio; await new Promise((resolve,reject)=>{ audio.onended=()=>{URL.revokeObjectURL(url);if(currentVoiceAudio===audio)currentVoiceAudio=null;resolve()}; audio.onerror=()=>{URL.revokeObjectURL(url);reject(new Error("Audio playback failed"))}; audio.play().catch(reject); }); }catch(e){console.error(e);setVoiceStatus("Voice reply failed · text conversation still available")} }); return voiceSpeakQueue; } async function submitVoiceBlob(blob,mimeType){ voiceProcessing=true;setMicVisual();setVoiceStatus("Transcribing your voice…"); try{ const fd=new FormData();fd.append("file",blob,"voice."+(mimeType.includes("mp4")?"m4a":"webm")); const r=await fetch(API_BASE+"/api/voice/transcribe",{method:"POST",body:fd}); if(!r.ok)throw new Error(await r.text()); const d=await r.json(),text=(d.text||"").trim();if(!text)throw new Error("No speech detected"); setVoiceStatus("You said: "+text); const who=activeChannel==="TEAM"?"You ➔ Full Team":"You ➔ "+activeChannel; liveMsg(who,text,"user"); const el=document.createElement("div");el.className="msg user";el.innerHTML='
'+escapeHtml(who)+'
'+renderRichText(text)+'
';log.appendChild(el);log.scrollTop=log.scrollHeight; await sendLive(text,true); }catch(e){console.error(e);setVoiceStatus("Voice failed: "+String(e.message||e).slice(0,120))} finally{voiceProcessing=false;setMicVisual()} } async function toggleVoiceRecording(){ if(voiceProcessing)return; if(voiceRecording){ voiceRecording=false;setMicVisual();setVoiceStatus("Processing recording…"); try{voiceRecorder?.stop()}catch(e){} return; } try{ if(!navigator.mediaDevices?.getUserMedia)throw new Error("Microphone access is not supported in this browser"); voiceStream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true}}); const preferred=["audio/webm;codecs=opus","audio/webm","audio/mp4"]; const mime=preferred.find(x=>window.MediaRecorder&&MediaRecorder.isTypeSupported(x))||""; voiceChunks=[]; voiceRecorder=new MediaRecorder(voiceStream,mime?{mimeType:mime}:undefined); voiceRecorder.ondataavailable=e=>{if(e.data?.size)voiceChunks.push(e.data)}; voiceRecorder.onstop=async()=>{ const type=voiceRecorder.mimeType||mime||"audio/webm"; const blob=new Blob(voiceChunks,{type}); voiceStream?.getTracks().forEach(t=>t.stop());voiceStream=null; await submitVoiceBlob(blob,type); }; voiceRecorder.start(); voiceRecording=true;setMicVisual();setVoiceStatus("Listening… click again when finished"); }catch(e){ setVoiceStatus("Microphone unavailable: "+String(e.message||e).slice(0,120)); } } document.getElementById("voiceMicBtn")?.addEventListener("click",toggleVoiceRecording); document.getElementById("mainVoiceMicBtn")?.addEventListener("click",toggleVoiceRecording); document.getElementById("stopVoiceBtn")?.addEventListener("click",stopCurrentVoice); document.getElementById("voiceReplyToggle")?.addEventListener("click",e=>{ voiceRepliesEnabled=!voiceRepliesEnabled;e.currentTarget.classList.toggle("active",voiceRepliesEnabled); e.currentTarget.textContent=voiceRepliesEnabled?"🔊 Replies":"🔇 Replies"; if(!voiceRepliesEnabled)stopCurrentVoice(); });