import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { createServer as createViteServer } from 'vite'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const port = Number(process.env.PORT || 3000); const isProduction = process.env.NODE_ENV === 'production'; const model = 'cohere/north-mini-code:free'; const storageDir = process.env.DATA_DIR || (fs.existsSync('/data') ? '/data' : path.join(__dirname, 'data')); const submissionsFile = path.join(storageDir, 'submissions.json'); function loadSubmissions() { try { const parsed = JSON.parse(fs.readFileSync(submissionsFile, 'utf8')); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } let submissions = loadSubmissions(); function persistSubmissions() { fs.mkdirSync(storageDir, { recursive: true }); const tempFile = `${submissionsFile}.tmp`; fs.writeFileSync(tempFile, JSON.stringify(submissions, null, 2)); fs.renameSync(tempFile, submissionsFile); } const fallbackReading = { score: 58, label: 'offline reflection — not a model read', read: 'The requested model could not be reached, so this is only a rough local reflection. There may be care here, but a conversation still has to do the work that a score cannot.', careSignal: 'unverified until it shows up in action', confidence: 'low', source: 'offline-fallback', }; function sendJson(res, status, body) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); } function cleanString(value, max = 800) { return typeof value === 'string' ? value.trim().slice(0, max) : ''; } function parseModelJson(content) { const stripped = content.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim(); const start = stripped.indexOf('{'); const end = stripped.lastIndexOf('}'); if (start === -1 || end === -1) throw new Error('Model response was not JSON'); return JSON.parse(stripped.slice(start, end + 1)); } function normalizeReading(raw) { const score = Math.max(0, Math.min(100, Math.round(Number(raw.score) || fallbackReading.score))); return { score, label: cleanString(raw.label, 80) || fallbackReading.label, read: cleanString(raw.read, 320) || fallbackReading.read, careSignal: cleanString(raw.careSignal, 100) || fallbackReading.careSignal, confidence: cleanString(raw.confidence, 30) || fallbackReading.confidence, source: raw.source === 'offline-fallback' ? 'offline-fallback' : 'model', }; } function localReflection(payload) { const text = `${cleanString(payload.apology, 700)} ${Array.isArray(payload.promises) ? payload.promises.join(' ') : ''}`.toLowerCase(); let score = 46; if (text.length > 160) score += 8; if (/(hurt|impact|carried|understand|listen|need|trust|show up|ordinary|defend)/.test(text)) score += 12; if (/(miss you|lonely|i need you|come back|my pain|my heart)/.test(text)) score -= 5; if (/(sorry|change|respect|pace|choice|choose)/.test(text)) score += 6; return { ...fallbackReading, score: Math.max(35, Math.min(74, score)) }; } function findSubmission(id) { return submissions.find((submission) => submission.id === id); } function latestSubmission() { return [...submissions].sort((a, b) => b.createdAt - a.createdAt)[0]; } function passwordMatches(supplied) { const expected = process.env.OWNER_VIEW_PASSWORD || ''; const value = cleanString(supplied, 200); return Boolean(expected && value && Buffer.byteLength(expected) === Buffer.byteLength(value) && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(value))); } async function rateSincerity(payload) { const apiKey = process.env.OPENROUTER_API_KEY; if (!apiKey) return localReflection(payload); const system = `You are a careful relationship reflection assistant. You cannot detect lies or know intent; never claim certainty. Given an apology, promises, consent statement, and name, produce an imperfect impression of how specific, accountable, and other-aware the answer sounds. Do not reward romantic intensity alone. Penalize vague promises, self-pity, pressure, blame shifting, or centering only the apologizer's relief. Reward naming impact, concrete behavior change, patience, and respect for the other person's choice. Return ONLY valid JSON with these keys: score (integer 0-100), label (short lowercase phrase), read (2-3 warm but direct sentences), careSignal (short phrase), confidence (one of low, medium, high). Use a nuanced score; do not give 90+ unless the text is unusually specific and accountable.`; const user = JSON.stringify({ name: cleanString(payload.name, 80), promises: Array.isArray(payload.promises) ? payload.promises.slice(0, 3).map((item) => cleanString(item, 140)) : [], apology: cleanString(payload.apology, 700), consent: Boolean(payload.consent), }); const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://secondchance.manus.app', 'X-Title': 'Second Chance sincerity reflection', }, body: JSON.stringify({ model, temperature: 0.45, max_tokens: 260, messages: [{ role: 'system', content: system }, { role: 'user', content: user }], }), }); if (!response.ok) throw new Error(`Model request failed (${response.status})`); const data = await response.json(); const content = data?.choices?.[0]?.message?.content; if (typeof content !== 'string') throw new Error('Model response was empty'); return normalizeReading({ ...parseModelJson(content), source: 'model' }); } async function readBody(req) { let body = ''; for await (const chunk of req) { body += chunk; if (body.length > 12000) throw new Error('Request too large'); } return JSON.parse(body || '{}'); } async function handleApi(req, res) { if (req.method === 'GET' && req.url === '/api/latest-submission') { const latest = latestSubmission(); sendJson(res, 200, latest ? { available: true, submissionId: latest.id } : { available: false }); return true; } if (req.method === 'POST' && req.url === '/api/owner/submissions') { try { const payload = await readBody(req); if (!passwordMatches(payload.password)) { sendJson(res, 401, { error: 'That key does not open this room.' }); return true; } sendJson(res, 200, { submissions: [...submissions].sort((a, b) => b.createdAt - a.createdAt) }); } catch (error) { console.error('[owner-submissions]', error.message); sendJson(res, 400, { error: 'The private key could not be checked.' }); } return true; } if (req.method === 'GET' && req.url.startsWith('/api/owner/submissions/')) { const id = cleanString(req.url.split('/').pop(), 100); const submission = findSubmission(id); if (!submission) { sendJson(res, 404, { error: 'Submission not found.' }); return true; } sendJson(res, 200, { submission }); return true; } if (req.method === 'POST' && req.url === '/api/owner-unlock') { try { const payload = await readBody(req); const submission = findSubmission(cleanString(payload.submissionId, 100)) || latestSubmission(); if (!passwordMatches(payload.password) || !submission) { sendJson(res, 401, { error: 'That key does not open this room.' }); return true; } sendJson(res, 200, { ok: true, submission: { name: submission.name, promises: submission.promises, apology: submission.apology, reading: submission.reading, }, }); return true; } catch (error) { console.error('[owner-unlock]', error.message); sendJson(res, 400, { error: 'The private key could not be checked.' }); return true; } } if (req.method !== 'POST' || req.url !== '/api/sincerity') return false; try { const payload = await readBody(req); if (!cleanString(payload.name, 80) || !cleanString(payload.apology, 700) || !Array.isArray(payload.promises) || payload.promises.length < 3 || payload.consent !== true) { sendJson(res, 400, { error: 'Complete the name, three promises, hard part, and consent first.' }); return true; } const normalizedPayload = { name: cleanString(payload.name, 80), promises: payload.promises.slice(0, 3).map((item) => cleanString(item, 140)), apology: cleanString(payload.apology, 700), consent: true, }; let reading; try { reading = await rateSincerity(normalizedPayload); } catch (error) { console.error('[sincerity]', error.message); reading = localReflection(normalizedPayload); } const submissionId = crypto.randomUUID(); submissions.push({ id: submissionId, ...normalizedPayload, reading, createdAt: Date.now(), viewed: false, decision: 'pending', updatedAt: Date.now(), }); persistSubmissions(); sendJson(res, 200, { sealed: true, submissionId }); } catch (error) { console.error('[sincerity]', error.message); sendJson(res, 200, localReflection({})); } return true; } function serveStatic(req, res) { const requestPath = new URL(req.url, 'http://localhost').pathname; const relative = requestPath === '/' ? 'index.html' : requestPath === '/owner' ? 'owner.html' : requestPath.replace(/^\//, ''); const filePath = path.resolve(__dirname, isProduction ? 'dist' : 'public', relative); const basePath = path.resolve(__dirname, isProduction ? 'dist' : 'public'); if (!filePath.startsWith(basePath)) { res.writeHead(403); res.end('Forbidden'); return; } if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { const ext = path.extname(filePath); const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.json': 'application/json', '.mp3': 'audio/mpeg' }; res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream' }); fs.createReadStream(filePath).pipe(res); } else { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not found'); } } const server = http.createServer(async (req, res) => { if (await handleApi(req, res)) return; if (!isProduction) { const vite = server.vite; vite.middlewares(req, res, () => serveStatic(req, res)); } else { serveStatic(req, res); } }); if (!isProduction) { server.vite = await createViteServer({ server: { middlewareMode: true, hmr: false }, appType: 'spa', plugins: [{ name: 'remove-preview-hmr-client', transformIndexHtml: { order: 'post', handler: (html) => html.replace(/\s*