diff --git a/app.js b/app.js
index 5ff3af6..3bd105a 100644
--- a/app.js
+++ b/app.js
@@ -1,7 +1,17 @@
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';
+const runtimeConfig = window.__TIMMY_CONFIG__ || { basePath: '/', stagingLabel: '' };
+const BASE_PATH = runtimeConfig.basePath || '/';
+const APP_ROOT = BASE_PATH === '/' ? '/' : `${BASE_PATH}/`;
+function appPath(path='') { return `${APP_ROOT}${String(path).replace(/^\/+/, '')}`; }
+const STORE = `timmy:${BASE_PATH}:ledger-v1`;
+if (BASE_PATH === '/' && !localStorage.getItem(STORE)) {
+ const legacyEntries = localStorage.getItem('timmy-ledger-v1');
+ if (legacyEntries !== null) {
+ try { localStorage.setItem(STORE, legacyEntries); localStorage.removeItem('timmy-ledger-v1'); } catch {}
+ }
+}
const app = document.querySelector('#app');
let entries = loadEntries();
let view = 'home';
@@ -24,7 +34,8 @@ function formatDate(value) { return new Intl.DateTimeFormat(undefined,{month:'sh
function toast(message) { const node=document.createElement('div');node.className='toast';node.textContent=message;document.body.append(node);setTimeout(()=>node.remove(),2400); }
function shell(content) {
- app.innerHTML = `${content}${nav()}`;
+ const label=runtimeConfig.stagingLabel?`${esc(runtimeConfig.stagingLabel)} `:'';
+ app.innerHTML = `${content}${label}${nav()}`;
bindGlobal();
}
function nav(){return `⌂ Today ▤ Journal ✦ Timmy `}
@@ -35,7 +46,7 @@ function bindGlobal(){
}
function recentList(limit=5){
- if(!entries.length)return `
Quiet bowl, clean slate. Your first log takes about ten seconds.
`;
+ if(!entries.length)return `Quiet bowl, clean slate. Your first log takes about ten seconds.
`;
return entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt)).slice(0,limit).map(e=>`T${e.bristolType}
${formatDate(e.occurredAt)} ${esc(e.color)} · urgency ${e.urgency}/4 · discomfort ${e.discomfort}/4${e.note?` · ${esc(e.note)}`:''}
${bucketForBristolType(e.bristolType)} `).join('');
}
function thisWeek(){const now=Date.now(),week=7*864e5;return entries.filter(e=>now-new Date(e.occurredAt).getTime()e.occurredAt.slice(0
function home(){
const latest=entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt))[0];
- shell(`Your intelligent pooping pal Log it. Learn the pattern. Start with a photo. Timmy suggests visible form and color; you review everything before it is saved.
◉ Start photo log Private, guided, about 10 seconds → Log manually instead ${thisWeek()} this week
${currentStreak()} day streak
${entries.length} all logs
Timmy noticed ${esc(buildTimmySummary(entries))}
Latest
${latest?'Recent log':'Ready when you are'} ${latest?'
View journal ':''}
${latest?`T${latest.bristolType}
${formatDate(latest.occurredAt)} ${esc(latest.color)} · ${bucketForBristolType(latest.bristolType)}
› `:'One quick, confirmed entry is enough to begin seeing your pattern.
'} `);
+ shell(`Your intelligent pooping pal Log it. Learn the pattern. Start with a photo. Timmy suggests visible form and color; you review everything before it is saved.
◉ Start photo log Private, guided, about 10 seconds → Log manually instead ${thisWeek()} this week
${currentStreak()} day streak
${entries.length} all logs
Timmy noticed ${esc(buildTimmySummary(entries))}
Latest
${latest?'Recent log':'Ready when you are'} ${latest?'
View journal ':''}
${latest?`T${latest.bristolType}
${formatDate(latest.occurredAt)} ${esc(latest.color)} · ${bucketForBristolType(latest.bristolType)}
› `:'One quick, confirmed entry is enough to begin seeing your pattern.
'} `);
}
function calendar(){
@@ -69,13 +80,13 @@ function timmy(){
requestAnimationFrame(()=>{const chat=document.querySelector('#chat');if(chat)chat.scrollTop=chat.scrollHeight});
}
async function loadAgentStatus(){
- try{const response=await fetch('/api/agent/status',{headers:{accept:'application/json'}});agentStatus=response.ok?await response.json():{enabled:false,configured:false,authenticated:false,mode:'local-fallback'}}
+ try{const response=await fetch(appPath('api/agent/status'),{headers:{accept:'application/json'}});agentStatus=response.ok?await response.json():{enabled:false,configured:false,authenticated:false,mode:'local-fallback'}}
catch{agentStatus={enabled:false,configured:false,authenticated:false,mode:'local-fallback'}}
if(view==='timmy')timmy();
}
async function unlockAgent(){
const code=document.querySelector('#agent-code')?.value||'';chatError='';
- try{const response=await fetch('/api/agent/unlock',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({accessCode:code})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Could not connect.');agentStatus=data;toast('Hermes Agent connected');timmy()}
+ try{const response=await fetch(appPath('api/agent/unlock'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({accessCode:code})});const data=await response.json();if(!response.ok)throw new Error(data.error||'Could not connect.');agentStatus=data;toast('Hermes Agent connected');timmy()}
catch(error){chatError=error.message||'Could not connect.';timmy()}
}
function localChatReply(message){
@@ -91,7 +102,7 @@ async function sendChat(event){
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})}
+ try{const response=await fetch(appPath('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})}
catch(error){chatError=error.message||'Hermes is unavailable.';chatMessages.push({role:'timmy',text:'I could not reach Hermes. Your local journal still works, and nothing was changed.'})}
finally{chatBusy=false;timmy()}
}
@@ -106,7 +117,7 @@ function deleteData(){if(confirm('Delete every local Timmy entry and photo? This
function openPhotoFirst(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}
async function loadVisionStatus(){
- try{const response=await fetch('/api/vision-status',{headers:{accept:'application/json'}});visionStatus=response.ok?await response.json():{enabled:false,providerReady:false}}
+ try{const response=await fetch(appPath('api/vision-status'),{headers:{accept:'application/json'}});visionStatus=response.ok?await response.json():{enabled:false,providerReady:false}}
catch{visionStatus={enabled:false,providerReady:false}}
if(document.querySelector('.scan-sheet')&&['pick','ready'].includes(photoFirstMode))showPhotoFirst(photoFirstMode)
}
@@ -117,9 +128,9 @@ function visionStatusHtml(){
return '○ Vision worker offline · manual logging is still available
';
}
function photoFirstBody(mode,error=''){
- if(mode==='pick')return `${visionStatusHtml()}One photo. Two useful suggestions. Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.
📷 Take or choose a photo JPEG, PNG, or WebP · compressed before analysis Continue without AI `;
+ if(mode==='pick')return `${visionStatusHtml()}One photo. Two useful suggestions. Timmy can suggest the visible Bristol form and color. A camera cannot know urgency, pain, symptoms, or a diagnosis.
📷 Take or choose a photo JPEG, PNG, or WebP · compressed before analysis Continue without AI `;
if(mode==='ready'){const processingCopy=visionStatus?.profile==='selfhost'?'Timmy’s server does not save it. The compressed copy stays on Timmy’s self-hosted model server.':'Timmy’s server does not save it. Your configured AI provider processes it under that provider’s terms.';return `${visionStatusHtml()}${esc(photoHint)}
Send this compressed copy for one-time AI analysis. ${processingCopy}
Analyze visible form + color Use another photo `;}
- if(mode==='analyzing')return `
Timmy is looking at form and color… Not symptoms. Not disease. Not whether Taco Bell was a strategic error.
`;
+ if(mode==='analyzing')return `
Timmy is looking at form and color… Not symptoms. Not disease. Not whether Taco Bell was a strategic error.
`;
if(mode==='error')return `Fill it out manually Try another photo `;
if(aiSuggestion?.status==='suggestion')return `AI SUGGESTION · ${Math.round(aiSuggestion.confidence*100)}% CONFIDENCE BRISTOL FORM Type ${aiSuggestion.bristolType}
VISIBLE COLOR ${esc(aiSuggestion.color)}
${esc(aiSuggestion.observations||'Visual match found.')}
${esc(aiSuggestion.warning)}
Use these suggestions Review everything manually `;
return `Choose the form yourself Try another photo `;
@@ -134,7 +145,7 @@ function showPhotoFirst(mode='pick',error=''){
document.querySelector('#use-suggestion')?.addEventListener('click',()=>{form=mergeVisualSuggestion(form,aiSuggestion);showLogStep(1)});
}
async function handleAiPhoto(e){const file=e.target.files[0];if(!file)return;try{const result=await compressPhoto(file);photoDataUrl=result.dataUrl;photoHint=photoQualityMessage(result);showPhotoFirst('ready')}catch{showPhotoFirst('error','That image could not be read. Try another photo.')}}
-async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch('/api/analyze',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}}
+async function runAiAnalysis(){showPhotoFirst('analyzing');try{const response=await fetch(appPath('api/analyze'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({imageDataUrl:photoDataUrl,consent:true})});const data=await response.json();if(!response.ok)throw new Error(data.error||'AI analysis is unavailable.');aiSuggestion=data;showPhotoFirst('result')}catch(error){showPhotoFirst('error',error.message||'AI analysis is unavailable. Continue manually.')}}
function openLogger(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;showLogStep(1)}
function showLogStep(step){
@@ -159,4 +170,4 @@ function saveLog(){const result=detectUrgentFlags(form.symptoms);const entry=san
function render(){({home,calendar,timmy,privacy}[view]||home)()}
render();
-if('serviceWorker' in navigator)navigator.serviceWorker.register('/service-worker.js').catch(()=>{});
+if('serviceWorker' in navigator)navigator.serviceWorker.register(appPath('service-worker.js'),{scope:APP_ROOT}).catch(()=>{});
diff --git a/package.json b/package.json
index 02346d3..6a284ed 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js",
+ "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js",
"test:ui": "node tests/ui.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs",
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
diff --git a/server.mjs b/server.mjs
index a1171ae..d6f6ed6 100644
--- a/server.mjs
+++ b/server.mjs
@@ -1,4 +1,5 @@
import http from 'node:http';
+import { isIP } from 'node:net';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -8,46 +9,77 @@ import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig }
const root=fileURLToPath(new URL('.',import.meta.url));
const port=Number(process.env.PORT||4173);
+const host=process.env.HOST||'0.0.0.0';
+if(!isIP(host))throw new Error('HOST must be an IPv4 or IPv6 address.');
+function resolveBasePath(value) {
+ const raw=String(value||'');
+ if(!raw||raw==='/')return '';
+ if(raw.length>128||!raw.startsWith('/')||/[?#\\]/.test(raw)||/%(?:2f|5c)/i.test(raw))throw new Error('TIMMY_BASE_PATH must be a safe absolute URL path.');
+ let normalized=raw.endsWith('/')?raw.slice(0,-1):raw;
+ if(normalized.includes('//'))throw new Error('TIMMY_BASE_PATH must not contain empty path segments.');
+ let decoded;
+ try{decoded=decodeURIComponent(normalized)}catch{throw new Error('TIMMY_BASE_PATH contains malformed encoding.')}
+ if(decoded.split('/').some(segment=>segment==='.'||segment==='..'))throw new Error('TIMMY_BASE_PATH must not contain traversal segments.');
+ return normalized;
+}
+const basePath=resolveBasePath(process.env.TIMMY_BASE_PATH);
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8','.webmanifest':'application/manifest+json','.svg':'image/svg+xml'};
const visionConfig=resolveVisionConfig(process.env);
const agentConfig=resolveHermesAgentConfig(process.env);
const agentService=createHermesAgentService({config:agentConfig});
+const release=/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(process.env.TIMMY_RELEASE_TAG||'')?process.env.TIMMY_RELEASE_TAG:'development';
+const commit=/^[0-9a-f]{12,40}$/.test(process.env.TIMMY_RELEASE_COMMIT||'')?process.env.TIMMY_RELEASE_COMMIT:'000000000000';
+const publicBasePath=basePath||'/';
+const appRoot=basePath?`${basePath}/`:'/';
+const stagingLabel=process.env.TIMMY_STAGING_LABEL?`Staging · ${release} · ${commit.slice(0,12)}`:'';
function sendJson(res,status,value){res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store','x-content-type-options':'nosniff'});res.end(JSON.stringify(value));}
function readJson(req,maxBytes=6*1024*1024){return new Promise((resolve,reject)=>{let size=0,tooLarge=false;const chunks=[];req.on('data',chunk=>{size+=chunk.length;if(size>maxBytes){tooLarge=true;return}chunks.push(chunk)});req.on('end',()=>{if(tooLarge)return reject(new AgentGatewayError(413,'Request is too large.'));try{resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))}catch{return reject(new AgentGatewayError(400,'Invalid JSON request.'))}});req.on('error',reject)})}
function cookie(req,name){for(const part of String(req.headers.cookie||'').split(';')){const [key,...value]=part.trim().split('=');if(key===name)return decodeURIComponent(value.join('='))}return ''}
function requestOrigin(req){return String(req.headers.origin||'').replace(/\/$/,'')}
function rejectCrossSite(req){const site=String(req.headers['sec-fetch-site']||'');if(site&&site!=='same-origin')throw new AgentGatewayError(403,'Cross-site agent requests are not allowed.')}
-function agentCookie(token){const secure=agentConfig.publicOrigin.startsWith('https://')?'; Secure':'';return `timmy_agent=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${secure}`}
+function agentCookie(token){const secure=agentConfig.publicOrigin.startsWith('https://')?'; Secure':'';return `timmy_agent=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=${appRoot}; Max-Age=86400${secure}`}
function sendAgentError(res,error){const status=error instanceof AgentGatewayError?error.status:503;const message=error instanceof AgentGatewayError?error.message:'Hermes is temporarily unavailable. Your local journal still works.';return sendJson(res,status,{error:message})}
http.createServer(async(req,res)=>{
try{
const url=new URL(req.url,'http://localhost');
- if(url.pathname==='/api/vision-status'&&req.method==='GET'){
+ if(basePath&&url.pathname!==basePath&&!url.pathname.startsWith(`${basePath}/`)){res.writeHead(404,{'content-type':'text/plain; charset=utf-8'});return res.end('Not found')}
+ if(basePath&&url.pathname===basePath){res.writeHead(308,{location:appRoot});return res.end()}
+ const appPath=basePath?(url.pathname.slice(basePath.length)||'/'):url.pathname;
+ if(appPath==='/api/healthz'&&req.method==='GET')return sendJson(res,200,{ok:true,release,commit,visionEnabled:visionConfig.enabled,agentEnabled:agentConfig.enabled});
+ if(appPath==='/api/vision-status'&&req.method==='GET'){
const provider=await probeVisionProvider(visionConfig);
const privacy=visionConfig.profile==='selfhost'?'The compressed image is processed by Timmy’s self-hosted model and is not forwarded to a third-party model provider.':'A compressed copy is sent to the configured AI provider only when you explicitly request analysis.';
return sendJson(res,200,{...visionConfig.publicStatus(),providerReady:provider.ready,modelSeen:provider.modelSeen,privacy});
}
- if(url.pathname==='/api/analyze'&&req.method==='POST'){
+ if(appPath==='/api/analyze'&&req.method==='POST'){
if(!visionConfig.enabled)return sendJson(res,503,{error:'AI analysis is disabled. Continue manually.'});
const payload=await readJson(req);
try{return sendJson(res,200,await analyzePhoto({payload,config:visionConfig}))}
catch(error){const safe=/consent|JPEG|PNG|WebP|empty|too large/i.test(error.message);return sendJson(res,safe?400:503,{error:error.message})}
}
- if(url.pathname==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent')));
- if(url.pathname==='/api/agent/unlock'&&req.method==='POST'){
+ if(appPath==='/api/agent/status'&&req.method==='GET')return sendJson(res,200,agentService.status(cookie(req,'timmy_agent')));
+ if(appPath==='/api/agent/unlock'&&req.method==='POST'){
try{rejectCrossSite(req);const payload=await readJson(req,4096);const result=await agentService.unlock({origin:requestOrigin(req),accessCode:String(payload?.accessCode||'')});res.setHeader('set-cookie',agentCookie(result.cookieToken));return sendJson(res,200,result.public)}catch(error){return sendAgentError(res,error)}
}
- if(url.pathname==='/api/agent/chat'&&req.method==='POST'){
+ if(appPath==='/api/agent/chat'&&req.method==='POST'){
try{rejectCrossSite(req);const payload=await readJson(req,128*1024);return sendJson(res,200,await agentService.chat({origin:requestOrigin(req),cookieToken:cookie(req,'timmy_agent'),payload}))}catch(error){return sendAgentError(res,error)}
}
- if(url.pathname.startsWith('/api/'))return sendJson(res,404,{error:'Not found'});
+ if(appPath.startsWith('/api/'))return sendJson(res,404,{error:'Not found'});
if(req.method!=='GET'&&req.method!=='HEAD'){res.writeHead(405,{'allow':'GET, HEAD'});return res.end()}
- const pathname=decodeURIComponent(url.pathname);
+ const pathname=decodeURIComponent(appPath);
let path=normalize(join(root,pathname==='/'?'index.html':pathname));
if(!path.startsWith(root))throw new Error('bad path');
const info=await stat(path);if(info.isDirectory())path=join(path,'index.html');
- const body=await readFile(path);res.writeHead(200,{'content-type':types[extname(path)]||'application/octet-stream','cache-control':'no-store','x-content-type-options':'nosniff'});if(req.method==='HEAD')res.end();else res.end(body);
+ let body=await readFile(path);
+ if(appPath==='/'||appPath==='/index.html'){
+ const browserConfig=JSON.stringify({basePath:publicBasePath,stagingLabel}).replace(/',`\n `).replace('\n