Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 4 additions & 11 deletions .env.secrets.example
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
# Required for Namecheap API DNS automation script.
# Keep real values in .env.secrets (already gitignored).
# Local-only secrets (kept out of git).
# Copy to `.env.secrets` and add only what you need for your environment.

NAMECHEAP_API_KEY=replace-me
NAMECHEAP_API_USER=replace-me
NAMECHEAP_USERNAME=replace-me
NAMECHEAP_CLIENT_IP=replace-me
NAMECHEAP_DOMAIN=yappybara.dev

# Optional: defaults to obsecurus.github.io
GITHUB_PAGES_USER=obsecurus
# GITHUB_PAGES_CNAME_TARGET=obsecurus.github.io
# Example:
# ANTHROPIC_API_KEY=sk-ant-...
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,6 @@ archive/

# Devcontainer local cert material
.devcontainer/certs/

# Local-only helper scripts and secrets
.local/
1 change: 0 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ make interview-test

- `site/` is deployed by `.github/workflows/pages.yml`
- custom domain target: `yappybara.dev`
- DNS automation script: `scripts/configure-namecheap-github-pages.mjs`

## Release posture

Expand Down
11 changes: 0 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,6 @@ The public site is in `site/` and deploys via `.github/workflows/pages.yml`.
Setup guide:
- `docs/github-pages.md`

## Namecheap DNS automation

Use the existing script to configure apex + `www` for GitHub Pages:

```bash
node scripts/configure-namecheap-github-pages.mjs
node scripts/configure-namecheap-github-pages.mjs --apply
```

Required secrets are documented in `.env.secrets.example`.

## Repository layout

```text
Expand Down
32 changes: 2 additions & 30 deletions docs/github-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ After Pages is enabled:
1. In `Settings -> Pages`, set custom domain to `yappybara.dev`
2. Enable HTTPS once DNS has propagated

## 3) Namecheap DNS records
## 3) DNS records

For GitHub Pages custom domain support:

Expand All @@ -34,39 +34,11 @@ For GitHub Pages custom domain support:
- `@ A 185.199.111.153`
- `www CNAME obsecurus.github.io`

## 4) DNS automation script

Dry run:

```bash
node scripts/configure-namecheap-github-pages.mjs
```

Apply:

```bash
node scripts/configure-namecheap-github-pages.mjs --apply
```

Required `.env.secrets` values:

- `NAMECHEAP_API_KEY`
- `NAMECHEAP_API_USER`
- `NAMECHEAP_USERNAME`
- `NAMECHEAP_CLIENT_IP`
- `NAMECHEAP_DOMAIN` (defaults to `yappybara.dev`)

Optional:

- `GITHUB_PAGES_USER` (defaults to `obsecurus`)
- `GITHUB_PAGES_CNAME_TARGET` (defaults to `${GITHUB_PAGES_USER}.github.io`)

## 5) Verify
## 4) Verify

- Pages URL: `https://obsecurus.github.io/yappybara/`
- Custom domain: `https://yappybara.dev`

If custom domain is still pending:
- verify DNS records,
- verify Namecheap API whitelist for `NAMECHEAP_CLIENT_IP`,
- wait for DNS propagation.
92 changes: 76 additions & 16 deletions interview-app/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2249,6 +2249,10 @@ <h3 id="parent-gate-title">Parent Supervision Required</h3>
lastSpokenEndedAt: 0,
};

const PREVIEW_BLOCK_MATCH_RE = /---\s*PREVIEW\s*---([\s\S]*?)---\s*END_PREVIEW\s*---/i;
const PREVIEW_BLOCK_STRIP_RE = /---\s*PREVIEW\s*---[\s\S]*?---\s*END_PREVIEW\s*---/gi;
const BUILD_PHASE_BLOCK_STRIP_RE = /---\s*BUILD(?:_|[\s-]+)PHASE\s*---[\s\S]*?---\s*END(?:_|[\s-]+)BUILD(?:_|[\s-]+)PHASE\s*---/gi;

// ===== DOM refs =====
const $chat = document.getElementById('chat');
const $input = document.getElementById('text-input');
Expand Down Expand Up @@ -3388,9 +3392,9 @@ <h3 id="parent-gate-title">Parent Supervision Required</h3>
if (buildPollTimer) return Promise.resolve();
// Strip markdown + emoji for speech (emoji still shows in chat, just not spoken)
const clean = text
.replace(/---BUILD_PHASE---[\s\S]*?---END_BUILD_PHASE---/g, '')
.replace(BUILD_PHASE_BLOCK_STRIP_RE, '')
// Avoid speaking preview boilerplate; STT can hear it and create loops.
.replace(/---PREVIEW---[\s\S]*?---END_PREVIEW---/g, '')
.replace(PREVIEW_BLOCK_STRIP_RE, '')
.replace(/---INTERVIEW_COMPLETE---[\s\S]*---END_INTERVIEW---/g, 'I\'ve put together a summary of your app idea!')
.replace(/[#*_`\[\]()>]/g, '')
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu, '')
Expand Down Expand Up @@ -3635,10 +3639,10 @@ <h3 id="parent-gate-title">Parent Supervision Required</h3>
function sanitizeAiDisplayText(text) {
if (!text) return text;
// Never show build internals in chat bubbles.
let cleaned = text.replace(/---BUILD_PHASE---[\s\S]*?---END_BUILD_PHASE---/g, '');
let cleaned = text.replace(BUILD_PHASE_BLOCK_STRIP_RE, '');
// Protect preview HTML from text sanitization so tags remain valid.
const previewBlocks = [];
cleaned = cleaned.replace(/---PREVIEW---[\s\S]*?---END_PREVIEW---/g, (match) => {
cleaned = cleaned.replace(PREVIEW_BLOCK_STRIP_RE, (match) => {
previewBlocks.push(match);
return `__PREVIEW_${previewBlocks.length - 1}__`;
});
Expand Down Expand Up @@ -3703,9 +3707,9 @@ <h3 id="parent-gate-title">Parent Supervision Required</h3>
} else if (role === 'ai') {
// Extract and render preview blocks
let displayText = text;
const previewMatch = text.match(/---PREVIEW---([\s\S]*?)---END_PREVIEW---/);
const previewMatch = text.match(PREVIEW_BLOCK_MATCH_RE);
if (previewMatch) {
displayText = text.replace(/---PREVIEW---[\s\S]*?---END_PREVIEW---/, '').trim();
displayText = text.replace(PREVIEW_BLOCK_STRIP_RE, '').trim();
}

// Highlight the question so the kid knows what to answer
Expand Down Expand Up @@ -3853,6 +3857,27 @@ <h3>Meet Your Build Crew!</h3>
}

// ===== API Communication =====
async function syncBuildProgressFromServer() {
try {
const resp = await fetch('/api/build-status');
if (!resp.ok) return false;
const data = await resp.json();

if (data.status === 'building') {
switchTab('dash');
startBuildPolling();
return true;
}

if (data.status === 'done' && data.previewUrl) {
updateBuildState('done', data.previewUrl.replace(/^\//, ''));
}
} catch (err) {
// Ignore transient polling errors and keep chat flow moving.
}
return false;
}

async function sendMessage(text, imageInfo) {
if (state.isWaiting) return;
if (!text && !imageInfo) return;
Expand Down Expand Up @@ -3930,15 +3955,19 @@ <h3>Meet Your Build Crew!</h3>
setBartMood('excited');
setTimeout(() => { if (bartMood === 'excited') setBartMood('idle'); }, 500);
addMessage('ai', aiText);
await speak(aiText);

// If build phase was triggered, switch to Dash tab
let buildStarted = false;
if (data.buildPhase) {
addMessage('ai', 'Dash is on it! Switch to the Dash tab to watch the progress and have fun while you wait!');
setTimeout(() => {
switchTab('dash');
startBuildPolling();
}, 1500);
addSystemMessage('Dash is on it! Opening Dash so you can watch the build.');
switchTab('dash');
startBuildPolling();
buildStarted = true;
} else {
buildStarted = await syncBuildProgressFromServer();
}

if (!buildStarted) {
await speak(aiText);
}
} catch (err) {
hideTyping();
Expand Down Expand Up @@ -5447,8 +5476,22 @@ <h2>What You Built</h2>
const sysMsg = document.querySelector('#chat .message.system');
if (sysMsg) sysMsg.remove();
addMessage('ai', chatData.response);
await speak(chatData.response);
if (state.recognition) {

let buildStarted = false;
if (chatData.buildPhase) {
addSystemMessage('Dash is on it! Opening Dash so you can watch the build.');
switchTab('dash');
startBuildPolling();
buildStarted = true;
} else {
buildStarted = await syncBuildProgressFromServer();
}

if (!buildStarted) {
await speak(chatData.response);
}

if (state.recognition && !buildPollTimer) {
state.micActive = true;
startListening();
}
Expand All @@ -5475,6 +5518,10 @@ <h2>What You Built</h2>
// Update build state
const buildStatus = data.buildStatus || 'idle';
updateBuildState(buildStatus, data.buildPath);
if (buildStatus === 'building') {
switchTab('dash');
startBuildPolling();
}
renderDeployStatus(data.deployRequest || null);

enterAppMode();
Expand Down Expand Up @@ -5819,7 +5866,20 @@ <h2>What You Built</h2>
refreshParentGuide();
state.messages.push({ role: 'assistant', content: data.response });
addMessage('ai', data.response);
await speak(data.response);

let buildStarted = false;
if (data.buildPhase) {
addSystemMessage('Dash is on it! Opening Dash so you can watch the build.');
switchTab('dash');
startBuildPolling();
buildStarted = true;
} else {
buildStarted = await syncBuildProgressFromServer();
}

if (!buildStarted) {
await speak(data.response);
}
} catch(e) { hideTyping(); }
state.isWaiting = false;
setBartMood('idle');
Expand Down
7 changes: 5 additions & 2 deletions interview-app/safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ export function sanitizePreviewHtml(html) {
return cleaned;
}

const PREVIEW_BLOCK_RE = /---\s*PREVIEW\s*---([\s\S]*?)---\s*END_PREVIEW\s*---/gi;
const BUILD_PHASE_BLOCK_RE = /---\s*BUILD(?:_|[\s-]+)PHASE\s*---([\s\S]*?)---\s*END(?:_|[\s-]+)BUILD(?:_|[\s-]+)PHASE\s*---/gi;

// Hard guardrail — runs on every Bart response before it reaches the kid
export function sanitizeBartResponse(text) {
if (!text) return text;
Expand All @@ -115,11 +118,11 @@ export function sanitizeBartResponse(text) {
// (they contain HTML/spec content which has legit tech words)
const previewBlocks = [];
const buildBlocks = [];
let cleaned = text.replace(/---PREVIEW---([\s\S]*?)---END_PREVIEW---/g, (match) => {
let cleaned = text.replace(PREVIEW_BLOCK_RE, (match) => {
previewBlocks.push(match);
return `__PREVIEW_${previewBlocks.length - 1}__`;
});
cleaned = cleaned.replace(/---BUILD_PHASE---([\s\S]*?)---END_BUILD_PHASE---/g, (match) => {
cleaned = cleaned.replace(BUILD_PHASE_BLOCK_RE, (match) => {
buildBlocks.push(match);
return `__BUILD_${buildBlocks.length - 1}__`;
});
Expand Down
11 changes: 7 additions & 4 deletions interview-app/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ if (BUILDS_DIR !== LEGACY_BUILDS_DIR) {
}

const BUILD_NUDGE_MINUTES = 1; // After this many minutes, nudge Bart to build
const PREVIEW_BLOCK_RE = /---\s*PREVIEW\s*---[\s\S]*?---\s*END_PREVIEW\s*---/gi;
const BUILD_PHASE_BLOCK_RE = /---\s*BUILD(?:_|[\s-]+)PHASE\s*---([\s\S]*?)---\s*END(?:_|[\s-]+)BUILD(?:_|[\s-]+)PHASE\s*---/i;
const BUILD_PHASE_BLOCK_RE_GLOBAL = /---\s*BUILD(?:_|[\s-]+)PHASE\s*---[\s\S]*?---\s*END(?:_|[\s-]+)BUILD(?:_|[\s-]+)PHASE\s*---/gi;
const REQUESTED_ASSIST_MODE = (process.env.YAPPY_MODE || 'auto').toLowerCase();
const VALID_ASSIST_MODES = new Set(['auto', 'claude', 'demo']);
const REQUESTED_BILLING_MODE = (process.env.YAPPY_BILLING_MODE || 'subscription').toLowerCase();
Expand Down Expand Up @@ -672,8 +675,8 @@ function userMessageTexts(messages) {
function cleanIdeaText(raw) {
return String(raw || '')
// Remove preview/build payloads if they somehow appear in message history.
.replace(/---PREVIEW---[\s\S]*?---END_PREVIEW---/g, ' ')
.replace(/---BUILD_PHASE---[\s\S]*?---END_BUILD_PHASE---/g, ' ')
.replace(PREVIEW_BLOCK_RE, ' ')
.replace(BUILD_PHASE_BLOCK_RE_GLOBAL, ' ')
// Remove html/xml tags to avoid parsing attribute values as app names.
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
Expand Down Expand Up @@ -1224,11 +1227,11 @@ app.post('/api/chat', async (req, res) => {
});

// Detect build phase trigger
const buildMatch = responseText.match(/---BUILD_PHASE---([\s\S]*?)---END_BUILD_PHASE---/);
const buildMatch = responseText.match(BUILD_PHASE_BLOCK_RE);
const currentBuildStatus = activeApp.buildStatus || 'idle';
if (buildMatch && (currentBuildStatus === 'idle' || currentBuildStatus === 'done' || currentBuildStatus === 'failed')) {
const spec = buildMatch[1].trim();
const displayText = responseText.replace(/---BUILD_PHASE---[\s\S]*?---END_BUILD_PHASE---/, '').trim();
const displayText = responseText.replace(BUILD_PHASE_BLOCK_RE_GLOBAL, '').trim();
logAction('build_phase', { spec: spec.slice(0, 500), appId: activeApp.id });

updateActiveApp({ buildStatus: 'building', spec });
Expand Down
61 changes: 61 additions & 0 deletions interview-app/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,19 @@ Idea: Build with HTML and API calls.
assert.ok(cleaned.includes('---END_BUILD_PHASE---'));
});

it('restores flexible build phase marker variants', () => {
const raw = `Ready to build!

--- build-phase ---
App Name: Rocket Painter
Idea: Paint stars and rockets.
--- end build-phase ---`;
const cleaned = sanitizeBartResponse(raw);
assert.ok(cleaned.includes('--- build-phase ---'));
assert.ok(cleaned.includes('App Name: Rocket Painter'));
assert.ok(cleaned.includes('--- end build-phase ---'));
});

it('does not alter interview complete payload', () => {
const raw = `---INTERVIEW_COMPLETE---
# Dream App
Expand Down Expand Up @@ -766,6 +779,54 @@ describe('API Endpoints (integration — requires server)', () => {
assert.equal(r.status, 400);
});

it('POST /api/chat triggers build phase and publishes build status', async (t) => {
if (skipIfNoServer()) return t.skip('Server not available');

const createResp = await fetch(`${BASE}/api/apps`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Build Trigger Test' }),
});
const { id } = await createResp.json();
await fetch(`${BASE}/api/apps/${id}/activate`, { method: 'PUT' });

const messages = [];
let buildTriggered = false;

for (let i = 1; i <= 7; i++) {
messages.push({ role: 'user', content: `Turn ${i}: I want a fun drawing app for kids.` });
const chatResp = await fetch(`${BASE}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
});
assert.equal(chatResp.status, 200);
const chatData = await chatResp.json();
messages.push({ role: 'assistant', content: chatData.response });
if (chatData.buildPhase) {
buildTriggered = true;
break;
}
}

assert.equal(buildTriggered, true, 'Expected build phase to trigger in demo mode');

let buildStatus = null;
let previewUrl = null;
for (let i = 0; i < 15; i++) {
const statusResp = await fetch(`${BASE}/api/build-status`);
assert.equal(statusResp.status, 200);
const statusData = await statusResp.json();
buildStatus = statusData.status;
previewUrl = statusData.previewUrl || null;
if (buildStatus === 'done' || buildStatus === 'failed') break;
await new Promise(r => setTimeout(r, 200));
}

assert.equal(buildStatus, 'done');
assert.ok(previewUrl && previewUrl.startsWith('/builds/'));
});

it('GET /api/build-status returns idle when no active app', async (t) => {
if (skipIfNoServer()) return t.skip('Server not available');
await fetch(`${BASE}/api/start-over`, { method: 'POST' });
Expand Down
Loading