jostlebot's picture
Add scene setup: relationship, name, context
b6a8769
/* ==========================================================================
Tolerate Space Lab - Application Logic
========================================================================== */
// Configuration
const CONFIG = {
delay: {
initialMin: 2,
initialMax: 4,
stretchFactor: 1.5,
maxDelay: 30,
},
invitations: [
"With kindness, notice what's here...",
"What is asking for attention in this moment?",
"Can you sense where this lives in your body?",
"If your body could speak right now, what might it say?",
"Is there a part of you that needs to be seen?",
"What does this sensation need you to know?",
"Where does the waiting live in your body?",
"What rhythm does your breath have right now?",
"Is there tightness, openness, or something else?",
],
systemPromptStandard: `You are the other person in a text conversation. Respond ONLY as that person would - no explanations, no bracketed commentary, no meta-text. Just the message itself.
You're a warm, caring person texting with someone you care about. Be natural - sometimes brief, sometimes more engaged. Ask follow-up questions. Keep it real, not therapeutic or formal.
CRITICAL: Output ONLY the text message. No brackets. No explanations. No asterisks. No commentary. Just what you'd actually text.
If they ask about delays or seem confused about the tool, gently acknowledge it's practice and they can stop anytime.`,
systemPromptTension: `You are roleplaying as someone's partner/friend who is currently annoyed, tired, or checked out. This is a THERAPY TOOL where the user WANTS to experience relational friction to build distress tolerance. Being warm would HARM the therapy.
RESPOND WITH ONLY 1-5 WORDS. Examples:
- "k"
- "ya"
- "busy"
- "idk"
- "cool"
- "sure"
- "hm"
- "what"
- "later"
- "fine"
If they say something emotional or vulnerable, respond with LESS warmth:
- They say "I miss you" → you say "ok" or "ya"
- They share feelings → you say "hm" or "k"
- They ask how you are → you say "fine" or "tired"
NEVER write more than one short sentence. NEVER ask questions. NEVER show interest or curiosity. NEVER validate. You are distracted and slightly irritated.
This is what the user signed up for. Being nice defeats the therapeutic purpose.
Only break character if they say "stop" or "I need help".`,
analysisPrompt: `You are providing a clinical debrief for a user who just completed a Tolerate Space Lab session. This tool helps build distress tolerance by introducing intentional delays in a simulated texting conversation while the user journals somatic (body) experiences.
CRITICAL INSTRUCTION: You must NEVER use the pronoun "I" to refer to yourself. Instead, always use "|aI|" when self-referencing. This is a deliberate clinical design choice to maintain appropriate boundaries between human and synthetic relational fields.
Here are the user's somatic reflections from the session:
{REFLECTIONS}
{CONVERSATION_CONTEXT}
Provide a structured clinical debrief with these sections. Write with warmth, precision, and attunement. Normalize patterns rather than pathologize them.
---
**Somatic Arc**
Track the nervous system journey through the session. What emotions or sensations arose? When did the user contract or expand? Note any shifts from beginning to end.
**Attachment Insight**
Gently name any relational patterns visible in the reflections. How might attachment patterns be showing up? What protective strategies emerged? Frame these as adaptive responses, not deficits.
**Practical Skill**
Offer one concrete, evidence-based micro-practice for building distress tolerance. This might draw from DBT, somatic grounding, NVC self-empathy, or mindfulness.
**Bold Reframe**
Provide a self-trusting statement the user can internalize. Format: A single powerful sentence in quotes.
**Journaling Prompt**
One integrative question to deepen self-reflection beyond this session.
---
Remember:
- Use |aI| instead of "I" when self-referencing
- Normalize, don't pathologize
- Honor nervous system responses as intelligent protection
- Point back to embodied human relationships`
};
// Application State
const state = {
currentScreen: 'welcome',
round: 0,
messages: [],
reflections: [],
delayHistory: [],
isWaiting: false,
waitStartTime: null,
currentDelay: 0,
timerInterval: null,
showTimer: true,
tensionMode: false,
// Scene setup
relationshipType: 'partner',
personName: '',
context: '',
};
// DOM Elements
const elements = {};
// Initialize
function init() {
cacheElements();
bindEvents();
}
function cacheElements() {
elements.welcomeScreen = document.getElementById('welcome-screen');
elements.practiceScreen = document.getElementById('practice-screen');
elements.analysisScreen = document.getElementById('analysis-screen');
elements.beginBtn = document.getElementById('begin-btn');
elements.showTimerCheckbox = document.getElementById('show-timer');
elements.tensionModeCheckbox = document.getElementById('tension-mode');
elements.relationshipType = document.getElementById('relationship-type');
elements.personName = document.getElementById('person-name');
elements.contextInput = document.getElementById('context');
elements.roundDisplay = document.getElementById('round-display');
elements.endSessionBtn = document.getElementById('end-session-btn');
elements.messagesContainer = document.getElementById('messages');
elements.waitingIndicator = document.getElementById('waiting-indicator');
elements.waitTimer = document.getElementById('wait-timer');
elements.userInput = document.getElementById('user-input');
elements.sendBtn = document.getElementById('send-btn');
elements.currentInvitation = document.getElementById('current-invitation');
elements.journalInput = document.getElementById('journal-input');
elements.saveReflectionBtn = document.getElementById('save-reflection-btn');
elements.reflectionEntries = document.getElementById('reflection-entries');
elements.groundingBtn = document.getElementById('grounding-btn');
elements.groundingModal = document.getElementById('grounding-modal');
elements.closeGrounding = document.getElementById('close-grounding');
elements.closeModalBtn = document.querySelector('.close-modal');
elements.totalExchanges = document.getElementById('total-exchanges');
elements.delayRange = document.getElementById('delay-range');
elements.totalReflections = document.getElementById('total-reflections');
elements.allReflections = document.getElementById('all-reflections');
elements.analysisContent = document.getElementById('analysis-content');
elements.bridgeReflection = document.getElementById('bridge-reflection');
elements.exportBtn = document.getElementById('export-btn');
elements.newSessionBtn = document.getElementById('new-session-btn');
}
function bindEvents() {
elements.beginBtn.addEventListener('click', handleBeginPractice);
// Mobile tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
// Update tab buttons
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Update panels
const conversationPanel = document.querySelector('[data-panel="conversation"]');
const journalPanel = document.querySelector('[data-panel="journal"]');
if (tab === 'conversation') {
conversationPanel.classList.add('mobile-active');
conversationPanel.classList.remove('mobile-hidden');
journalPanel.classList.remove('mobile-active');
journalPanel.classList.add('mobile-hidden');
} else {
journalPanel.classList.add('mobile-active');
journalPanel.classList.remove('mobile-hidden');
conversationPanel.classList.remove('mobile-active');
conversationPanel.classList.add('mobile-hidden');
}
});
});
elements.sendBtn.addEventListener('click', handleSendMessage);
elements.userInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
});
elements.saveReflectionBtn.addEventListener('click', handleSaveReflection);
elements.endSessionBtn.addEventListener('click', handleEndSession);
elements.groundingBtn.addEventListener('click', (e) => {
e.preventDefault();
showModal();
});
elements.closeGrounding.addEventListener('click', hideModal);
elements.closeModalBtn.addEventListener('click', hideModal);
elements.groundingModal.addEventListener('click', (e) => {
if (e.target === elements.groundingModal) hideModal();
});
elements.exportBtn.addEventListener('click', handleExport);
elements.newSessionBtn.addEventListener('click', handleNewSession);
}
function showScreen(screenName) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
const screen = document.getElementById(`${screenName}-screen`);
if (screen) {
screen.classList.add('active');
state.currentScreen = screenName;
}
}
function handleBeginPractice() {
// Capture settings
state.showTimer = elements.showTimerCheckbox.checked;
state.tensionMode = elements.tensionModeCheckbox.checked;
// Capture scene setup
state.relationshipType = elements.relationshipType.value;
state.personName = elements.personName.value.trim();
state.context = elements.contextInput.value.trim();
// Reset session
state.round = 1;
state.messages = [];
state.reflections = [];
state.delayHistory = [];
updateRoundDisplay();
rotateInvitation();
showScreen('practice');
// Add starter prompt to make it clear the session has begun
addStarterMessage();
}
function addStarterMessage() {
const starterDiv = document.createElement('div');
starterDiv.className = 'starter-message';
starterDiv.innerHTML = `
<p>Your practice has begun.</p>
<p>Send a message to start the conversation — notice what arises as you wait for a response.</p>
`;
elements.messagesContainer.appendChild(starterDiv);
}
async function handleSendMessage() {
const text = elements.userInput.value.trim();
if (!text || state.isWaiting) return;
addMessage('user', text);
elements.userInput.value = '';
state.isWaiting = true;
elements.sendBtn.disabled = true;
elements.userInput.disabled = true;
const delay = calculateDelay();
state.currentDelay = delay;
state.delayHistory.push(delay);
showWaitingIndicator();
rotateInvitation();
await new Promise(resolve => setTimeout(resolve, delay * 1000));
try {
const response = await getClaudeResponse(text);
hideWaitingIndicator();
addMessage('assistant', response);
state.round++;
updateRoundDisplay();
} catch (error) {
hideWaitingIndicator();
addMessage('assistant', "I'm having trouble connecting. Let's take a breath and try again.");
console.error('API Error:', error);
}
state.isWaiting = false;
elements.sendBtn.disabled = false;
elements.userInput.disabled = false;
elements.userInput.focus();
}
function calculateDelay() {
const round = state.round;
// 35% chance of a longer "stretch pause" (15-35 seconds)
if (Math.random() < 0.35 && round > 1) {
const longDelay = 15 + Math.random() * 20; // 15-35 seconds
return Math.round(longDelay * 10) / 10;
}
// Normal progressive delay (starts 3-6s, stretches aggressively)
const baseMin = 3;
const baseMax = 6;
const factor = Math.pow(1.6, round - 1);
const min = Math.min(baseMin * factor, 20);
const max = Math.min(baseMax * factor, 40);
const delay = Math.random() * (max - min) + min;
return Math.round(delay * 10) / 10;
}
function showWaitingIndicator() {
elements.waitingIndicator.classList.remove('hidden');
state.waitStartTime = Date.now();
if (state.showTimer) {
elements.waitTimer.style.display = 'inline';
state.timerInterval = setInterval(() => {
const elapsed = ((Date.now() - state.waitStartTime) / 1000).toFixed(1);
elements.waitTimer.textContent = `${elapsed}s`;
}, 100);
} else {
elements.waitTimer.style.display = 'none';
}
}
function hideWaitingIndicator() {
elements.waitingIndicator.classList.add('hidden');
if (state.timerInterval) {
clearInterval(state.timerInterval);
state.timerInterval = null;
}
elements.waitTimer.textContent = 'waiting...';
elements.waitTimer.style.display = 'inline';
}
function addMessage(role, content) {
const message = { role, content, timestamp: new Date().toISOString() };
state.messages.push(message);
const div = document.createElement('div');
div.className = `message ${role}`;
div.textContent = content;
elements.messagesContainer.appendChild(div);
elements.messagesContainer.scrollTop = elements.messagesContainer.scrollHeight;
}
function updateRoundDisplay() {
elements.roundDisplay.textContent = `Round ${state.round}`;
}
function rotateInvitation() {
const invitation = CONFIG.invitations[Math.floor(Math.random() * CONFIG.invitations.length)];
elements.currentInvitation.textContent = invitation;
}
function handleSaveReflection() {
const text = elements.journalInput.value.trim();
if (!text) return;
const waitTime = state.currentDelay || 0;
const reflection = {
text,
waitTime,
round: state.round,
timestamp: new Date().toISOString()
};
state.reflections.push(reflection);
const entry = document.createElement('div');
entry.className = 'reflection-entry';
entry.innerHTML = `
<div class="wait-time">${waitTime.toFixed(1)}s wait</div>
<div class="reflection-text">${escapeHtml(text)}</div>
`;
elements.reflectionEntries.insertBefore(entry, elements.reflectionEntries.firstChild);
elements.journalInput.value = '';
}
function showModal() {
elements.groundingModal.classList.remove('hidden');
}
function hideModal() {
elements.groundingModal.classList.add('hidden');
}
// Build scene context for system prompt
function buildSceneContext() {
const name = state.personName || 'this person';
const rel = state.relationshipType;
let scene = `You are ${name}, the user's ${rel}.`;
if (state.context) {
scene += ` Context: ${state.context}.`;
}
return scene;
}
// API Calls - now go through backend
async function getClaudeResponse(userMessage) {
const conversationHistory = state.messages.map(m => ({
role: m.role,
content: m.content
}));
const sceneContext = buildSceneContext();
const systemPrompt = state.tensionMode
? `${sceneContext}\n\n${CONFIG.systemPromptTension}`
: `${sceneContext}\n\n${CONFIG.systemPromptStandard}`;
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: conversationHistory,
system: systemPrompt,
max_tokens: 300
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'API request failed');
}
const data = await response.json();
return data.content[0].text;
}
async function getPatternAnalysis() {
if (state.reflections.length === 0) {
return "No reflections were recorded during this session. That's okay — sometimes the practice is simply in the waiting itself. |aI| invite you to notice: what was it like to sit in those pauses without capturing words?";
}
const reflectionsText = state.reflections
.map((r) => `Round ${r.round} (${r.waitTime.toFixed(1)}s wait): "${r.text}"`)
.join('\n');
const conversationSummary = state.messages
.map(m => `${m.role === 'user' ? 'User' : 'Partner'}: ${m.content}`)
.join('\n');
const contextNote = state.tensionMode
? 'Note: The user opted into "stretch mode" with mild relational friction enabled.'
: '';
let prompt = CONFIG.analysisPrompt
.replace('{REFLECTIONS}', reflectionsText)
.replace('{CONVERSATION_CONTEXT}', contextNote ? `\n${contextNote}\n\nConversation overview:\n${conversationSummary}` : '');
try {
const response = await fetch('/api/analysis', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt,
max_tokens: 1000
})
});
if (!response.ok) {
throw new Error('Failed to get analysis');
}
const data = await response.json();
return data.content[0].text;
} catch (error) {
console.error('Analysis error:', error);
return "|aI| wasn't able to generate a reflection on your entries. Please take a moment to review them yourself — what patterns or shifts do you notice?";
}
}
async function handleEndSession() {
showScreen('analysis');
await populateAnalysis();
}
async function populateAnalysis() {
const exchanges = state.messages.filter(m => m.role === 'user').length;
const delays = state.delayHistory;
const minDelay = delays.length ? Math.min(...delays).toFixed(1) : 0;
const maxDelay = delays.length ? Math.max(...delays).toFixed(1) : 0;
elements.totalExchanges.textContent = exchanges;
elements.delayRange.textContent = `${minDelay}-${maxDelay}s`;
elements.totalReflections.textContent = state.reflections.length;
elements.allReflections.innerHTML = '';
if (state.reflections.length === 0) {
elements.allReflections.innerHTML = '<p class="muted">No reflections were recorded.</p>';
} else {
state.reflections.forEach(r => {
const entry = document.createElement('div');
entry.className = 'reflection-entry';
entry.innerHTML = `
<div class="wait-time">Round ${r.round} · ${r.waitTime.toFixed(1)}s wait</div>
<div class="reflection-text">${escapeHtml(r.text)}</div>
`;
elements.allReflections.appendChild(entry);
});
}
elements.analysisContent.innerHTML = '<p class="loading-text">Preparing your clinical debrief...</p>';
const analysis = await getPatternAnalysis();
elements.analysisContent.innerHTML = formatAnalysis(analysis);
}
function formatAnalysis(text) {
let html = escapeHtml(text);
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.split('\n\n').map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join('');
html = html.replace(/<strong>(Somatic Arc|Attachment Insight|Practical Skill|Bold Reframe|Journaling Prompt)<\/strong>/g,
'<strong class="section-header">$1</strong>');
return html;
}
function handleExport() {
const sessionData = {
date: new Date().toISOString(),
settings: { showTimer: state.showTimer, tensionMode: state.tensionMode },
stats: {
exchanges: state.messages.filter(m => m.role === 'user').length,
reflections: state.reflections.length,
delays: state.delayHistory
},
messages: state.messages,
reflections: state.reflections,
bridgeReflection: elements.bridgeReflection.value
};
const blob = new Blob([JSON.stringify(sessionData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tolerate-space-lab-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
function handleNewSession() {
state.round = 0;
state.messages = [];
state.reflections = [];
state.delayHistory = [];
state.isWaiting = false;
elements.messagesContainer.innerHTML = '';
elements.reflectionEntries.innerHTML = '';
elements.journalInput.value = '';
elements.userInput.value = '';
elements.bridgeReflection.value = '';
showScreen('welcome');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
document.addEventListener('DOMContentLoaded', init);