{"slug":"butlr","total":83,"limit":100,"offset":0,"since":null,"commits":[{"hash":"9c34807","date":"2026-07-23 10:20:24 -0700","author":"Steve Abrams","subject":"auto-save: 2026-07-23T10:20:16 (1 files) — 5x/","body":""},{"hash":"c9f8417","date":"2026-07-03 08:08:06 -0700","author":"Steve","subject":"chore: macstudio3 migration — reconcile from mac2 + repoint paths (stevestudio2→macstudio3, node/npm/npx→/opt/homebrew)","body":""},{"hash":"cd0e467","date":"2026-05-31 16:32:32 -0700","author":"SteveStudio2","subject":"Add per-site favicon (kills /favicon.ico 404)","body":""},{"hash":"c415746","date":"2026-05-26 15:04:57 -0700","author":"SteveStudio2","subject":"live page: show recording length as m:ss badge + preload=metadata so player shows duration","body":""},{"hash":"11722b0","date":"2026-05-26 11:01:00 -0700","author":"SteveStudio2","subject":"butlr: sequential Ventura-Blvd hours sweep dialer (one call at a time, up to 40)","body":""},{"hash":"4997af2","date":"2026-05-26 10:41:22 -0700","author":"SteveStudio2","subject":"live page: harden audio — auto-listen on appear, resume ctx per frame, red MUTED banner when audio arrives locked","body":""},{"hash":"6e3d4f7","date":"2026-05-26 10:33:25 -0700","author":"SteveStudio2","subject":"butlr: recording disclosure to 'being recorded for Steve'","body":""},{"hash":"0a071c5","date":"2026-05-26 10:30:07 -0700","author":"SteveStudio2","subject":"butlr: soft intro variant to Butler/real-local-client wording (soft is the active variant in prod)","body":""},{"hash":"1f52b13","date":"2026-05-26 10:29:23 -0700","author":"SteveStudio2","subject":"butlr: short punchy call intro (Butler / real local client) keeping § 632 recording disclosure","body":""},{"hash":"b82d7e0","date":"2026-05-26 10:24:29 -0700","author":"SteveStudio2","subject":"live page: completed-calls filter to recordings over 5s + show duration","body":""},{"hash":"eeda3f9","date":"2026-05-26 10:15:38 -0700","author":"SteveStudio2","subject":"live page: fix completed-calls flash-disappear (scope live-grid card removal to #grid)","body":""},{"hash":"7b0d62c","date":"2026-05-26 10:12:26 -0700","author":"SteveStudio2","subject":"live page: hide + reap stale zombie calls (age-based filter + 60s reaper → failed)","body":""},{"hash":"40322db","date":"2026-05-26 10:06:31 -0700","author":"SteveStudio2","subject":"butlr: add voicemail-IVR language guard to auto-hangup when AMD false-positives a voicemail tree as human","body":"Twilio AMD mislabels chatty voicemail systems as AnsweredBy=human, so the\nexisting machine-hangup in /twilio/amd never fires and the AI agent navigates\nthe voicemail menu (call Tjz8cfFd burned turns inside Civil Coffee's mailbox).\nAdds a deterministic looksLikeVoicemail() guard in both /twilio/gather (silent\nHangup TwiML, pre-LLM) and /twilio/partial (earliest call.update Status=completed\nfast-bail) as belt-and-suspenders. Voicemail = wasted money."},{"hash":"5ed99e3","date":"2026-05-26 10:05:23 -0700","author":"SteveStudio2","subject":"butlr: live-listen now broadcasts BOTH call tracks (inbound + outbound) so the owner hears the full conversation, not just the called party","body":"Fixes \"can't hear anything on live-listen\". The bridge only called\nbroadcastPcm for the inbound track, so the AI (outbound) and on-hold/silent\nbusiness produced no audio. Now every decoded 20ms frame is broadcast as it\narrives; the browser's nextPlayTime queue interleaves them in order. Phone\ncalls are mostly half-duplex so this sounds clean with no per-sample summing,\nno clipping, no buffering latency, and zero client change (still mono 8kHz\nInt16). Stereo MP3 archive (archiveAppend both tracks) untouched.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"4ef812b","date":"2026-05-26 10:00:56 -0700","author":"SteveStudio2","subject":"live page: chime on every new call (two-tone Web Audio beep, fires on new call ids)","body":""},{"hash":"56de574","date":"2026-05-26 09:51:17 -0700","author":"SteveStudio2","subject":"live page: completed-calls history + reconcile prod drift","body":"Adds a 'Completed calls' history section below the live grid on\nlive.agentabrams.com — fetches /api/done?limit=24 every 30s and renders\neach done call with business name, status pill, time-ago, goal snippet,\nand an inline <audio> player streaming from /api/calls/:id/recording\n(same-origin session-cookie auth, token stays out of the DOM). onerror\ngracefully swaps a failed player for a 'no recording archived' note.\n\nAlso reconciles 31 lines of prod drift: the Kamatera copy had auto-play-\non-connect + the shared-AudioContext unlock banner that were never\ncommitted back to this repo. This commit pulls prod's full current state\n(342 lines) plus the new history section (→408) so Mac2 = prod again."},{"hash":"1295f61","date":"2026-05-26 09:40:30 -0700","author":"SteveStudio2","subject":"sync local from prod — reconcile butlr drift","body":"Prod (root@45.61.58.125:/root/public-projects/butlr) was substantially\nahead of local after Steve's direct-on-box edits. Pulled prod → local\n(excludes: .env, node_modules, data/, *.log, *.bak-*, *.pre-*; no --delete).\n\nThis supersedes my redundant local AMD commit (f4b29f0) — Steve's prod\nversion of the answering-machine auto-hangup is cleaner and broader:\n  - triggers on [machine_start, machine_end_beep, machine_end_silence,\n    machine_end_other, fax] (mine missed fax)\n  - hangs up via Status=completed (cleaner than my Twiml=<Hangup/>)\n  - patches the call row notes with [auto-hangup: AMD <kind>]\n\nAlso brought down prod-only work that was never on local:\n  - lib/scheduling.js + routes/scheduled.js (appointment scheduling)\n  - routes/live.js (live-calls surface)\n  - views/public/calendar.ejs\n  - public/audio/intro-steve.mp3 (call intro)\n  - scripts/dial-sequence.sh\n  - modified: server.js, lib/data.js, lib/twilio.js, lib/vapi.js,\n    routes/listen.js, routes/public.js, several views\n\nAll key files node -c clean. Local is now a faithful mirror of the live\nbutlr service, so a future /deploy won't clobber prod's work.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"f4b29f0","date":"2026-05-26 09:29:51 -0700","author":"SteveStudio2","subject":"amd: silent hangup on machine answer (no voicemail messages)","body":"Steve 2026-05-26: \"if answering machine beep, HANG UP.\"\n\nThe /twilio/amd/:call_id handler was previously log-only with a \"WE DO NOT\nAUTO-BRIDGE\" note explaining the human-detection false-positive risk on\nchatty IVRs. That stays true for the HUMAN branch.\n\nThis patch adds a MACHINE branch: when Twilio's AnsweredBy starts with\n\"machine_\" (with our DetectMessageEnd mode that's machine_end_beep,\nmachine_end_silence, or machine_end_other), the handler fires the same\nproven call.update fast-path as /partial/:call_id's goal-satisfied\nhangup — but with BARE <Response><Hangup/></Response>, no <Say>/<Play>.\nBare = no audio gets recorded onto the voicemail. Silent disconnect.\n\nState tracked in MACHINE_HUNGUP Set (callId dedupe) so a delayed\nduplicate AMD callback doesn't re-fire. On Twilio API failure we\ndelete from the set to allow retry on next webhook.\n\nStatus updates to 'done' on successful hangup (matches the partial-hangup\npattern at line 428).\n\nAcked the webhook before doing async work (res.type('text/xml').send\nmoved to top) so Twilio doesn't retry the AMD callback while we're\nmid-call.update.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"a5dc24d","date":"2026-05-19 17:24:13 -0700","author":"SteveStudio2","subject":"gitignore + serve-time 404-guard for .bak/.pre- snapshot files","body":"- broaden .gitignore to cover *.bak, *.bak.*, *.pre-*, .pre-*\n- add Express middleware that 404s any .bak/.pre- path before\n  express.static so a stray snapshot dropped in public/ can never\n  leak. Pre-existing target=_blank links already carry rel=noopener.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"b52ca5e","date":"2026-05-19 14:00:17 -0700","author":"SteveStudio2","subject":"Fix µ-law decoder bias (33 → 0x84) — root cause of distorted call audio","body":"lib/listen-bridge.js decoded Twilio's G.711 µ-law media frames with\nMULAW_BIAS=33. The G.711 decode bias is 0x84 (132), added inside the\n<< exponent shift. At 33 the bias was under-counted 4×: quiet samples\n(exponent 0) stayed correct but every loud sample was nonlinearly\ncrushed (0x00 decoded to -19551 instead of -32124). That amplitude-\ndependent distortion made both the recorded .mp3 and the live-listen\nfeed sound horrible. Verified: the decoder now matches canonical\nG.711 on all 256 µ-law byte values.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"f709144","date":"2026-05-19 13:21:46 -0700","author":"Steve","subject":"Add external call-trigger API for first-party sites (NPH)","body":"POST /api/external/place-call lets NationalPaperHangers.com place a Butlr\ncall without the owner-authed wizard. Guarded by a shared-secret header\n(X-Butlr-External-Secret); fail-closed when BUTLR_EXTERNAL_SECRET is unset.\n\nThree modes — hold / bridge / ai_agent — map onto the existing addCall +\ndialCall path. Calls are owned by a dedicated auto-created service user so\nper-user scoping holds. allow_repeat:true is passed so different customers\ncan call the same installer; the 4-call/number hard cap still applies.\nHonors TWILIO_DRY_RUN like every other call path.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"c3e3751","date":"2026-05-19 12:50:18 -0700","author":"SteveStudio2","subject":"wire nodemailer Purelymail SMTP transport for password-reset email","body":""},{"hash":"2606c4d","date":"2026-05-19 12:25:53 -0700","author":"SteveStudio2","subject":"Add password-reset test suite and wire into npm test","body":""},{"hash":"051c322","date":"2026-05-19 12:22:53 -0700","author":"SteveStudio2","subject":"Add password-reset token store, pluggable delivery layer, and forgot/reset routes","body":""},{"hash":"06f6cc8","date":"2026-05-14 09:28:40 -0700","author":"SteveStudio2","subject":"callback_phone: use BUTLR_OWNER_CALLBACK_PHONE env + per-call override","body":"Per Steve's standing instruction (2026-05-14): Bridge-me-now should dial\nthe human's personal cell, NOT the Twilio outbound FROM line. The\nquick-dial handler was reading req.user.email_phone, a field that does\nnot exist in users.js's schema, falling all the way through to the\nhardcoded +16067130489 (the Twilio FROM number).\n\nFix has two halves:\n  1. seedLegacyAdmin() now persists callback_phone from a new env var\n     BUTLR_OWNER_CALLBACK_PHONE so every fresh boot picks it up.\n  2. /api/quick-dial's callback chain becomes:\n       body.callback_phone           (per-call override from /butlr skill)\n       → req.user.callback_phone     (seeded from env)\n       → process.env.BUTLR_OWNER_CALLBACK_PHONE\n       → process.env.TWILIO_PHONE_NUMBER  (last-resort fallback)\n       → +16067130489\n\nSteve set BUTLR_OWNER_CALLBACK_PHONE=+13107130489 in /root/public-projects/butlr/.env\nand pm2-restarts butlr, the admin user record auto-persists callback_phone."},{"hash":"25f76be","date":"2026-05-14 08:52:53 -0700","author":"SteveStudio2","subject":"Add /api/runtime-config — surfaces DRY_RUN state for the /butlr skill","body":"The /butlr Claude Code skill was confidently saying 'DIALED' when the\nserver was actually in TWILIO_DRY_RUN=ON mode (simulated state machine,\nno real Twilio call). The skill now hits this probe BEFORE POSTing to\n/api/quick-dial; if dry_run is true it warns Steve and refuses to\nclaim 'DIALED' without explicit override.\n\nOwner-only. Surfaces nothing sensitive — only boolean flags."},{"hash":"89f3440","date":"2026-05-14 08:46:53 -0700","author":"SteveStudio2","subject":"owner-auth: accept token via X-Butlr-Owner-Token header + Bearer","body":"tokenFromReq() already accepted the cookie and ?owner_token= query.\nThe /butlr Claude Code skill needs to POST /api/quick-dial without\nthe token landing in URL/nginx logs. Adding two header transports:\n  - X-Butlr-Owner-Token (explicit)\n  - Authorization: Bearer <token> (standard)\nFallback chain stays: cookie → query → header → bearer.\n\nCoupling: the /butlr skill prefers the header path now and falls\nback to ?owner_token= only when the server returns 401 (pre-deploy\nof this commit). Existing browser cookies + the legacy /login?t=\nfast-path are unaffected."},{"hash":"0c6e41e","date":"2026-05-14 08:23:28 -0700","author":"SteveStudio2","subject":"Permissions-Policy: allow geolocation=(self) on wizard /new","body":"Was blocking the wizard-2-business 'Use my location' button with\nChrome err.message 'Geolocation has been disabled in this document\nby permissions policy.' Same-origin top-level only — mic/camera stay\ndenied. Flipped payment to (self) too so Stripe Payment Request\nAPI stops console-warning."},{"hash":"03e71d2","date":"2026-05-13 13:04:43 -0700","author":"SteveStudio2","subject":"admin: /admin/status — live health snapshot UI","body":"Surfaces what scripts/status-snapshot.sh writes to ~/status, but live\nper-request (no dependence on cron persistence). Auto-refreshes every\n30s. Shows butlr + wallco-ai with HTTP, uptime, restarts, pm2 status,\nmemory, CPU. Reads recent ALERT-*.txt files if present.\n\nAdmin-only. URL: https://butlr.agentabrams.com/admin/status"},{"hash":"6db4767","date":"2026-05-13 12:34:51 -0700","author":"SteveStudio2","subject":"sms: friendly GET handler on /twilio/sms-inbound (was 404 in browser)","body":""},{"hash":"4e0a5bc","date":"2026-05-13 12:32:17 -0700","author":"SteveStudio2","subject":"sms: /twilio/sms-inbound STOP-keyword auto-suppression (TCPA compliance)","body":"POST /twilio/sms-inbound — TCPA §64.1200(d) compliant inbound-SMS\nhandler. Twilio's Messaging Service routes inbound SMS here.\n\nKeywords (case-insensitive, word-boundary match anywhere in body):\n- STOP / STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT / REVOKE →\n  dnc.addToInternalSuppression(from, 'sms_stop_keyword', 'twilio_inbound')\n  Reply: \"You are unsubscribed from Butlr. No further messages will be\n  sent. Reply START to re-subscribe.\"\n- START / UNSTOP / YES → removes from suppression (re-opt-in).\n  Reply: \"You are re-subscribed to Butlr. Reply STOP to opt out.\"\n- Anything else → generic identity + opt-out reply (no engagement).\n\nAtomic write on suppression updates (tmp + rename). No-op gracefully\nif From is missing. dnc-check's addToInternalSuppression is already\nidempotent so repeat STOPs don't duplicate.\n\ntest/sms-stop.test.js: 8/8 — STOP/UNSUBSCRIBE/CANCEL add, repeat STOP\nis idempotent, START removes, random body doesn't change list, missing\nFrom → 200 no-op, lowercase \"stop\" matches.\n\nConfigure on Twilio side: Messaging Service → Inbound webhook →\nhttps://butlr.agentabrams.com/twilio/sms-inbound\n\nnpm test now: 49/49 across 6 files (orphan 8 + admin-gate 6 +\nvapi-webhook 11 + dnc 9 + upload-watcher 7 + sms-stop 8)."},{"hash":"f848efc","date":"2026-05-13 12:24:30 -0700","author":"SteveStudio2","subject":"cap repeat dials at 4 per phone — even with allow_repeat, surface count on call detail","body":""},{"hash":"65c8fc1","date":"2026-05-13 12:21:08 -0700","author":"SteveStudio2","subject":"loosen phone validation + add Retry button on call detail + repeat-anyway override on wizard 4","body":""},{"hash":"96ffa09","date":"2026-05-13 12:14:26 -0700","author":"SteveStudio2","subject":"snapshot: backup uncommitted work (4 files)","body":""},{"hash":"986f634","date":"2026-05-13 11:12:18 -0700","author":"SteveStudio2","subject":"upload-watcher: auto-import from connected folder every 30s","body":"Replaces manual \"Sync now\" click with a background scan that runs on\nthe server startup loop.\n\n- lib/upload-watcher.js: scanOnce() walks meta.folder, dedups by\n  (size+mtime) per source_path, copies new allowed-ext files into\n  data/uploads/ with unguessable hex filenames. Logged with source:\n  'auto_watcher' so manual + watcher imports are distinguishable.\n  start({intervalMs:30000}) sets the recurring tick. HFM_NO_WATCHER=1\n  disables (used by tests). First scan fires 1s after server boot.\n- server.js: wires uploadWatcher.start() right after twilio.start().\n- test/upload-watcher.test.js: 7/7 — imports allowed exts, skips bad\n  ext + hidden, idempotent on second scan, picks up new file, meta\n  source_* fields populated, missing folder gracefully reported,\n  no-folder config is a noop.\n- package.json: npm test now runs 41 assertions across 5 files.\n\nSteve drops images in the configured folder; within 30s they appear\nin /admin/uploads with copy-URL chips."},{"hash":"81ccd55","date":"2026-05-13 11:07:16 -0700","author":"SteveStudio2","subject":"uploads: folder-sync — one-shot scan + \"Sync now\" button","body":"Second half of Steve's \"connect to a folder\" ask.\n\n- POST /admin/uploads/folder/sync — admin-gated. Walks meta.folder,\n  imports any file (allowed ext, ≤10MB) not already in the gallery.\n  Dedup via (size + mtime) key per source path. Returns\n  {ok, imported, skipped, errors}. Atomic per-file via copyFileSync.\n- /admin/uploads page: when a folder is configured, shows a \"Sync\n  now\" button + status line next to the path display. Reload page\n  on first new import.\n- New gallery entries from folder-sync carry source_path /\n  source_size / source_mtime so future syncs skip them automatically.\n\nWatcher-on-cron is a follow-up tick (TODO: 30s interval scan on the\nworker tick). For now Steve presses \"Sync now\" when he drops files\ninto the watched folder."},{"hash":"f69ec60","date":"2026-05-13 11:02:20 -0700","author":"SteveStudio2","subject":"admin/uploads: image + small-file upload UI with public URL","body":"GET  /admin/uploads        — drop-zone + gallery + folder-connect field\nPOST /admin/uploads/file   — multipart, 10MB cap, 20 files at once, ext-whitelist\nPOST /admin/uploads/folder — record a local folder path for future watcher\nPOST /admin/uploads/delete — admin delete by filename\nGET  /uploads/<filename>   — public serve (unguessable 24-hex names),\n                              immutable cache, no auth (content-addressed token)\n\nPer Steve's standing rule: \"create a button to upload images to url, or\nconnect to a folder\". Button + drag-drop ships now; the folder side\nrecords a path (watcher implementation deferred — TODO surface in next\ntick once Steve confirms which folder he wants watched).\n\nStorage: data/uploads/<uuid>.<ext> + data/uploads-meta.json. Both\nrsync-excluded + gitignored so prod state persists across deploys.\n\nAllowed exts: png/jpg/jpeg/gif/webp/svg/pdf/mp3/wav/mp4/txt/csv/json.\n\nFrontend: tile gallery, copy-URL chip per file (writes\nlocation.origin + /uploads/<filename> to clipboard), inline preview\nfor images, link-out for non-image types. Delete confirmed via\nwindow.confirm."},{"hash":"eb1df5e","date":"2026-05-13 10:40:34 -0700","author":"SteveStudio2","subject":"cost-tracker: log Vapi voice_min + Twilio voice_min + SMS to ~/.claude/cost-ledger.jsonl","body":"Per Steve's standing rule — every paid API call appends to the ledger.\n\n- ~/.claude/skills/cost-tracker/pricing.json: new vapi_voice entry at\n  $0.10/min conservative midpoint (tune from real invoice). Twilio\n  rates (sms $0.0083 / voice_min $0.014) already present.\n- routes/vapi-webhooks.js: on end-of-call-report, spawn cost-tracker\n  log.js detached with durationSeconds/60 voice_min units. Best-\n  effort — log failure doesn't affect call data.\n- routes/twilio-webhooks.js: on POST /status/<id> with CallStatus=\n  completed, log the CallDuration as voice_min via twilio.logCost().\n- lib/twilio.js: new logCost(api, qty, unit, note) helper. Spawns\n  detached + unref so SMS send / call hangup return immediately.\n  sendSms now logs 1 sms unit on successful send.\n\nSmoke-tested: log.js --api vapi_voice --units 1:voice_min logs\n$0.100000 and writes the jsonl entry as expected."},{"hash":"0dd1395","date":"2026-05-13 10:19:46 -0700","author":"SteveStudio2","subject":"wizard step 2: DNC pre-check on phone field + directory presets","body":"views/public/wizard-2-business.ejs:\n- Custom-phone input fires /api/dnc-check on blur — shows 🚫 + red\n  explainer + disables \"Next\" button if number is on DNC.\n- Selecting a directory preset radio also pre-checks (pulls phone from\n  the muted sibling line). Catches the case where Steve's curated\n  businesses.json includes a number that's been added to suppression.\n- ✓ \"not on DNC\" or \"(dry-run mode)\" confirmation on allowed.\n\nSame contract as the /calls quick-dial form (deployed prior). Now\nusers see DNC status BEFORE submitting either dial path."},{"hash":"73c5692","date":"2026-05-13 10:06:58 -0700","author":"SteveStudio2","subject":"dnc: pre-check endpoint + /calls quick-dial inline warning","body":"- GET /api/dnc-check?phone=… returns {allowed, reason, dry_run_bypass}.\n  Auth-gated. Doesn't log a block — checks-only, no dial attempt\n  happened. Gate-error (snapshot missing) surfaces as allowed=false\n  with reason=gate_error + code, so UI can render the right message.\n- /calls quick-dial form: phone-blur hits /api/dnc-check. On block,\n  dial button is disabled + red 🚫 explainer (\"on federal DNC\" /\n  \"in internal suppression\" / \"DNC list unavailable\"). On allowed,\n  blue ✓ confirmation.\n\nUser now gets pre-flight feedback before pressing Dial; they don't\nwaste a submit cycle finding out the number is suppressed."},{"hash":"2a88642","date":"2026-05-13 09:34:03 -0700","author":"SteveStudio2","subject":"admin/dnc + dnc_flagged surface on /calls","body":"Two visual halves of the DNC compliance work shipped in the prior commit:\n\n- /admin/dnc — owner+role=admin gated page:\n  * federal snapshot status (size on disk, MISSING warning if absent)\n  * add-to-suppression form (phone + reason, manual entry)\n  * current suppression table with per-row \"remove\" button\n  * POST /admin/dnc/add and /admin/dnc/remove are also admin-gated;\n    /remove uses atomic write (tmp+rename) like the main lib does\n- /calls rows now render:\n  * 🚫 DNC pill next to the status pill (red, hover-tooltip with\n    reason+source)\n  * Red sub-line explaining what was blocked + link to /admin/dnc\n  * Only renders when row has dnc_flagged=true (set by tick() at the\n    pre-flight gate)\n\nTogether this gives Steve a complete loop: dial → DNC gate blocks →\nrow is flagged with explanation in his UI → he can remove from\nsuppression in admin if false-positive, or leave it as a permanent\nblock."},{"hash":"9a769b7","date":"2026-05-13 09:32:22 -0700","author":"SteveStudio2","subject":"gitignore: also untrack dnc-suppression + users.json + dnc-blocks","body":""},{"hash":"19da0c1","date":"2026-05-13 09:32:13 -0700","author":"SteveStudio2","subject":"dnc: untrack runtime files (blocks log + suppression list)","body":"data/dnc-blocks.jsonl is append-only audit log (per-call); should not\nbe in git or rsynced. data/dnc-suppression.json is prod-side state\n(per-call adds via STOP keyword / manual admin). Both excluded from\nrsync via RSYNC_EXTRA_EXCLUDES. .gitignore updated to match."},{"hash":"6fcff17","date":"2026-05-13 09:31:57 -0700","author":"SteveStudio2","subject":"dnc-check: fail-closed pre-flight gate for outbound calls + SMS","body":"Steve's standing rule 2026-05-13: \"Do not allow butlr to call anyone\non the do not call list for each state and do not sms too. Any do not\non either, remove from list. Always check this before dialing.\"\n\nImplementation (per comms-compliance subagent's policy review):\n\n- lib/dnc-check.js: checkPhone(num, channel) returns {allowed} after\n  consulting (a) federal DNC snapshot at $DNC_FILE (default\n  ~/.claude/data/dnc/national-dnc.txt), (b) internal suppression list\n  at data/dnc-suppression.json. Missing snapshot THROWS (fail closed).\n  TWILIO_DRY_RUN=1 bypasses for dev. Block events written to\n  data/dnc-blocks.jsonl (last-4 + sha256 only, no PII).\n\n- lib/twilio.js tick(): pre-flight checks BOTH business_phone AND\n  callback_phone before any Twilio/Vapi dispatch. Block → status=\n  'cancelled' + dnc_flagged=true + dnc_reason/source/field on the row.\n  Gate-error (snapshot missing) also fail-closed, same row patches.\n\n- lib/twilio.js sendSms(): pre-flight checks `to` against DNC.\n  Returns {ok:false, error:'dnc_block'} on hit. Gate-error fails closed.\n\n- test/dnc-check.test.js: 9/9 — normalize variants, federal hit,\n  internal suppression, DRY_RUN bypass, missing snapshot throws,\n  addToInternalSuppression idempotent + atomic, non-US passthrough,\n  no-phone validation.\n\n- npm test now: orphan-recordings 8/8 + admin-gate 6/6 +\n  vapi-webhook 11/11 + dnc-check 9/9 = 34/34.\n\nState DNC lists (IN/LA/MO/OK/WY) require paid subscriber portals same\nas federal; not API-accessible. Sticking with federal + internal. CA\nuses federal + stacks penalties via Cal. Bus. & Prof. § 17592.\n\nFederal DNC SAN subscription: https://telemarketing.donotcall.gov/.\nUntil the SAN file is at $DNC_FILE, the gate fails closed on every\nlive dial. Prod seeded with a placeholder so dev mode works."},{"hash":"ed991e5","date":"2026-05-13 09:07:20 -0700","author":"SteveStudio2","subject":"call-quality-report: auto-detect Vapi vs Twilio provider","body":"The 4AnkhRRI smoke call exposed that Butlr's worker is now dialing\nthrough Vapi instead of Twilio — the report was false-flagging\n3 red on a fully-successful call because it only knew the Twilio\nshape (twilio_call_sid, local MP3 in data/recordings/, local\nwhisper transcript).\n\nNow auto-detects provider by which sid is present and branches:\n- TWILIO path: existing Twilio API cross-check + local MP3/PCM +\n  data/transcripts/*.json + TwiML validate.\n- VAPI path: HEAD probe on c.recording_url (storage.vapi.ai),\n  validates c.transcript_path starts with \"vapi:\", confirms the\n  /vapi/webhook route is mounted (covered by test/vapi-webhook.test.js).\n- UNKNOWN provider stays red.\n\nHeader now shows \"(VAPI)\" or \"(TWILIO)\" so the operator immediately\nknows which infrastructure the call used.\n\nVerified by re-running against 4AnkhRRI: 7/7 green, recording confirmed\n1141.9KB audio/wav on storage.vapi.ai (publicly fetchable)."},{"hash":"f05df40","date":"2026-05-13 08:59:39 -0700","author":"SteveStudio2","subject":"test: 11/11 unit test for routes/vapi-webhooks.js","body":"Locks in the Vapi event-shape contract before Steve starts routing\nreal call traffic through /vapi/webhook. Tests cover:\n- status-update mappings (queued/ringing/in-progress/ended/failed →\n  Butlr's internal statuses dialing/connected/done/failed)\n- skips DB write when metadata.butlr_call_id is absent\n- final transcript appends to data/agent-log/<id>.json\n- partial/non-final transcript chunks do NOT log\n- end-of-call-report stores recording_url + summary in DB + log\n- unknown event types return 200 with no side effects\n- empty body returns 200, no crash\n\nUses require.cache injection to stub lib/data + fs read/write\nredirect to a tmp dir so the real calls.json/agent-log isn't touched.\n\nnpm test now runs 25 assertions across 3 files (orphan-recordings 8/8\n+ admin-gate 6/6 + vapi-webhook 11/11)."},{"hash":"fde778e","date":"2026-05-13 08:57:54 -0700","author":"Steve","subject":"snapshot: 5 file(s) changed, +2 new, ~3 modified","body":""},{"hash":"bf42377","date":"2026-05-13 08:27:38 -0700","author":"SteveStudio2","subject":"call-quality-report: one-shot 7-check report card for any call_id","body":"Cross-checks 4 systems for any call:\n- DB record exists + user-scoped\n- Twilio API status/duration matches local DB\n- Recording (MP3) exists + size; warns if PCM left unfinalized\n- Transcript exists + content sample\n- Live TwiML still serves the inline-Gather/both_tracks/no-Redirect\n  contract for this call_id\n\nGreen/yellow/red per check + exit code 0 only on all-green. Supports\n--remote to ssh into prod for you. Wired into npm: `npm run report --\nn87ResHr`.\n\nFirst run against n87ResHr (NIST both_tracks proof) — 7/7 green. The\ntranscript starts with \"Hello!\" (Butlr's greeting via ElevenLabs) +\nNIST time-reading (Whisper mis-recognized as \"singing in foreign\nlanguage\"), confirming track=\"both_tracks\" captures both sides."},{"hash":"83398b4","date":"2026-05-13 07:36:46 -0700","author":"SteveStudio2","subject":"twilio: Stream track=both_tracks so listener hears Butlr's voice too","body":"inbound_track only captured the business side — the listener heard\nthe IVR and rep but Butlr's own TTS responses were silent. AI and rep\nspeak in alternation, not overlap, so there's no echo to avoid by\nsplitting tracks. Switch to both_tracks. Bonus: Steve can now verify\nthe consent announcement actually played at call start.\n\nExposed by Steve testing the post-prompt-fix AAA + Capital One calls\n2026-05-13 — he reported \"nothing is talking back\" while logs showed\nthe AI was correctly responding \"No\" / \"I don't know\" / etc."},{"hash":"d68c3bf","date":"2026-05-13 07:31:26 -0700","author":"SteveStudio2","subject":"ai-agent: fix 3 bugs exposed by AAA call PSmRTAJ5","body":"The AAA Roadside call (Steve testing membership verification) exposed\nthree prompt-engineering bugs:\n\n1. AI greeted rep \"Carol\" as \"Sarah\" because Sarah was in the worked\n   example and the model copied it literally. Fix: explicit \"DO NOT\n   memorize names from examples below. Extract from each input. The\n   name CHANGES per call\" instruction + 4 worked examples with 4\n   different names (Sarah/Carol/Marcus/no-name).\n\n2. AI pressed digits not present in the menu — pressed 4 when \"for\n   roadside press 1, for travel press 2\" was offered. Fix: \"ONLY\n   press a digit that was EXPLICITLY mentioned. The digit MUST\n   literally appear in the input.\" Plus new yes/no question rule\n   (the original Kentucky prompt got pressed-0 when it should have\n   been \"No\" spoken aloud).\n\n3. AI hung up claiming \"goal achieved\" on empty input right after\n   greeting Carol — never actually heard the answer. Fix: GOAL\n   ACHIEVED now requires \"the rep EXPLICITLY answered the goal\n   question\". Empty input now ALWAYS routes to wait, never hangup.\n\nAlso added rule 7: rep follow-up handler so when the rep asks for\nphone-on-account or name-on-account, we answer concisely with the\nreal data."},{"hash":"6fe29bf","date":"2026-05-13 07:22:53 -0700","author":"SteveStudio2","subject":"predeploy-guard: abort deploy if any call is in flight","body":"Discovered 2026-05-13 in the 5-call live test — a mid-test deploy\nkilled 3 active calls' WSS audio capture because pm2 reload drops the\nMedia Streams lane. The AI agent loop survives (it's stateless from\nTwilio's POV — they just keep POSTing /twilio/gather), but the PCM\nstream goes dark and the MP3 finalize never fires.\n\nscripts/predeploy-guard.sh queries prod's calls.json for any call in\nstatus dialing/on_hold/connected, prints them, and exits 2 unless\nFORCE_DEPLOY=1 is set. Hooked in via .deploy.conf's new PREDEPLOY_HOOK\nvariable, which the shared deploy.sh (also in this commit) now runs\nbefore the rsync step.\n\nStale-on-hold cleanup: 3 Wells Fargo calls (6o3PMv5s, npTGTE7T,\nGNNCY2co) had been stuck in status=on_hold since 2026-05-12 because\ntheir /twilio/status callbacks were lost to a prior pm2 restart. One-\nshot Twilio API query confirmed all 3 actually completed (45/125/106s\ndurations) — synced to status=done.\n\nAudio path proof (post-this-commit): NIST call lbd8VV_r and Steve's\nMacEnthusiasts quick-dial call 7pCAWx5A both produced full MP3 +\nwhisper.cpp transcript. The /calls inline UI works end-to-end."},{"hash":"3f6963c","date":"2026-05-13 07:18:22 -0700","author":"SteveStudio2","subject":"calls: quick-dial form + inline transcript viewer + listen-live link","body":"Per Steve's \"allow user to dial from here and listen\" — the /calls page\nnow does all three:\n- Top-of-page Quick dial form (business name + phone + goal). POSTs\n  /api/quick-dial which uses immediate-dial path so call moves the\n  instant the form returns.\n- Per-row \"🔊 Listen live →\" chip when status is queued/dialing/\n  on_hold/connected, links to /calls/<id>/live (gesture-unlock + WSS).\n- Per-row inline <audio> when status is done/failed/completed (hidden\n  unless /has-recording confirms MP3 exists).\n- Per-row \"📝 transcript\" toggle button (hidden unless /has-transcript\n  says it exists). Click to expand inline below the audio; renders\n  segments with start-time prefixes, falls back to raw text or JSON.\n- Auto-poll status every 5s so rows transition live→archived without\n  page reload.\n\nroutes/public.js: new POST /api/quick-dial handler, validates body,\nfalls back to env phone if user has no email_phone, kicks immediate\ndial in-process, returns {ok, id}."},{"hash":"f977833","date":"2026-05-13 06:28:04 -0700","author":"SteveStudio2","subject":"admin-gate + has-transcript: unit tests + 5 routes/listen.js endpoints","body":"Tick 6 work:\n- lib/admin-gate.js: extract requireAdmin middleware. Pure function,\n  unit-testable, no Express deps.\n- test/admin-gate.test.js: 6/6 — no-user/wrong-role/no-role/case-sensitive\n  all 403; role==='admin' calls next(). Asserts 404 template body so the\n  status leaks 403 but the body looks like a 404 (no enumeration).\n- routes/listen.js: re-add /api/calls/<id>/has-recording (lost in an\n  earlier external rollback — was documented in ead4134 but the route\n  body never made it to disk), and add new /api/calls/<id>/has-transcript.\n  Both owner-scoped via data.getCall, cheap existence probes.\n- views/public/calls.ejs: each row now shows a \"📝 transcript\" chip\n  next to the inline player when /has-transcript says it exists.\n- routes/admin.js: delegate to lib/admin-gate.requireAdmin.\n- package.json: npm test runs orphan-recordings (8/8) + admin-gate (6/6).\n\nYOLO tick 6 — all reversible, local-only."},{"hash":"2341930","date":"2026-05-13 05:55:21 -0700","author":"SteveStudio2","subject":"orphan-recordings: extract pure scanOrphans() + unit test (8/8)","body":"- lib/orphan-recordings.js: scanOrphans({dir, knownIds}) extracted from\n  routes/admin.js. No Express deps so it can be unit-tested in isolation.\n  Accepts optional knownIds injection for tests; defaults to live calls.json.\n- routes/admin.js: delegates to scanOrphans(), 20 lines lighter.\n- test/orphan-recordings.test.js: 8 assertions in a temp dir —\n  returns-exactly-N, mtime-desc-sort, known-ID exclusion, .pcm rejection,\n  empty/nonexistent dirs. npm test exits 0 on full pass.\n- package.json: wire \"npm test\" script.\n\nTick 5 validation findings:\n- validate-twiml.js: 11/11 green on UozqdFJg + JsAqO2by (two different\n  prod call IDs); confirms inline-Gather/no-Redirect/inbound_track holds\n  across calls, not just US33rQWN.\n\nYOLO tick 5 — all reversible, local-only."},{"hash":"2196d1f","date":"2026-05-13 05:23:41 -0700","author":"SteveStudio2","subject":"admin: /admin/orphan-recordings route + UA regex fix for /stream guard","body":"- routes/admin.js: new requireAdmin-gated route lists MP3s in\n  data/recordings/ with no matching call_id. Two orphans currently\n  on prod (D5y8T2YV, gdRSfYO5) — leftovers from a pre-RSYNC_EXTRA_\n  EXCLUDES rsync wipe. Inline <audio> for each so Steve can ID by\n  ear, then delete/relink manually.\n- server.js: mount /admin behind requireOwner; admin.js double-checks\n  req.user.role === 'admin' per-route.\n- lib/listen-bridge.js: external add of TwilioProxy UA guard on\n  /stream WS lane had a regex that wouldn't match the actual UA\n  (\"Twilio.TmeWs/1.0\" per nginx logs) — would have broken every live\n  call. Fixed regex to match both observed Twilio UA forms while\n  still blocking everything else.\n- Plus external add of orphan-PCM recovery on boot — finalizes any\n  .pcm files left from a mid-call process death. Kept.\n\nYOLO tick 4 — all reversible, local-only."},{"hash":"27d8207","date":"2026-05-12 21:08:27 -0700","author":"SteveStudio2","subject":"scripts: validate-twiml.js — 11-assertion smoke test for prod TwiML","body":"POSTs /twilio/twiml/<call_id> against prod and asserts:\n- track=\"inbound_track\" on <Stream>\n- no <Redirect> (would tear down <Stream>)\n- exactly one <Gather> with actionOnEmptyResult=true\n- gather action URL matches /twilio/gather/<id>\n- announce present (Play or Say fallback)\n\nUsed in YOLO tick 3 to confirm cd10f95 deploy was correct on prod —\nreturned 11/11 green. Exit code = 0 on all-pass for CI use."},{"hash":"ead4134","date":"2026-05-12 20:28:17 -0700","author":"SteveStudio2","subject":"calls: inline MP3 player per row + has-recording probe + LLM pre-warm","body":"- views/public/calls.ejs: each done/failed/completed row now renders an\n  <audio controls> inline. JS probes /api/calls/<id>/has-recording on\n  load and only reveals the audio block if MP3 exists. Skips the rows\n  with no recording (dry-run calls, lost-audio calls like DTCI7PuZ).\n- routes/listen.js: new GET /api/calls/<id>/has-recording returns\n  {ok, exists, size_bytes} — cheap probe without streaming.\n- views/public/listen.ejs: minor button-text consistency fix on disconnect.\n- server.js: pre-warm Ollama at startup with keep_alive=24h so first\n  real call doesn't hit BUTLR_LLM_TIMEOUT_MS on cold cache.\n\nYOLO tick 2 — all reversible, local-only."},{"hash":"cd10f95","date":"2026-05-12 17:44:57 -0700","author":"SteveStudio2","subject":"twilio: kill <Redirect> + inline-Gather + inbound_track + AudioCtx guard","body":"Three fixes that together unblock live audio on Steve's calls:\n\n1. routes/twilio-webhooks.js — DROP <Redirect> after Gather. Redirect ends\n   the TwiML execution context and tears down <Start><Stream/>. Switch to\n   self-loop inline Gather: /gather POST returns a new <Say> + inline\n   <Gather> in the same response, so the stream persists across all turns\n   of the conversation. Also add track=\"inbound_track\" to <Stream> — the\n   default both_tracks mixes Butlr's outbound TTS into the listen feed.\n   Plus action-first system prompt with worked examples for press/wait/\n   escalate/hangup so qwen3 doesn't deflect with \"One moment please\".\n\n2. views/public/call-live.ejs — beep on status=connected now guards on\n   window.__butlrAudioCtx (only fires if user already clicked \"Listen live\"\n   to satisfy Chrome's autoplay gesture policy). Listen-live block exposes\n   audioCtx as window.__butlrAudioCtx so the guard works.\n\n3. server.js — add uncaughtException + unhandledRejection + signal\n   handlers. pm2 was restarting butlr ~5×/10min on 2026-05-12 with clean\n   SIGINT exits and no stack traces; explicit handlers surface the\n   cause on next crash.\n\nAlso: views/public/listen.ejs split into live/archive sections so the\npost-call MP3 player auto-shows once call.status hits done/failed."},{"hash":"da345d1","date":"2026-05-12 17:40:14 -0700","author":"SteveStudio2","subject":"call-live: integrate listen-live audio inline so call audio auto-prompts when status hits dialing","body":"Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"},{"hash":"4a77606","date":"2026-05-12 17:34:11 -0700","author":"SteveStudio2","subject":"audio: finalize-recordings.js + diagnostics that found Twilio fix","body":"After Steve's 'i heard nothing' report, traced through Twilio Alerts\nAPI → nginx access logs → pm2 logs to confirm Media Streams audio path\nis actually working post-StatusCallbackEvent fix.\n\nThe latest call US33rQWN captured 44s of audio to data/recordings/\nand triggered the AI agent loop. Steve heard nothing only because he\nwasn't on the live listen page during the call.\n\n  • scripts/finalize-recordings.js — convert leftover .pcm → .mp3 via\n    ffmpeg on every deploy, so a crashed/restarted pm2 doesn't strand\n    raw PCM. Idempotent — skips when .mp3 already exists.\n  • scripts/diagnose-audio.js + diagnose-wss.js — kept for future\n    audit. Print pm2 logs, nginx access/error, Twilio Recordings API.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"fc2ef8e","date":"2026-05-12 17:28:35 -0700","author":"SteveStudio2","subject":"twilio: fix StatusCallbackEvent — repeated param not space-string","body":"Twilio's REST API rejects 'initiated ringing answered completed' as\na single string with error 21626 (Invalid Events) and silently falls\nback to firing only the 'completed' webhook. This is exactly why our\n/twilio/status/:id route never received ringing/answered events all\nday. Fix: use URLSearchParams.append() to pass StatusCallbackEvent\nas a repeated param.\n\nFound by checking Monitor v1 Alerts API on the 23:45 UTC calls today.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>\nEOF"},{"hash":"bc26177","date":"2026-05-12 17:09:24 -0700","author":"SteveStudio2","subject":"deploy.conf: lock in data/ excludes — rsync had wiped real call records","body":"After today's data-loss incident (prod's calls.json was overwritten by\nlocal test fixture during an earlier rsync --delete), reinforce the\nRSYNC_EXTRA_EXCLUDES list and document the why so a future deploy.conf\ndiff doesn't quietly drop the excludes again.\n\nRestored 4 Wells Fargo records from ~/Desktop/butlr-calls-LATEST.json\nin the prior deploy.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"4d008b0","date":"2026-05-12 17:03:23 -0700","author":"SteveStudio2","subject":"migrate: re-link orphaned user_id refs to current legacy admin","body":"When users.json is wiped between deploys (which happened during the\nv2 rollout — fixed by RSYNC_EXTRA_EXCLUDES in .deploy.conf), existing\ncalls keep pointing at the old admin's user_id. The new admin gets a\nfresh id, and the calls become invisible to Steve. This pass detects\nuser_ids missing from users.json and re-stamps them to the current\nlegacy admin. Idempotent.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"927b54f","date":"2026-05-12 17:00:59 -0700","author":"SteveStudio2","subject":"owner-auth: detect /api/* via originalUrl so middleware mounted at /api/calls returns 401 JSON","body":""},{"hash":"73f6463","date":"2026-05-12 16:59:21 -0700","author":"SteveStudio2","subject":"feat(auth): per-user accounts v2 — email+password signup/login, user_id scoping","body":"Replace single BUTLR_OWNER_TOKEN with per-user accounts backed by\ndata/users.json (atomic writes, bcrypt password hashes, 64-char hex\nowner_tokens used as cookie values).\n\n  • lib/users.js — create/verify/lookup, bcrypt cost 12, sha256-keyed\n    token lookup for constant-time auth without bcrypt per-request\n  • lib/owner-auth.js — req.user populated from cookie → users.findByOwnerToken;\n    new GET/POST /signup + /login forms with email + password\n  • lib/data.js — addCall/listCalls/getCall all take userId now;\n    rows include user_id; getCall returns null when user_id mismatches\n    (404 not 403, no oracle); listCallsUnscoped/getCallUnscoped reserved\n    for Twilio webhook + background worker contexts only\n  • server.js — hard-gates /calls, /listen, /api/calls, /new behind\n    requireOwner; seeds legacy BUTLR_OWNER_TOKEN as admin user on boot\n  • scripts/migrate-calls-to-user.js — one-shot stamp existing calls\n    with legacy admin user_id (idempotent, dry-run flag, backs up\n    calls.json before write)\n\nBackward compat: existing browser cookies keep working because the\nseeded admin's owner_token == BUTLR_OWNER_TOKEN env value.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"79e9940","date":"2026-05-12 16:54:01 -0700","author":"SteveStudio2","subject":"deploy: fix .deploy.conf path to /root/public-projects/butlr + drop debug fingerprints","body":"pm2 runs butlr from /root/public-projects/butlr (not the original\nholdforme path). Earlier deploys rsynced to /root/Projects/holdforme\nwhich was a dead mirror, so new routes never reached the live process.\nConfirmed via embedded pm2 jlist discovery.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"c1c36a4","date":"2026-05-12 16:51:30 -0700","author":"SteveStudio2","subject":"deploy: server-level fingerprint route","body":""},{"hash":"fb73a00","date":"2026-05-12 16:50:45 -0700","author":"SteveStudio2","subject":"deploy fingerprint route for verification","body":""},{"hash":"ab050b2","date":"2026-05-12 16:43:46 -0700","author":"SteveStudio2","subject":"twilio: number-level voice-default + status-default fallbacks + deploy conf","body":"Adds two Twilio webhook fallback routes used by the +16067130489\nincoming-phone-number itself (not per-call):\n  POST/GET /twilio/voice-default  — polite inbound rejection TwiML\n  POST     /twilio/status-default — number-level status log-only\n\nWired via Twilio REST API on the number, so callbacks land at\nbutlr.agentabrams.com regardless of whether a worker has the right\nPUBLIC_URL set. Belt-and-suspenders for the 2026-05-12 outage where\nthe local .env had a stale trycloudflare PUBLIC_URL.\n\nAlso adds .deploy.conf so /deploy can ship to Kamatera, and gitignores\nruntime artifact dirs (data/recordings, transcripts, audio-cache,\nmenu-map) to prevent committed test debris.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"5150ae3","date":"2026-05-12 16:43:42 -0700","author":"SteveStudio2","subject":"listen-bridge: per-call PCM archive + ffmpeg→MP3 finalize on stream close","body":"Persists every Twilio MediaStream to data/recordings/<id>.pcm even\nwhen no browser is connected. On close, shells out to ffmpeg to\nproduce an MP3 + (optionally) kicks local Whisper. Prevents repeat of\nthe 2026-05-12 DTCI7PuZ Wells Fargo loss where audio path failed and\nthe call left zero artifact on disk.\n\nCo-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"},{"hash":"5ef12b5","date":"2026-05-12 16:04:14 -0700","author":"Steve Abrams","subject":"security(P0): kill ?reveal=1 leak + owner-auth + robots noindex","body":"Fixes the GPTBot exposure at 22:51:46 UTC where /calls/DTCI7PuZ?reveal=1\nreturned the decrypted Wells Fargo account number (0209203652) in plain\nHTML to anyone with the call ID — no auth, no robots.txt, no noindex.\n\nChanges:\n1. lib/owner-auth.js — new middleware. Gates /calls, /calls/:id,\n   /listen/:id, /api/calls/:id/* behind a BUTLR_OWNER_TOKEN cookie\n   (or ?owner_token=). Constant-time compare. /login route renders a\n   paste-token form. /logout clears.\n2. routes/public.js — reveal=1 now requires req.ownerAuthed=true.\n   Always sets X-Robots-Tag: noindex.\n3. server.js — mounts /login + /logout publicly, then requireOwner\n   on every call-data surface BEFORE the route handlers see the\n   request. Soft-auth everywhere else for view locals.\n4. public/robots.txt — Disallow: /calls /listen /api /twilio /login.\n   Explicit Disallow: / for GPTBot, ChatGPT-User, OAI-SearchBot,\n   ClaudeBot, anthropic-ai, Claude-Web, PerplexityBot, Perplexity-User,\n   CCBot, Bytespider, Amazonbot, cohere-ai, Google-Extended,\n   FacebookBot, Applebot-Extended, Diffbot, ImagesiftBot, TimpiBot.\n5. views/partials/head.ejs — noindex,nofollow,noarchive,nosnippet\n   meta on every page by default; routes opt in to indexing.\n\nVerified post-deploy:\n  - GET /calls/DTCI7PuZ?reveal=1 (no cookie) → 302 /login (was 200 + acct#)\n  - GET /api/calls/DTCI7PuZ/status (no cookie) → 302 /login (was 200 JSON)\n  - GET /listen/DTCI7PuZ (no cookie) → 302 /login (was 200 page)\n  - GET /robots.txt → 200 (was 404)\n  - GET /calls/DTCI7PuZ?reveal=1 + owner cookie → 200 + acct visible (owner OK)\n  - GET / (no cookie) → 200 (homepage still public)\n\nBUTLR_OWNER_TOKEN registered with /secrets (digest ...da8c:693bb5f9).\nToken persisted at /tmp/butlr_owner_token + Kamatera .env."},{"hash":"4d03f06","date":"2026-05-12 15:56:29 -0700","author":"Steve","subject":"AI-gather fixes: hermes3:8b model (0.3s warm vs 6.9s qwen3:14b), speechTimeout=3 (no more mid-sentence clipping), 4s LLM AbortController timeout (no more 24s call-killers), warm keep_alive=15m","body":""},{"hash":"391b859","date":"2026-05-12 15:50:26 -0700","author":"Steve Abrams","subject":"feat(listen): live AI-agent transcript fallback when Twilio Stream unavailable","body":"/api/calls/:id/agent-log greps pm2 logs for [ai-agent] heard/reply lines and\nreturns parsed turns. Listen page polls every 3s and renders bubbles\n(They said / Butlr replied). Works while Twilio Media Streams is disabled\nat the account tier — gives Steve real-time visibility into the call\nwithout depending on the audio bridge.\n\nDTCI7PuZ Wells Fargo call confirmed working: turn 1 heard recording\ndisclaimer, Butlr replied requesting $100K limit increase."},{"hash":"ffd47bd","date":"2026-05-12 14:51:19 -0700","author":"Steve","subject":"wire MacEnthusiasts IVR DTMF (press 2 for sales)","body":"Adds the MacEnthusiasts row in data/businesses.json with ivr_dtmf:\n[{delay_sec:6, digit:'2'}]. The twimlHandler() picks this up via\nivrDtmfForPhone() and emits <Pause length=6/><Play digits=2/> after\nthe consent announcement.\n\nDiscovered the IVR layout from the 2026-05-12 test-call transcript\n(CAb64d5b1ee, whisper.cpp local) — 1=rentals, 2=sales, 3=service,\n4=accounting, 5=address/hours, *=directory, #=repeat."},{"hash":"2c7bdce","date":"2026-05-12 14:50:04 -0700","author":"Steve","subject":"transcribe: whisper.cpp PRIMARY, OpenAI Whisper FALLBACK","body":"Reason — 2026-05-12 MacEnthusiasts test call (CAb64d5b1ee, 93s) landed\nfine, MP3 saved, but OpenAI Whisper returned 429 insufficient_quota and\nleft us blind. whisper-cpp on the local M2 Max transcribed the same file\nin 1.6s. Local-first removes the OpenAI quota dependency entirely.\n\ntranscribeLocal() — runs ffmpeg → whisper-cli with ggml-base.en model.\ntranscribeRemote() — original OpenAI path, kept as a fallback only.\ntranscribe() — dispatcher: try local, fall back to remote, error if both fail.\n\nEnv knobs (all optional, sensible Homebrew defaults):\n  WHISPER_CLI_PATH=/opt/homebrew/bin/whisper-cli\n  FFMPEG_PATH=/opt/homebrew/bin/ffmpeg\n  WHISPER_MODEL_PATH=~/.cache/whisper-cpp/ggml-base.en.bin"},{"hash":"f463de5","date":"2026-05-12 14:08:18 -0700","author":"Steve","subject":"API-Key auth preferred over Auth Token for Twilio","body":"twilioAuth() resolver picks the best available credential:\n  1) TWILIO_API_KEY_SID + TWILIO_API_KEY_SECRET (preferred, revocable)\n  2) TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN (fallback, master cred)\n  3) error if neither\n\nWires through dialCall(), sendSms(), and transcribe.downloadRecording().\nlog() now records which auth mode was used per call."},{"hash":"54497d0","date":"2026-05-12 13:09:12 -0700","author":"SteveStudio2","subject":"rebrand: HoldForMe → Butlr (mass swap across 27 files: brand mark ⏱→🎩, all titles/wordmarks/email-placeholders/SMS-body strings updated to Butlr; legal page emails now @butlr.ai; project directory name kept as 'holdforme' to preserve pm2 process refs + git remotes; TM-collision risk with butlr.com sensor-tech firm noted and accepted by Steve)","body":""},{"hash":"9846567","date":"2026-05-12 12:59:14 -0700","author":"Steve","subject":"live-audio bridge + recording + Whisper transcript on finish","body":"End-to-end pipeline for Phase 2 going live:\n\n  lib/elevenlabs.js   — TTS for the announce preamble, sha256 cached,\n                        graceful fallback to Twilio <Say> on 401/network err\n  lib/listen-bridge.js — single ws server, two lanes:\n                        /stream/<id>   = Twilio Media Streams (µ-law → PCM16)\n                        /listen-ws/<id> = browser playback (raw PCM frames)\n  lib/transcribe.js   — Twilio Recording download (basic-auth) + Whisper\n                        verbose_json, stored at data/transcripts/<id>.json\n  lib/twilio.js       — LIVE dialCall() now real, POSTs to Twilio Calls API\n                        with Url=/twilio/twiml/<id>, status/AMD callbacks,\n                        AsyncAmd for human-vs-voicemail branch\n  routes/twilio-webhooks.js — /twiml /status /recording /amd /audio/announce\n                              TwiML: <Stream> + announce + <Record/> with\n                              recordingStatusCallback → kicks Whisper\n  routes/listen.js    — /listen/<id> browser page + /calls/<id>/{transcript,recording}\n  views/public/listen.ejs — Web Audio API playback page with consent banner\n  server.js           — http.createServer, attachListenBridge(server),\n                        CSP allows ws:/wss: + media-src blob:\n  lib/data.js         — setTwilioSid/setRecordingMeta/setTranscriptMeta\n\nAll gated by feedback_holdforme_always_announce.md — every outbound call\nopens with two-party-consent announcement, no skip flag, ever."},{"hash":"040cea4","date":"2026-05-12 12:50:09 -0700","author":"Steve","subject":"always-announce two-party consent preamble — Steve's standing rule 2026-05-12","body":"Per ~/.claude/.../memory/feedback_holdforme_always_announce.md:\n- buildAnnouncement(call) + announceTwiml(call) in lib/twilio.js\n- DRY-RUN now logs [ANNOUNCE] preamble line so the rule is visible pre-Twilio\n- LIVE mode will emit <Say>+<Pause> as first TwiML node on every outbound call\n- No skip flag; California Cal. Penal Code § 632 protection is uniform"},{"hash":"38ede4f","date":"2026-05-12 12:30:22 -0700","author":"SteveStudio2","subject":"/social-claim — admin tool that generates a one-page claim sheet for any brand name. Tier 1 (high sniping risk: Instagram, X, TikTok, YouTube, Threads), Tier 2 (LinkedIn Company, Facebook Page, Bluesky, GitHub, Pinterest, Reddit), Tier 3 (Snapchat, Twitch, Vimeo, Medium, Substack, Discord, Patreon), bonus (Product Hunt, Crunchbase, HN, Trustpilot, BBB). Every link goes to the platform's actual signup flow, not a check page. Honest about the platform-side limitation: no public API for new-account creation, all require manual CAPTCHA + phone.","body":""},{"hash":"282926f","date":"2026-05-12 12:15:09 -0700","author":"SteveStudio2","subject":"perfect: (1) AES-256-GCM encryption at rest for account_number/last4_ssn/billing_zip/date_of_birth/auth_password — envelope v1.gcm.<iv>.<tag>.<ct>, key from HFM_ENC_KEY env (fail-closed in prod, warn-and-plaintext in dev); reveal=1 decrypts; (2) Privacy + Terms + Accessibility pages with CCPA/CPRA/TCPA/two-party-consent language + WCAG 2.1 AA commitment + known-gaps list; (3) Pricing page (Free/Pro $9/Concierge $29) with Stripe checkout stub (STRIPE_DRY_RUN by default, logs the plan); (4) SMS notifications via Twilio Messages API — dry-run by default, fires on on_hold/connected/done transitions with category-appropriate copy; (5) footer now links Privacy/Terms/Accessibility/Pricing on every page","body":""},{"hash":"c889dbb","date":"2026-05-12 11:55:27 -0700","author":"SteveStudio2","subject":"polish: (1) 44-business preset directory across 13 categories with IVR notes per business — Chase/AmEx/Cedars-Sinai/UCLA/Anthem/Delta/Comcast/IRS/etc; (2) 4-step onboarding wizard (category → business picker → goal + account → callback + consent) replacing the single mega-form, with progress bar + hidden-input state carry; (3) Twilio Voice worker — dry-run mode by default, advances calls through queued → dialing → on_hold → connected → done over ~50s simulated; live mode scaffolded behind TWILIO_DRY_RUN=0 + creds; 5s tick interval.","body":""},{"hash":"af3a901","date":"2026-05-12 10:33:11 -0700","author":"SteveStudio2","subject":"initial scaffold — HoldForMe phase-1 onboarding MVP. Captures the full call submission (business + goal + account info + callback + limits + consent) across 13 category presets. Standing rules: gucci hero, logo UL/hamburger UR, helmet+rate-limit security baseline, dark/light toggle with localStorage. Phase 2 will wire Twilio Voice.","body":""}]}