The first recorded card split 'ANALYZE REQUESTS' and every 'FAILURE' label across two lines, detaching each label from its count. Long CLI labels are now folded into values (one wrapped failures line), the key column holds only short keys, and the card was geometry-probed at the recording's phone viewport before rebuilding: 706px of content in a 706px panel, no clipped key.
Timmy the Talking Turd
A working mobile-first bowel diary with an optional photo-first AI assist: “Your intelligent pooping pal.”
- Product roadmap and live Gitea epic map
- Product and safety boundary
- Product boundary and release authority decision
- Self-hosted stool-vision decision
- Production topology, secrets, backup, and rollback runbook
- Privacy-safe model health counters (operator view)
Build a gated review release
python3 scripts/build_release.py
The builder clones the committed main tree into an isolated directory, runs unit/security and both mobile acceptance suites, audits dependencies, checks syntax and secrets, excludes model weights and sensitive/generated media, records a vertical feature demonstration from the working app, fully decodes and probes that MP4, and writes checksummed source/video artifacts plus manifest.json under /root/timmy-releases/. It refuses dirty or non-main source trees.
Promote or roll back private staging
The standard-library deployment tool verifies the declared archive SHA-256 before tar parsing, rejects unsafe or secret-bearing members, creates one immutable releases/<40-character-commit> directory, and atomically repoints current. Restart, bounded commit-specific health, or synthetic mobile smoke failures restore and verify the prior release automatically.
python3 scripts/deploy_staging.py --dry-run promote --tag TAG --archive FILE --sha256 HASH --commit FULL_COMMIT
sudo python3 scripts/deploy_staging.py promote --tag TAG --archive FILE --sha256 HASH --commit FULL_COMMIT
python3 scripts/deploy_staging.py status
sudo python3 scripts/deploy_staging.py rollback --commit PRIOR_FULL_COMMIT
Commands are fixed argv arrays executed with shell=False; JSON argv overrides and --root exist for rootless fixtures. See the staging runbook for host prerequisites, mode-600 environment injection, Caddy validation, smoke credentials, logs, backups, removal, and the explicit no-live-change boundary.
Back up and restore the records an operator holds
The app keeps the journal in the person's own browser, so the server has no diary to back up. What an
operator can lose is the corpus: the append-only manifest.jsonl with its provenance records and its
withdrawal tombstones, plus the derived images those records point at.
python3 scripts/operator_backup.py backup --source /srv/timmy-corpus --archive /srv/backups/timmy-nightly.tar.gz --label nightly
python3 scripts/operator_backup.py verify --archive /srv/backups/timmy-nightly.tar.gz --manifest /srv/backups/timmy-nightly.tar.gz.manifest.json
python3 scripts/operator_backup.py restore --archive /srv/backups/timmy-nightly.tar.gz --manifest /srv/backups/timmy-nightly.tar.gz.manifest.json --target /srv/timmy-corpus.restore-check
Model weights, transient inference images, secrets, logs, and symlinks are refused by name, and every skipped path is listed in the manifest with its reason. Each stored member carries a SHA-256, the archive is reproducible, and a restore re-verifies every digest and the record counts before it writes anything — a tampered archive or a lost tombstone fails loudly instead of restoring quietly. See the production runbook for topology, network boundaries, secrets, retention, and the rollback paths.
Run with the self-hosted open-weight path
npm install
# Installs the exact llama.cpp commit and verifies both GGUF SHA-256 receipts.
scripts/bootstrap_selfhost_smolvlm.sh install
scripts/bootstrap_selfhost_smolvlm.sh start
scripts/bootstrap_selfhost_smolvlm.sh health
# Run Timmy in another terminal.
TIMMY_VISION_PROFILE=selfhost npm start
# open http://localhost:4173
# When finished:
scripts/bootstrap_selfhost_smolvlm.sh stop
The bootstrap defaults to ${XDG_DATA_HOME:-$HOME/.local/share}/timmy-selfhost, prints immutable source/model receipts with receipt, and binds only to 127.0.0.1:8080. Downloads are rejected unless both pinned SHA-256 hashes match, and model weights remain outside Git. Set TIMMY_SELFHOST_ROOT to choose another private data directory; override host, port, threads, or build jobs with the documented TIMMY_MODEL_*/TIMMY_BUILD_JOBS environment variables.
The selected bootstrap model is SmolVLM2-2.2B-Instruct using the official Apache-2.0 GGUF conversion. It is a provisional labeling worker, not a diagnostic or clinically validated classifier.
Run with a hosted provider
hermes proxy start --provider nous --host 127.0.0.1 --port 8645
npm start
Hosted remains the default compatibility profile. To make it explicit, set TIMMY_VISION_PROFILE=hosted. Credentials stay server-side:
TIMMY_VISION_BASE_URL=https://your-openai-compatible-provider/v1 \
TIMMY_VISION_API_KEY=... \
TIMMY_VISION_MODEL=your-vision-model \
npm start
Set TIMMY_VISION_ENABLED=0 to disable uploads and retain manual-only operation.
Enable the full Hermes Agent chat
Hermes chat is disabled by default because a tool-capable agent can spend the authority of its server-side profile. Timmy requires an exact public origin, a strong operator access code, and an absolute dedicated workspace before the browser route exists.
mkdir -m 700 -p /root/timmy-agent-workspace
install -m 600 docs/TIMMY-AGENT-POLICY.md /root/timmy-agent-workspace/AGENTS.md
hermes profile create timmyapp --description "Private Timmy chat agent"
TIMMY_AGENT_ENABLED=true \
TIMMY_AGENT_ACCESS_TOKEN="$(openssl rand -hex 24)" \
TIMMY_PUBLIC_ORIGIN=http://127.0.0.1:4173 \
TIMMY_AGENT_WORKDIR=/root/timmy-agent-workspace \
TIMMY_HERMES_COMMAND=/root/.local/bin/timmyapp \
npm start
Authenticate/configure the dedicated timmyapp profile before use. Do not reuse a broad personal profile in a public deployment. The browser receives only an opaque HttpOnly, SameSite session cookie; it never receives provider credentials, Hermes session IDs, model/provider controls, or tool policy. Each conversation is resumed server-side with one in-flight turn, a 4,000-character message cap, a 20-entry text-only ledger context, fixed origin checks, rate and timeout limits, and sanitized errors. Photos are excluded from chat by construction. The application’s deterministic urgent-symptom warning runs before Hermes.
For an HTTPS deployment, set TIMMY_PUBLIC_ORIGIN to the exact HTTPS origin so the session cookie is marked Secure. Put the Node service behind authenticated TLS and give the dedicated profile only the host/service authority the intended user should be able to exercise.
Verify
npm test
npm run test:ui # server required on port 4173
npm run test:photo # mocked positive suggestion through the real browser UX
npm run test:sleek # simplified 390x844 shell + free-text Hermes chat
npm run test:manual-fallback # vision-offline manual path + in-session draft recovery
npm run test:vision-refusal # corpus refusals through a real server and a real browser
npm run test:grounded-chat # chat answers cite the ledger, or refuse to answer
npm run test:photo-lifecycle # photo presence, explicit reveal, remove-photo-keeps-log, delete-all
npm run test:calibration # the release-gate harness: gates, threshold protocol, refused corpora
npm run test:review # blind review queue, held-back model answer, an append-only ledger, withdrawals
npm run test:security-attack # hostile synthetic uploads at the real boundary, with a counting worker (#40)
npm audit --audit-level=high
What works
- Photo-first camera/file capture
- Client-side image compression before transfer
- Explicit consent before one-time provider analysis
- Structured AI suggestions for visible Bristol form and color only
- Confidence display, low-confidence abstention, and user confirmation, governed by one versioned policy:
poorimage quality abstains even at high confidence, and a confident reply is never enough on its own — see docs/ABSTENTION-POLICY.md - Urgency, discomfort, notes, and symptoms remain strictly user-reported
- Manual logging that never uploads
- Sleek three-destination mobile shell with one dominant photo action
- Free-text smart chat with real server-side Hermes session continuity and local fallback
- Grounded chat: an answer that states a count, a date, or a form cites the confirmed records behind it, and each citation opens exactly that log. A question the journal cannot answer (food safety, medicine, what a symptom means) gets boundary copy with no borrowed evidence, a window below the minimum refuses to describe a trend, and a Hermes answer that cites a record the ledger does not contain is labelled unverified instead of shown as a source
- Authenticated, same-origin, bounded browser-to-agent gateway with no browser-side credentials
- Red-flag symptom escalation
- A short user-reported follow-up after a hard/lumpy (Bristol 1–2) log — difficulty passing, soreness, visible blood, truly black/tarry versus dark brown, and hydration. Mild reports get concise non-diagnostic self-care that never claims a photo ruled out bleeding; a confirmed red flag sets the same deterministic escalation the manual checkboxes use
- Manual fallback that never depends on AI: when the vision worker is offline or disabled, no AI or consent control is offered and manual Bristol entry stays one action away
- A committed CC0 synthetic vision corpus that continuously proves every reply is schema-valid or fails closed. A schema-valid reply with no readable form or colour now abstains ("No confident match") instead of surfacing as a worker failure, and unparsable or out-of-range replies return plain copy — "No suggestion was applied. Continue manually, or try a clearer photo." — with no provider text, image bytes, or credentials echoed. See tests/fixtures/vision-corpus
- Unsaved logging drafts are retained in the session with an explicit start over, and the service worker never caches analysis requests or image bodies
- Private photo lifecycle: a confirmed log reports a saved photo as a label rather than a thumbnail, showing the image is an explicit request, removing the photo keeps every confirmed field, deleting a log is a separate two-step decision, and delete-all removes the ledger and every stored photo together
- Local browser ledger, calendar, pattern summary, versioned JSON backup (logs, training receipts, and per-log AI provenance) with safe upgrade/refusal on import, and delete-all
- Explainable pattern summaries: the home insight card reports the confirmed-log count and form groupings for the last 28 days, every line names the exact confirmed records behind it, following a citation opens those records, and a window below the minimum says it is too sparse instead of describing a trend. No line explains a cause, rules on food, or offers a health verdict
- Installable/offline PWA shell for the manual and saved-ledger paths
What is deliberately not faked
The positive photo-prefill interface is covered by a deterministic provider fixture. A live self-hosted SmolVLM2-2.2B model was exercised against a CC-licensed real Type 4 stool photograph. It accepted the content and identified stool, but predicted the wrong type with low confidence; Timmy correctly abstained. The open model removes provider moderation from the path, but its Bristol accuracy is not clinically established.
Timmy does not diagnose disease, identify bleeding, infer pain/urgency, recommend treatment, or clear foods. The selected VLM is a bootstrap worker, not the final classifier. See research/SELF-HOSTED-STOOL-VISION.md for the measured receipts, hardware gate, consented-data pipeline, specialist-classifier plan, and sources.
The calibration harness (scripts/calibration_report.py, contract) computes the ROADMAP release gates — macro-F1, precision-at-coverage, and calibration error — from prediction files, chooses a threshold on validation only, and refuses a corpus that leaks one contributor across two splits. Its committed fixtures are invented numbers that exercise the arithmetic: no candidate model has been measured against these gates yet, and the harness itself is not evidence about accuracy.
The reviewer tool (scripts/review_corpus.py, contract) is the labeling control for that corpus, and it is a command-line tool with no screen in the app. The review queue shows the contributor's proposal and withholds every model field, a reveal is refused until a human has committed their own labels, a correction must actually change the proposal, adjudication is appended beside the review it resolves instead of overwriting it, and a withdrawn contribution cannot be queued, reviewed, revealed, adjudicated, or trained on. Unreviewed and excluded samples are never training data. It records decisions; it does not measure a model.
What the corpus is, and what a label may assert, is published rather than implied: docs/DATASET_CARD.md is generated from the manifest by scripts/dataset_card.py, so every count on it is read back from real records and every fact the pipeline does not collect (devices, contexts, licenses) is printed as pending instead of estimated — a card for a corpus where one contributor straddles two splits is refused, not published. docs/LABELING_GUIDE.md is the reviewer's rulebook: visible form, broad visible color, and photo quality only, visible color is not blood, nothing is diagnosed, and a model suggestion is provisional input to a human decision rather than the label.
The release candidate is attacked before anyone is asked to trust it. scripts/security_attack_suite.mjs drives the real server with synthetic hostile bytes — an oversized body, an HTML polyglot dressed as a JPEG, undecodable bytes, a request without consent, a worker answer that breaks the schema, a byte-identical replay, and a concurrent burst — while a counting stub worker records every call that reaches the model boundary. docs/RC-SECURITY-EVIDENCE.md owns the rule: any failing P0 case blocks RC approval, and a required class with no executable evidence blocks it too. It currently returns VERDICT: BLOCKED — bytes are still trusted from the data-URL label, so a hostile file reaches the worker before it fails on schema. That is a published finding against #16, not a passing check.
Architecture
Browser PWA
├── app.js sleek photo-first UX, local ledger, free-text chat
├── src/domain.js tested health/safety and summary rules
├── src/analysis.js strict AI schema, validation, visual-only merge
├── server.mjs static server + bounded vision and agent routes
├── src/hermes-agent-service.js authenticated session-bound Hermes CLI adapter
├── src/vision-service.js server-side OpenAI-compatible provider adapter
├── src/vision-config.js hosted/self-hosted profiles and readiness probe
├── scripts/bootstrap_selfhost_smolvlm.sh pinned install/start/health/stop lifecycle
├── scripts/run_selfhost_smolvlm.sh custom-path foreground launcher
├── scripts/ingest_training_photo.py
├── localStorage saved ledger and attached photos
└── llama.cpp / hosted API selected per server-side profile
The server accepts JPEG/PNG/WebP data URLs up to 4 MB, uses a bounded 60-second hosted or 120-second self-hosted timeout, never accepts an API key from browser input, does not write inference images to disk, and returns only validated visual suggestions. The separate dataset ingester runs only for explicit, consented contributions.