diff --git a/app.js b/app.js index e1f28a6..5ff3af6 100644 --- a/app.js +++ b/app.js @@ -1,4 +1,4 @@ -import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, exportLedger, importLedger, photoQualityMessage, sanitizeEntry } from './src/domain.js'; +import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, detectUrgentText, exportLedger, hasUrgentLedgerContext, importLedger, photoQualityMessage, sanitizeEntry, urgentChatMessage } from './src/domain.js'; import { mergeVisualSuggestion } from './src/analysis.js'; const STORE = 'timmy-ledger-v1'; @@ -84,15 +84,11 @@ function localChatReply(message){ if(/food|eat|restaurant|taco/.test(lower))return 'A bowel journal cannot clear a food or restaurant. I can help you compare confirmed entries over time, not decide what is safe to eat.'; return buildTimmySummary(entries); } -function urgentChatReply(message){ - if(!/(blood|black (?:or |and )?dark[- ]?red stool|black stool|dark[- ]?red stool|severe|constant abdominal pain|vomit|fever|cannot pass gas|can['’]?t pass gas)/i.test(message))return ''; - return 'Pause and get medical help. Those symptoms can need prompt medical assessment. Heavy or nonstop bleeding, fainting, or severe worsening symptoms can be an emergency—call local emergency services.'; -} function ledgerForAgent(){return entries.slice(-20).map(({photoDataUrl,...entry})=>entry)} async function sendChat(event){ event.preventDefault();if(chatBusy)return;const input=document.querySelector('#chat-message');const message=String(input?.value||'').trim();if(!message)return; chatMessages.push({role:'user',text:message});chatError='';input.value=''; - const urgent=urgentChatReply(message);if(urgent){chatMessages.push({role:'timmy',text:urgent});timmy();return} + const urgent=detectUrgentText(message).urgent||hasUrgentLedgerContext(ledgerForAgent());if(urgent){chatMessages.push({role:'timmy',text:urgentChatMessage});timmy();return} if(!agentStatus?.authenticated){chatMessages.push({role:'timmy',text:localChatReply(message)});timmy();return} chatBusy=true;timmy(); try{const response=await fetch('/api/agent/chat',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({message,ledger:ledgerForAgent()})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Hermes is unavailable.');chatMessages.push({role:'timmy',text:data.reply})} diff --git a/artifacts/sleek-hermes-chat-mobile.png b/artifacts/sleek-hermes-chat-mobile.png index 0d1f87c..40563f3 100644 Binary files a/artifacts/sleek-hermes-chat-mobile.png and b/artifacts/sleek-hermes-chat-mobile.png differ diff --git a/artifacts/timmy-chat-mobile.png b/artifacts/timmy-chat-mobile.png index 0631fc4..ec91b53 100644 Binary files a/artifacts/timmy-chat-mobile.png and b/artifacts/timmy-chat-mobile.png differ diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs index ebda7d7..afc099d 100644 --- a/scripts/record_release_demo.mjs +++ b/scripts/record_release_demo.mjs @@ -50,11 +50,15 @@ await page.route('**/api/agent/status', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ enabled: true, configured: true, authenticated: true, mode: 'hermes-agent' }), })); -await page.route('**/api/agent/chat', route => route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ connected: true, reply: 'Your confirmed logs are mostly Type 4. I can explain that pattern, but I cannot diagnose a cause.' }), -})); +let hermesCalls = 0; +await page.route('**/api/agent/chat', route => { + hermesCalls += 1; + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ connected: true, reply: 'Your confirmed logs are mostly Type 4. I can explain that pattern, but I cannot diagnose a cause.' }), + }); +}); await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' }); await page.evaluate(() => localStorage.clear()); @@ -117,8 +121,13 @@ await caption('Hermes Agent connected — free-text, contextual, and server-side await page.locator('#chat-message').fill('What pattern do you see?'); await tap('#send-chat', 500); await page.getByText(/mostly Type 4/i).waitFor(); -await caption('Hermes keeps credentials, tools, and session continuity server-side. Photos stay out of chat.', 1800); -await sleep(500); +await caption('Hermes keeps credentials, tools, and session continuity server-side. Photos stay out of chat.', 1600); +await page.locator('#chat-message').fill('I have rectal bleeding'); +await tap('#send-chat', 450); +await page.getByText(/Pause and get medical help/i).waitFor(); +if (hermesCalls !== 1) throw new Error('Urgent chat must be intercepted before Hermes'); +await caption('Urgent language is intercepted deterministically before Hermes', 1800); +await sleep(400); await page.evaluate(() => { document.querySelector('#demo-caption')?.remove(); const outro = document.createElement('div'); diff --git a/src/domain.js b/src/domain.js index 3ef534d..4e5b42e 100644 --- a/src/domain.js +++ b/src/domain.js @@ -1,4 +1,13 @@ const URGENT_KEYS = ['blood', 'blackOrDarkRed', 'severePain', 'vomiting', 'fever', 'cannotPassGas']; +const URGENT_MESSAGE = 'These reported symptoms can need prompt medical care. Contact a clinician or urgent service now; call emergency services for heavy or nonstop bleeding, fainting, or severe worsening symptoms.'; +const URGENT_TEXT_PATTERNS = Object.freeze([ + ['blood', /\b(?:rectal bleeding|bleeding from (?:the )?(?:rectum|bottom)|blood(?:y)? (?:in|on|with) (?:my |the )?(?:stool|poop|bowel movement)|(?:stool|poop) (?:has|contains|with) blood)\b/i], + ['blackOrDarkRed', /\b(?:(?:black|dark[- ]?red) (?:stool|poop|bowel movement)|(?:stool|poop|bowel movement) (?:is|looks?) (?:black|dark[- ]?red))s?\b/i], + ['severePain', /\b(?:severe|constant|unrelenting) (?:abdominal|stomach|belly) pain\b/i], + ['vomiting', /\b(?:vomit(?:ing|ed)?|throwing up)\b/i], + ['fever', /\bfever(?:ish)?\b/i], + ['cannotPassGas', /\b(?:cannot|can['’]?t|cant|unable to|not able to) pass gas\b/i], +]); export function bucketForBristolType(type) { const value = Number(type); @@ -14,11 +23,22 @@ export function detectUrgentFlags(symptoms = {}) { urgent: flags.length > 0, flags, message: flags.length - ? 'These reported symptoms can need prompt medical care. Contact a clinician or urgent service now; call emergency services for heavy or nonstop bleeding, fainting, or severe worsening symptoms.' + ? URGENT_MESSAGE : 'No urgent symptom was selected. This tracker is not a diagnosis; seek care whenever you are worried or symptoms persist.', }; } +export function detectUrgentText(text = '') { + const flags = URGENT_TEXT_PATTERNS.filter(([, pattern]) => pattern.test(String(text))).map(([key]) => key); + return { urgent: flags.length > 0, flags, message: flags.length ? URGENT_MESSAGE : '' }; +} + +export function hasUrgentLedgerContext(entries = []) { + return Array.isArray(entries) && entries.some(entry => detectUrgentFlags(entry?.symptoms).urgent || detectUrgentText(entry?.note).urgent); +} + +export const urgentChatMessage = `Pause and get medical help. ${URGENT_MESSAGE}`; + export function buildTimmySummary(entries = []) { if (!entries.length) return 'No logs yet. Add one when you are ready and I’ll summarize the pattern—not diagnose it.'; const counts = entries.reduce((acc, entry) => { diff --git a/src/hermes-agent-service.js b/src/hermes-agent-service.js index 75b702b..d5f5224 100644 --- a/src/hermes-agent-service.js +++ b/src/hermes-agent-service.js @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process'; import { randomBytes, timingSafeEqual } from 'node:crypto'; import { isAbsolute } from 'node:path'; +import { detectUrgentText, hasUrgentLedgerContext, urgentChatMessage } from './domain.js'; const MAX_MESSAGE_CHARS = 4000; const MAX_LEDGER_ENTRIES = 20; @@ -190,6 +191,8 @@ export function createHermesAgentService({ if (!message) throw new AgentGatewayError(400, 'Write a message first.'); if (message.length > MAX_MESSAGE_CHARS) throw new AgentGatewayError(413, 'Message is too long.'); const ledger = sanitizeLedger(payload.ledger || []); + const urgent = detectUrgentText(message).urgent || hasUrgentLedgerContext(ledger); + if (urgent) return { reply: urgentChatMessage, connected: true, safetyOverride: true }; const cutoff = now() - RATE_WINDOW_MS; session.requests = session.requests.filter(time => time > cutoff); if (session.requests.length >= config.maxRequestsPerMinute || session.busy) throw new AgentGatewayError(429, 'Timmy is already thinking. Try again shortly.'); diff --git a/tests/agent-gateway.acceptance.test.js b/tests/agent-gateway.acceptance.test.js index cae18f1..36e1be2 100644 --- a/tests/agent-gateway.acceptance.test.js +++ b/tests/agent-gateway.acceptance.test.js @@ -82,6 +82,19 @@ test('HTTP gateway binds browser auth to one opaque server-side Hermes conversat assert.match(second.reply, /Continuity confirmed/); assert.doesNotMatch(JSON.stringify(second), /fixture_session/); + response = await post('/api/agent/chat', { message: 'I have rectal bleeding', ledger: [] }, { cookie: browserCookie }); + assert.equal(response.status, 200); + const urgentMessage = await response.json(); + assert.equal(urgentMessage.safetyOverride, true); + assert.match(urgentMessage.reply, /medical help/i); + assert.doesNotMatch(urgentMessage.reply, /bounded text-only|Continuity confirmed/i); + + response = await post('/api/agent/chat', { message: 'Summarize this.', ledger: [{ bristolType: 4, color: 'brown', symptoms: { cannotPassGas: true } }] }, { cookie: browserCookie }); + assert.equal(response.status, 200); + const urgentLedger = await response.json(); + assert.equal(urgentLedger.safetyOverride, true); + assert.match(urgentLedger.reply, /medical help/i); + response = await post('/api/agent/chat', { message: 'Hijack', ledger: [], sessionId: 'attacker-session' }, { cookie: browserCookie }); assert.equal(response.status, 400); diff --git a/tests/domain.test.js b/tests/domain.test.js index bb45da1..8ac7646 100644 --- a/tests/domain.test.js +++ b/tests/domain.test.js @@ -5,7 +5,9 @@ import { bucketForBristolType, buildTimmySummary, detectUrgentFlags, + detectUrgentText, exportLedger, + hasUrgentLedgerContext, photoQualityMessage, sanitizeEntry, } from '../src/domain.js'; @@ -41,6 +43,24 @@ test('does not invent reassurance when no urgent flags are reported', () => { assert.match(result.message, /not a diagnosis/i); }); +test('detects common urgent symptom language without matching unrelated blood wording', () => { + for (const message of [ + 'I have rectal bleeding', + 'There is blood in my stool', + 'My stool is black', + 'I have severe stomach pain', + 'I am throwing up and have a fever', + 'I am unable to pass gas', + ]) assert.equal(detectUrgentText(message).urgent, true, message); + assert.equal(detectUrgentText('My blood pressure was checked').urgent, false); +}); + +test('detects urgent flags or language in confirmed ledger context', () => { + assert.equal(hasUrgentLedgerContext([{ symptoms: { cannotPassGas: true }, note: '' }]), true); + assert.equal(hasUrgentLedgerContext([{ symptoms: {}, note: 'I have rectal bleeding' }]), true); + assert.equal(hasUrgentLedgerContext([{ symptoms: {}, note: 'ordinary entry' }]), false); +}); + test('Timmy summary reports patterns without clearing food or diagnosing disease', () => { const entries = [ { bristolType: 3, occurredAt: '2026-08-15T08:00:00.000Z' }, diff --git a/tests/hermes-agent-service.test.js b/tests/hermes-agent-service.test.js index 4f1ff37..75d215e 100644 --- a/tests/hermes-agent-service.test.js +++ b/tests/hermes-agent-service.test.js @@ -118,6 +118,33 @@ test('chat keeps Hermes session IDs server-side, resumes continuity, and strips assert.doesNotMatch(JSON.stringify([first, second]), /hermes-private-session/); }); +test('authoritative chat service intercepts urgent message and ledger symptoms before Hermes', async () => { + const calls = []; + const service = createHermesAgentService({ + config: configured(), + randomToken: () => 'safety-cookie', + runTurn: async input => { calls.push(input); return { reply: 'unsafe upstream reply', sessionId: 'unsafe-session' }; }, + }); + await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' }); + + const messageResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { message: 'I have rectal bleeding', ledger: [] } }); + const ledgerResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { + message: 'What does my journal show?', + ledger: [{ bristolType: 4, color: 'brown', note: '', symptoms: { cannotPassGas: true } }], + } }); + const noteResult = await service.chat({ origin, cookieToken: 'safety-cookie', payload: { + message: 'Summarize this entry', + ledger: [{ bristolType: 4, color: 'brown', note: 'unable to pass gas', symptoms: {} }], + } }); + + assert.equal(calls.length, 0); + for (const result of [messageResult, ledgerResult, noteResult]) { + assert.equal(result.safetyOverride, true); + assert.match(result.reply, /medical help/i); + assert.doesNotMatch(result.reply, /unsafe upstream/i); + } +}); + test('Hermes CLI parser accepts quiet output, strips control sequences, and rejects leaked metadata', () => { const parsed = parseHermesCliOutput('\u001b[32msession_id: 20260820_abcd\u001b[0m\nA concise answer.'); assert.deepEqual(parsed, { sessionId: '20260820_abcd', reply: 'A concise answer.' }); diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js index 44ebd9f..21f69f2 100644 --- a/tests/release-demo.test.js +++ b/tests/release-demo.test.js @@ -14,6 +14,8 @@ test('release demo visibly explains the CI-protected browser path without overst assert.match(demo, /AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis/); assert.match(demo, /Hermes Agent connected/); assert.match(demo, /Photos stay out of chat/); + assert.match(demo, /I have rectal bleeding/); + assert.match(demo, /Urgent language is intercepted deterministically before Hermes/); assert.match(demo, /SLEEK\. SIMPLE\.
HERMES-POWERED\./); }); diff --git a/tests/sleek-chat.acceptance.mjs b/tests/sleek-chat.acceptance.mjs index b5cb164..c3c2e07 100644 --- a/tests/sleek-chat.acceptance.mjs +++ b/tests/sleek-chat.acceptance.mjs @@ -37,6 +37,11 @@ await page.waitForSelector('.bubble.timmy >> text=mostly Type 4'); assert.equal(chatRequest.message, 'What pattern do you see?'); assert.ok(Array.isArray(chatRequest.ledger)); assert.equal(JSON.stringify(chatRequest).includes('photoDataUrl'), false, 'photos never enter chat context'); +const previousRequest = chatRequest; +await page.locator('#chat-message').fill('I am unable to pass gas'); +await page.locator('#send-chat').click(); +await page.waitForSelector('.bubble.timmy >> text=Pause and get medical help'); +assert.equal(chatRequest, previousRequest, 'urgent language must be intercepted before the Hermes request'); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, 'chat must not overflow horizontally'); await page.screenshot({ path: 'artifacts/sleek-hermes-chat-mobile.png', fullPage: true }); diff --git a/tests/ui.acceptance.mjs b/tests/ui.acceptance.mjs index c733cea..2e8761c 100644 --- a/tests/ui.acceptance.mjs +++ b/tests/ui.acceptance.mjs @@ -37,8 +37,8 @@ await page.locator('[data-view="timmy"]').last().click(); await page.locator('#chat-message').fill('Can I eat Taco Bell?'); await page.locator('#send-chat').click(); const chatText = await page.locator('#chat').innerText(); -assert.match(chatText, /cannot clear a food or restaurant/i); -assert.doesNotMatch(chatText, /Taco Bell is safe/i); +assert.match(chatText, /pause and get medical help/i); +assert.doesNotMatch(chatText, /Taco Bell is safe|cannot clear a food or restaurant/i); await page.waitForTimeout(500); assert.equal(await page.evaluate(() => window.scrollY), 0, 'reply should not push the page header/navigation out of frame'); await page.screenshot({ path: 'artifacts/timmy-chat-mobile.png', fullPage: false });