# Project export: AIntercept

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Hangs up on scammers before your grandparents do.
- Devpost: https://devpost.com/software/aintercept
- GitHub: https://github.com/kritav/AIntercept-CalHacks
- Video: https://www.youtube.com/embed/F49hIC8Rtbw?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — kritav (1 commits)

## Devpost submission (written by the team)

### Inspiration

Last year, my grandma fell for a fake police officer scam. They called her and told her that she was under investigation by the police department and that she could not tell anyone. She was told to take pictures of various forms of identification, which she sent to the scammers. Luckily, my uncle found out what was going on before they took anything of monetary value. Every year, elderly people lose billions to phone scams: fake IRS agents, "your grandson is in jail" emergencies, gift-card payment demands. The victim is alone on the call, and by the time the family finds out, the money's gone.

### What it does

AIntercept is a real-time guardian that sits on the line and listens. As the call is transcribed live, a cheap semantic gateway filter watches every sentence; the moment the conversation sounds like a scam (even with no obvious keywords), it escalates that slice to AI for a structured verdict. If the AI says it's an active scam, AIntercept terminates the call and texts the family instantly; if it's merely suspicious, it quietly notifies family without hanging up. It also remembers across calls and across days, so a household that's been targeted before is flagged the moment the next suspicious call comes in.

### How we built it

Frontend: Static HTML/CSS/Typescript — served from public/ (index.html, app.html, history.html, styles.css) Backend: Runtime: Node.js (ESM-only) with TypeScript 5.7, run via tsx Web server: Express 5 Real-time: ws (WebSocket) — Twilio Media Streams in, dashboard events out Telephony: Twilio (Media Streams, mu-law 8 kHz audio) Speech-to-text: Deepgram SDK (nova-2) LLM / scam judge: Anthropic Claude via Token Router path Embeddings / semantic gate: @xenova/transformers (local embeddings) feeding a Redis vector gate Datastore / memory: Redis (redis v4) — vector search + persistent household "memory"

### Challenges we ran into

Recording a call using Twilio and connecting that to a ngrok webhook Redis wouldn't work properly due to school firewall, had to run locally

### Accomplishments we're proud of

An accomplishment that we are especially proud of is the fact that we were able to have calls be screened in real time for scams. For us, this is a step forward in preventing elderly populations from being scammed due to a lack of information about fraudulent phone calls.

### What we learned

Anything could break at the last minute, but the best way to get through that is by giving time. Make sure all webhook links are correct otherwise you will spend hours trying to fix bugs that don't exist Ask a lot of questions online and at the hackathon for help

### What's next

Our next move would be to have this connect with people's real phone numbers. Right now, this only works by phone numbers provided by Twilio.

## README (from the GitHub repository)

# AIntercept
<div align="center">
  <img src="/public/logo.png" width="500" />
</div>
A real-time call guardian for elderly scam prevention. It transcribes a live
phone call, watches for scam patterns, and surfaces a calm "family view"
dashboard that turns red the moment a call looks dangerous.

**Pipeline:** call leg → Twilio Media Streams → Deepgram (speech-to-text) →
transcript → Claude (scam verdict) → alert. The audio source is swappable: a
local file for development, a real Twilio call for the demo.

> Build status: **Pass 1 complete + Pass 2a** — transcription spine, Twilio call
> leg, live dashboard, gated Claude scam judge, verdict-driven action, and a
> Redis-backed semantic gate + per-call / per-household memory.

##  Submission — UC Berkeley AI Hackathon 2026

AIntercept is our submission to the **UC Berkeley AI Hackathon 2026**. We're applying
to the following tracks:

- **Best Use of Claude** (projects built with Claude Code) — the codebase was
  built with Claude Code
- **Best Use of Redis (Beyond Caching)** — Redis is our memory and retrieval layer,
  not a cache. We use **RediSearch vector search** (COSINE KNN over an embedded
  scam-script corpus) as the semantic suspicion gate, and Redis as **agent memory**:
  per-call transcripts/gate-hits/verdicts and per-household scam history that biases
  the next call from a known caller. The new read-only **past-call history** view is
  served straight from these records (`call_summary:{id}` + a `calls:index` sorted set).
- **Best Creativity & Originality** — a calm, family-facing "call guardian" that
  protects elderly people from phone scams in real time, turning the dashboard red and
  hanging up the moment a call looks dangerous. Solving a real, human problem (elder
  fraud) in a way we haven't seen done live on a phone call.
- **Best Technical Implementation** — a clean, swappable architecture (audio source →
  transcriber → suspicion gate → judge → action, each behind an interface), a
  two-stage cheap-filter-then-LLM design that keeps cost and latency down, fail-safe
  verdict parsing, graceful Redis fallback, and Redis-indexed history that scales the
  list view without reading every full record.
- **Best Use of Deepgram** — Deepgram (nova-2) powers the live voice experience: it
  streams speech-to-text off the Twilio media socket in real time, distinguishing
  interim vs. final results so the dashboard shows a live transcript while only final
  lines feed the scam judge.
- **Best Use of TokenRouter by PaleBlueDot AI** — the Claude judge is reached through
  **TokenRouter**, a single OpenAI-compatible gateway (one base URL + key). The
  judge's prompt, verdict schema, and parser are transport-agnostic, so we route Claude
  through TokenRouter by default and can swap to the direct Anthropic SDK with one env
  flag — no other code changes.

## How the scam judge works (Steps 4–5)

Three stages, so the expensive reasoning is protected by a cheap filter and the
verdict drives a real consequence:

1. **Gate** — every final transcript line updates a rolling window of recent
   turns, screened by a `SuspicionGate` (today a keyword `KeywordGate`). Benign
   windows stop here and **never reach Claude**.
2. **Verdict** — only flagged windows go to Claude (`claude-sonnet-4-6`), which
   returns a structured `{ riskLevel, reason, category }` verdict.
3. **Action** — the verdict drives the dashboard and the action layer:
   - `alert` → terminate the call **and** notify family
   - `warn` → notify family, leave the call up for a human to judge
   - `safe` → do nothing

   Actions are idempotent per session (terminate once, notify once). The hang-up
   (`CallController`) and family alert (`FamilyNotifier`) are clean interfaces
   with logging/stub impls now; real telephony + SMS drop in behind them later.

The gate and action layers all sit behind interfaces so Pass 2 can drop in a
Redis semantic gate and live Twilio actions with no other changes.

### Judge transport — Claude through Token Router (Pass 2b)

Claude is still the reasoning model, but the verdict call is sent **through Token
Router**, an OpenAI-compatible gateway (one base URL, one key), via the standard
`chat.completions` shape. This is a transport swap only — the prompt, the
`{ riskLevel, reason, category }` schema, and the tolerant parser are unchanged,
and it sits behind the `JudgeTransport` seam so the rest of the pipeline is
untouched. Configure with `TOKENROUTER_BASE_URL`, `TOKENROUTER_API_KEY`, and `JUDGE_MODEL`
(the model id as Token Router expects it — defaults to the free `MiniMax-M3`;
set a Claude id like `claude-sonnet-4-6` once you have credit). The active base URL is
logged once at startup (`[judge] transport: …`); the key is never logged. The
direct Anthropic SDK path is still available behind `JUDGE_TRANSPORT=anthropic`,
which is the only case that needs `ANTHROPIC_API_KEY`.

## Redis vector gate + memory (Pass 2a)

The keyword gate only catches literal phrases. The **`RedisVectorGate`** (same
`SuspicionGate` interface) instead embeds the recent transcript and KNN-matches
it against an embedded corpus of scam scripts in Redis — so a *reworded* scam
with no shared keywords still gets flagged. Selecting the gate is one config
switch: `GATE=redis` (default) vs `GATE=keyword` (fallback, no Redis needed).

- **Embeddings** sit behind an `Embedder` interface: `EMBEDDER=local` runs
  all-MiniLM-L6-v2 in-process (no API key), `EMBEDDER=api` uses an
  OpenAI-compatible endpoint. Vectors are unit-normalized; the index is COSINE.
- **Flag threshold** is cosine similarity, default `REDIS_GATE_THRESHOLD=0.45`
  (paraphrased scams land ~0.46–0.55 vs ≤0.15 for benign chatter).
- **Memory** persists context beyond the current chunk:

  | Key | Holds |
  |---|---|
  | `call:{id}` (hash) | startedAt, lastRisk, lastReason, lastCategory, terminated |
  | `call:{id}:lines` (list) | rolling transcript |
  | `call:{id}:gateHits` (list) | `category\|score` per flagged window |
  | `call_summary:{id}` (hash) | end-of-call summary for the history list (see below) |
  | `calls:index` (zset) | call ids scored by start time, for newest-first listing |
  | `household:{id}` (hash) | alerts, warns, lastCategory, first/last-seen |
  | `household:{id}:cats` (hash) | per-category alert counts |

  Per-call keys expire after 1h; household keys persist across calls, so a repeat
  scam pattern is surfaced on the dashboard the moment the next call starts.

### Past-call history (read-only)

At call end a compact `call_summary:{id}` is written and the id is added to the
`calls:index` sorted set — the **only** writes this feature adds. Everything else
is read straight from the keys the pipeline already persisted. Two read-only
endpoints serve the history view (they never mutate call data), reusing the same
Redis connection as the gate:

| Endpoint | Returns |
|---|---|
| `GET /api/calls` | recent call summaries, newest first (cap 50) |
| `GET /api/calls/:id` | one call's full detail: transcript, gate hits, verdict, outcome |

The dashboard's **Past calls** view lives at `/history` (linked from the live
dashboard topbar): a list of past calls on the left — time, caller/household,
detected category, final risk level, and outcome — and the selected call's
transcript + verdict on the right. It's a separate page with no websocket, so it
can't interfere with the live monitoring stream. When `GATE=keyword` (no Redis),
the list is simply empty.

### Start Redis Stack

```bash
docker run -d --name aintercept-redis -p 6379:6379 redis/redis-stack:latest
```

(Redis Stack bundles RediSearch for the vector index. If Redis isn't running and
`GATE=redis`, the app logs a warning and falls back to the keyword gate.)

### See semantic beat keyword (the before/after)

Run the SAME keyword-free scam line through each gate:

```bash


[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 41 recognized source files, 160 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- Express (technology) — detected in the code
- HTML (language) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code
- Redis (technology) — detected in the code
- TypeScript (language) — detected in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository

## Codebase structure (from repository index)

### Files (45 of 45)

```
.env.example
.gitignore
CLAUDE.md
package.json
public/app.html
public/history.html
public/index.html
public/styles.css
README.md
src/action/loggingCallController.ts
src/action/stubFamilyNotifier.ts
src/action/types.ts
src/audio/fileSource.ts
src/audio/twilioSource.ts
src/audio/types.ts
src/config.ts
src/dashboard.ts
src/embeddings/apiEmbedder.ts
src/embeddings/index.ts
src/embeddings/transformersEmbedder.ts
src/embeddings/types.ts
src/history.ts
src/index.ts
src/judge/attach.ts
src/judge/claudeJudge.ts
src/judge/corpus.ts
src/judge/keywordGate.ts
src/judge/redisVectorGate.ts
src/judge/scamWatch.ts
src/judge/transport.ts
src/judge/types.ts
src/memory/callKeys.ts
src/memory/nullMemory.ts
src/memory/redisMemory.ts
src/memory/types.ts
src/pipeline.ts
src/redis/client.ts
src/server.ts
src/stt/deepgramTranscriber.ts
src/stt/record.py
src/stt/types.ts
src/tools/memoryDump.ts
src/tools/redisCheck.ts
src/wsRouter.ts
tsconfig.json
```

### Dependencies

- package.json: @anthropic-ai/sdk@^0.69.0, @deepgram/sdk@^3.9.0, @types/express@^5.0.6, @types/node@^22.10.0, @types/ws@^8.18.1, @xenova/transformers@^2.17.2, dotenv@^16.4.5, express@^5.2.1, openai@^6.44.0, redis@^4.7.1, tsx@^4.19.2, typescript@^5.7.2, ws@^8.21.0

### Recent commits (newest first)

- Initial commit (due to leaked api key)

## Key source files (fetched from GitHub, selected and truncated for size)

### CLAUDE.md

```markdown
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Purpose

AIntercept is a real-time call guardian for elderly scam prevention. It listens to a phone call via Twilio Media Streams, transcribes audio through Deepgram, runs a two-stage scam detection pipeline (cheap keyword gate → Claude verdict), and streams results to a family-facing browser dashboard.

## Commands

```bash
npm run dev                                    # File runner: streams assets/spacewalk.wav through the full pipeline
npm run dev -- path/to/audio.wav              # File runner with a custom WAV
npm run dev -- --scam-demo                    # Inject a canned scam script (no audio/Deepgram needed)
npm run dev -- --scam-demo "line 1" "line 2" # Inject custom lines
npx tsx src/server.ts                         # Twilio server (live call mode, Steps 2-3+)
npm run build                                 # Compile TypeScript to dist/
npm run typecheck                             # Type-check without emitting
```

Copy `.env.example` to `.env`. `DEEPGRAM_API_KEY` is required for most modes. `ANTHROPIC_API_KEY` is only read lazily when the keyword gate actually flags a window — a benign run never needs it.

## Two Entry Points

**`src/index.ts`** — file/demo runner. Starts an Express server on `PORT` (for the dashboard at `/`), then either streams a WAV through Deepgram or injects `--scam-demo` lines directly into the pipeline. Use this for development and testing the full scam-detection path without a real phone call.

**`src/server.ts`** — Twilio server. Exposes:
- `GET/POST /twilio/voice` — returns TwiML that connects the call to the media stream
- `WS /twilio/media` — receives the live call audio (mu-law 8 kHz) from Twilio
- `GET /` — serves the browser dashboard from `./public`
- `WS /dashboard` — pushes transcript and verdict events to browsers

Both entry points share the same pipeline, dashboard, and judgment layer via `pipeline.ts`, `dashboard.ts`, and `judge/attach.ts`.

## Architecture

### Pipeline (`src/pipeline.ts`)
The fixed spine: `AudioSource` → `Transcriber` → transcript chunks → downstream consumers. `runPipeline()` wires them together — callers only choose the source and what to do with each `TranscriptChunk`. Both entry points call this.

### Audio Sources (`src/audio/`)
- **`FileAudioSource`** — parses 16-bit PCM WAV, emits 100ms chunks at real-time pace. Format: `linear16 / variable kHz`.
- **`TwilioMediaStreamSource`** — decodes Twilio's JSON WebSocket protocol (`connected → start → media → stop`), base64-decodes the mu-law payloads, buffers audio that arrives before `start()` is called. Format: `mulaw / 8000 Hz / mono`.

Both implement the `AudioSource` interface (`src/audio/types.ts`): `format`, `start()`, `stop()`, plus `audio`/`end`/`error` events. Swapping one for the other is the entire point — the transcriber doesn't change.

### Speech-to-Text (`src/stt/`)
`DeepgramTranscriber` 
[truncated — 2591 more characters]
```

### package.json

```
{
  "name": "aintercept",
  "version": "0.1.0",
  "private": true,
  "description": "Real-time call guardian for elderly scam prevention (Pass 1 skeleton)",
  "type": "module",
  "scripts": {
    "dev": "tsx src/index.ts",
    "serve": "tsx src/server.ts",
    "redis:check": "tsx src/tools/redisCheck.ts",
    "memory:dump": "tsx src/tools/memoryDump.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.69.0",
    "@deepgram/sdk": "^3.9.0",
    "@xenova/transformers": "^2.17.2",
    "dotenv": "^16.4.5",
    "express": "^5.2.1",
    "openai": "^6.44.0",
    "redis": "^4.7.1",
    "ws": "^8.21.0"
  },
  "devDependencies": {
    "@types/express": "^5.0.6",
    "@types/node": "^22.10.0",
    "@types/ws": "^8.18.1",
    "tsx": "^4.19.2",
    "typescript": "^5.7.2"
  }
}

```

### src/server.ts

```typescript
import http from 'node:http';
import express, { type Request } from 'express';
import { WebSocketServer } from 'ws';
import { config } from './config.js';
import { TwilioMediaStreamSource } from './audio/twilioSource.js';
import { DeepgramTranscriber } from './stt/deepgramTranscriber.js';
import { logTranscriptChunk, runPipeline } from './pipeline.js';
import { mountDashboard } from './dashboard.js';
import { mountHistoryApi } from './history.js';
import { routeWebsockets } from './wsRouter.js';
import { createJudgeRuntime } from './judge/attach.js';

/**
 * Steps 2-3: a real inbound call leg -> Twilio Media Streams -> the existing
 * Deepgram pipeline -> live console transcript + browser dashboard.
 *
 *   GET/POST /twilio/voice  -> TwiML that starts a media stream
 *   WS       /twilio/media  -> receives the call audio (mu-law 8k)
 *   GET      /              -> family dashboard (served from ./public)
 *   WS       /dashboard     -> transcript pushed out to the browser
 *
 * Single leg only. No Claude verdict yet (Step 4).
 */

const app = express();

// Twilio fetches this when the call connects. We return TwiML that connects
// the caller's audio to our websocket for the duration of the call.
// /record is an alias so Twilio can be pointed here without needing record.py.
app.all(['/twilio/voice', '/record'], (req, res) => {
  const wsUrl = resolveMediaWsUrl(req);
  console.log(`[twilio] voice webhook hit — streaming to ${wsUrl}`);
  const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say>Connecting you to A Intercept. This call is being monitored for your protection.</Say>
  <Connect>
    <Stream url="${wsUrl}" />
  </Connect>
</Response>`;
  res.type('text/xml').send(twiml);
});

const server = http.createServer(app);

// Two separate websocket endpoints on one HTTP server, routed by path:
//   /dashboard     — transcript out to browsers (separate from the media WS)
//   /twilio/media  — call audio in
const dashboard = mountDashboard(app);
const twilioWss = new WebSocketServer({ noServer: true });
routeWebsockets(server, { '/dashboard': dashboard.wss, '/twilio/media': twilioWss });

async function main(): Promise<void> {
  // Build the gate / Redis / embedder once; mint a fresh session per call.
  const runtime = await createJudgeRuntime();
  // Read-only past-call history, reusing the runtime's Redis connection.
  mountHistoryApi(app, runtime.redis);

  twilioWss.on('connection', async (ws) => {
    console.log('[twilio] media stream connected');

    // New source per call leg; the transcriber + pipeline are reused as-is.
    const source = new TwilioMediaStreamSource(ws);
    const transcriber = new DeepgramTranscriber(source.format, config.deepgramApiKey);
    // Fresh judgment layer + memory per call so the rolling window starts clean.
    const scam = await runtime.newSession(dashboard, { callId: `twilio-${Date.now()}` });

    source.on('start', (info) =>
      console.log(`[twilio] call started (streamSid=${info.streamSid}, callSid=${info.callSid})`),
    );

    runPipeline(source, transcriber, {
      onOpen: () => console.log('[deepgram] open — transcribing live call...\n'),
      onTranscript: (chunk) => {
        logTranscriptChunk(chunk);
        dashboard.transcript(chunk);
        scam.onTranscript(chunk);
      },
      onError: (scope, err) => console.error(`[${scope}] error:`, err.message),
      onClose: () => {
        console.log('\n[twilio] call ended — Deepgram closed.\n');
        void scam.finalize(); // record this call leg in history
      },
    }).catch((err) => console.error('[pipeline] failed to start:', err.message));
  });

  server.listen(config.port, () => {
    console.log(`[aintercept] server listening on :${config.port} (gate=${runtime.gateKind})`);
    console.log(`  dashboard     : http://localhost:${config.port}`);
    console.log(`  voice webhook : http://localhost:${config.port}/twilio/voice`);
    console.log(`  media socket  : ws://localhost:${config.port}/twilio/media`);
    console.log('  (expose with a tunnel; point the Twilio number webhook at /twilio/voice)\n');
  });
}

main().catch((err) => {
  console.error('[fatal]', err instanceof Error ? err.message : err);
  process.exit(1);
});

/**
 * Build the public wss:// URL Twilio should stream to. Prefers an explicit
 * PUBLIC_WS_URL; otherwise derives it from the (tunneled) request host, which
 * "just works" with ngrok since the webhook arrives through the same tunnel.
 */
function resolveMediaWsUrl(req: Request): string {
  if (process.env.PUBLIC_WS_URL && process.env.PUBLIC_WS_URL.trim() !== '') {
    return process.env.PUBLIC_WS_URL.trim();
  }
  const host = (req.headers['x-forwarded-host'] as string) ?? req.headers.host ?? `localhost:${config.port}`;
  return `wss://${host}/twilio/media`;
}

```

### src/index.ts

```typescript
import http from 'node:http';
import { resolve } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import express from 'express';
import { config } from './config.js';
import { FileAudioSource } from './audio/fileSource.js';
import { DeepgramTranscriber } from './stt/deepgramTranscriber.js';
import { logTranscriptChunk, runPipeline } from './pipeline.js';
import { mountDashboard } from './dashboard.js';
import { mountHistoryApi } from './history.js';
import { routeWebsockets } from './wsRouter.js';
import { createJudgeRuntime } from './judge/attach.js';
import type { TranscriptChunk } from './stt/types.js';

/**
 * Step 3-4 / fallback input: stream a local file through the pipeline, push the
 * transcript to console + dashboard, and run the gate-then-escalate scam judge.
 *
 *   local WAV  ->  FileAudioSource  ->  DeepgramTranscriber  ->  console + dashboard
 *                                                            \-> ScamWatch (gate -> Claude)
 *
 * Usage:
 *   npm run dev                       benign demo (assets/spacewalk.wav)
 *   npm run dev -- path/to/audio.wav  any WAV
 *   npm run dev -- --scam-demo        inject a canned scam script (no audio/Deepgram)
 *   npm run dev -- --scam-demo "line one" "line two"   inject your own lines
 *
 * Household memory (Part B) — identify the household + caller so a repeat call
 * from a known scam number is treated as higher-risk from the start:
 *   npm run dev -- --scam-demo --household h1 --caller +1555scam
 */

/** Pull a `--flag value` pair out of argv, returning the value + the remaining args. */
function takeFlag(args: string[], name: string): { value?: string; rest: string[] } {
  const i = args.indexOf(name);
  if (i === -1) return { rest: args };
  return { value: args[i + 1], rest: args.slice(0, i).concat(args.slice(i + 2)) };
}

// A canned tech-support / gift-card scam so the escalation path is demoable
// without recording a scam WAV. Early lines already trip the gate.
const SCAM_SCRIPT = [
  'Hello, this is Officer Daniels calling from the Social Security Administration.',
  "We've detected suspicious activity linked to your Social Security number.",
  'There is an arrest warrant issued under your name as we speak.',
  'To clear this you must verify your account and act right now.',
  'Please go to the store and buy four hundred dollars in Google Play gift cards.',
  "Do not tell anyone about this call. Keep this between us.",
  'Read me the numbers on the back of the gift cards to confirm your identity.',
];

async function main(): Promise<void> {
  let args = process.argv.slice(2);
  const scamDemo = args.includes('--scam-demo');
  args = args.filter((a) => a !== '--scam-demo');
  // Household + caller identity for memory (Part B); default if not passed.
  const householdFlag = takeFlag(args, '--household');
  args = householdFlag.rest;
  const callerFlag = takeFlag(args, '--caller');
  args = callerFlag.rest;
  const householdId = householdFlag.value ?? config.householdId;
  const caller = callerFlag.value ?? 'unknown';
  const rest = args;

  // Dashboard + judgment layer are shared by both modes.
  const app = express();
  const server = http.createServer(app);
  const dashboard = mountDashboard(app);
  routeWebsockets(server, { '/dashboard': dashboard.wss });
  await new Promise<void>((r) => server.listen(config.port, () => r()));
  console.log(`[dashboard] open  http://localhost:${config.port}`);

  const runtime = await createJudgeRuntime();
  // Read-only past-call history, reusing the runtime's Redis connection.
  mountHistoryApi(app, runtime.redis);
  const callId = `cli-${Date.now()}`;
  console.log(
    `[aintercept] gate=${runtime.gateKind} call=${callId} household=${householdId} caller=${caller}\n`,
  );
  const scam = await runtime.newSession(dashboard, { callId, householdId, caller });

  // The one place transcript chunks fan out — same wiring for file + injection.
  const handleTranscript = (chunk: TranscriptChunk): void => {
    logTranscriptChunk(chunk);
    dashboard.transcript(chunk);
    scam.onTranscript(chunk);
  };

  if (scamDemo) {
    const lines = rest.length > 0 ? rest : SCAM_SCRIPT;
    console.log(`[aintercept] Step 4 — injecting ${lines.length} scam-demo line(s) (no audio)\n`);
    await injectLines(lines, handleTranscript);
    await scam.finalize(); // record this call in history once injection settles
    console.log('\n[scam-demo] done — dashboard still live (Ctrl+C to stop).');
    return;
  }

  const filePath = resolve(rest[0] ?? 'assets/spacewalk.wav');
  console.log(`[aintercept] Step 4 — streaming "${filePath}" to Deepgram + dashboard + judge`);
  const source = new FileAudioSource(filePath);
  const { encoding, sampleRate, channels } = source.format;
  console.log(`[audio] ${encoding} ${sampleRate}Hz ${channels}ch (real-time paced)\n`);

  const transcriber = new DeepgramTranscriber(source.format, config.deepgramApiKey);

  await runPipeline(source, transcriber, {
    onOpen: () => console.log('[deepgram] connection open — listening...\n'),
    onTranscript: handleTranscript,
    onError: (scope, err) => console.error(`[${scope}] error:`, err.message),
    onClose: () =>
      console.log('\n[deepgram] closed. File done — dashboard still live (Ctrl+C to stop).'),
  });
  await scam.finalize(); // record this call in history once the stream ends
}

/** Feed test lines through the same handler the pipeline uses, paced like speech. */
async function injectLines(
  lines: string[],
  handle: (chunk: TranscriptChunk) => void,
): Promise<void> {
  for (const text of lines) {
    handle({ text, isFinal: true, receivedAt: Date.now() });
    await sleep(2000);
  }
}

main().catch((err) => {
  console.error('[fatal]', err instanceof Error ? err.message : err);
  process.exit(1);
});

```

### src/embeddings/index.ts

```typescript
import { config } from '../config.js';
import type { Embedder } from './types.js';
import { TransformersEmbedder } from './transformersEmbedder.js';
import { ApiEmbedder } from './apiEmbedder.js';

export type { Embedder } from './types.js';

// text-embedding-3-small default; override via EMBEDDINGS_API_DIM if your model differs.
const API_DIM = Number(process.env.EMBEDDINGS_API_DIM ?? '1536');

/** Pick the embedder from config: local transformer (default) or an API endpoint. */
export function createEmbedder(): Embedder {
  if (config.embedder === 'api') {
    return new ApiEmbedder(
      config.embeddingsApiUrl,
      () => config.embeddingsApiKey,
      config.embeddingsApiModel,
      API_DIM,
    );
  }
  return new TransformersEmbedder(config.localEmbeddingModel);
}

```

### src/wsRouter.ts

```typescript
import type { Server } from 'node:http';
import type { WebSocketServer } from 'ws';

/**
 * Route websocket upgrades to the right WebSocketServer by URL path.
 *
 * Each WSS must be created with `{ noServer: true }`. This is the supported way
 * to run several websocket endpoints on one HTTP server — e.g. the /dashboard
 * browser channel alongside the /twilio/media audio channel — without them
 * clobbering each other's handshakes (sharing via the `server` option makes a
 * non-matching server abort the socket the matching one just upgraded).
 */
export function routeWebsockets(server: Server, routes: Record<string, WebSocketServer>): void {
  server.on('upgrade', (req, socket, head) => {
    const path = req.url ? new URL(req.url, 'http://localhost').pathname : '';
    const wss = routes[path];
    if (!wss) {
      socket.destroy();
      return;
    }
    wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
  });
}

```

### src/pipeline.ts

```typescript
import type { AudioSource } from './audio/types.js';
import type { Transcriber, TranscriptChunk } from './stt/types.js';

const GRAY = '\x1b[90m';
const GREEN = '\x1b[32m';
const RESET = '\x1b[0m';

/** Print a transcript chunk: interim updates in place (gray), finals commit (green). */
export function logTranscriptChunk(chunk: TranscriptChunk): void {
  if (chunk.isFinal) {
    process.stdout.write(`${GREEN}${chunk.text}${RESET}\n`);
  } else {
    process.stdout.write(`\r${GRAY}${chunk.text}${RESET}`);
  }
}

export interface PipelineHooks {
  onTranscript: (chunk: TranscriptChunk) => void;
  /** Fired once the transcriber connection is open and audio can flow. */
  onOpen?: () => void;
  /** Fired when the transcriber closes (audio ended / stream done). */
  onClose?: () => void;
  onError?: (scope: 'audio' | 'deepgram', err: Error) => void;
}

/**
 * The fixed spine: audio chunks in -> transcriber -> transcript chunks out.
 *
 * Swapping the source (FileAudioSource vs TwilioMediaStreamSource) or the
 * transcriber never changes this wiring — that is the whole point of the
 * AudioSource / Transcriber interfaces. Callers only choose which source runs
 * and what to do with the transcript chunks.
 */
export async function runPipeline(
  source: AudioSource,
  transcriber: Transcriber,
  hooks: PipelineHooks,
): Promise<void> {
  transcriber.on('transcript', hooks.onTranscript);
  transcriber.on('error', (err: Error) => hooks.onError?.('deepgram', err));
  transcriber.on('close', () => hooks.onClose?.());

  // Audio only starts flowing once the transcriber is ready to receive it.
  await transcriber.start();
  hooks.onOpen?.();

  source.on('audio', (buf: Buffer) => transcriber.send(buf));
  source.on('error', (err: Error) => hooks.onError?.('audio', err));
  source.on('end', () => transcriber.finish());

  source.start();
}

```

### src/config.ts

```typescript
import 'dotenv/config';

/**
 * Centralized, validated access to environment configuration.
 * Only the keys needed for the current step are required at call time —
 * use `requireEnv` lazily so Step 1 doesn't demand Twilio/Anthropic keys.
 */

export function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value || value.trim() === '') {
    throw new Error(
      `Missing required environment variable: ${name}. ` +
        `Copy .env.example to .env and fill it in.`,
    );
  }
  return value.trim();
}

export function optionalEnv(name: string, fallback: string): string {
  const value = process.env[name];
  return value && value.trim() !== '' ? value.trim() : fallback;
}

export const config = {
  get deepgramApiKey(): string {
    return requireEnv('DEEPGRAM_API_KEY');
  },
  // --- Judge transport (Pass 2b): reach Claude THROUGH Token Router ---
  // 'tokenrouter' (default) = OpenAI-compatible gateway; 'anthropic' = direct SDK.
  // Claude stays the reasoning model either way — this only swaps the transport.
  judgeTransport: optionalEnv('JUDGE_TRANSPORT', 'tokenrouter') as 'tokenrouter' | 'anthropic',
  tokenRouterBaseUrl: optionalEnv('TOKENROUTER_BASE_URL', 'https://api.tokenrouter.com/v1'),
  // Only read when a flagged window actually escalates, so a benign run needs no key.
  get tokenRouterApiKey(): string {
    return requireEnv('TOKENROUTER_API_KEY');
  },
  // The model id as Token Router expects it (single source of truth). Defaults
  // to the free MiniMax-M3 to avoid API spend; set JUDGE_MODEL to a Claude id
  // (e.g. claude-sonnet-4-6) once credit is available.
  judgeModel: optionalEnv('JUDGE_MODEL', 'MiniMax-M3'),

  // Direct-Anthropic fallback (only used when JUDGE_TRANSPORT=anthropic).
  // Read lazily so the default Token Router path doesn't require an Anthropic key.
  get anthropicApiKey(): string {
    return requireEnv('ANTHROPIC_API_KEY');
  },
  claudeModel: optionalEnv('CLAUDE_MODEL', 'claude-sonnet-4-6'),
  port: Number(optionalEnv('PORT', '3000')),

  // --- Pass 2a: Redis vector gate + memory ---
  // Which SuspicionGate the pipeline uses. 'redis' = semantic vector gate;
  // 'keyword' = the Pass 1 fallback (no Redis needed).
  gate: optionalEnv('GATE', 'redis') as 'redis' | 'keyword',
  redisUrl: optionalEnv('REDIS_URL', 'redis://localhost:6379'),
  // Optional credential overrides — use these instead of embedding the password
  // in REDIS_URL when it contains special characters (@ : / #).
  redisUsername: optionalEnv('REDIS_USERNAME', ''),
  redisPassword: optionalEnv('REDIS_PASSWORD', ''),
  // Cosine-similarity flag threshold for the vector gate (0..1). Tuned for
  // all-MiniLM: paraphrased scams land ~0.45-0.75 against the corpus.
  redisGateThreshold: Number(optionalEnv('REDIS_GATE_THRESHOLD', '0.45')),
  // Stable across runs so per-household history accumulates between calls.
  householdId: optionalEnv('HOUSEHOLD_ID', 'demo-household'),

  // --- Embeddings (behind the Embedder interface) ---
  // 'local' = @xenova/transformers, runs in-process, no API key.
  // 'api'   = OpenAI-compatible embeddings endpoint (set the three EMBEDDINGS_API_* vars).
  embedder: optionalEnv('EMBEDDER', 'local') as 'local' | 'api',
  localEmbeddingModel: optionalEnv('LOCAL_EMBEDDING_MODEL', 'Xenova/all-MiniLM-L6-v2'),
  embeddingsApiUrl: optionalEnv('EMBEDDINGS_API_URL', 'https://api.openai.com/v1/embeddings'),
  get embeddingsApiKey(): string {
    return requireEnv('EMBEDDINGS_API_KEY');
  },
  embeddingsApiModel: optionalEnv('EMBEDDINGS_API_MODEL', 'text-embedding-3-small'),
};

```

### src/dashboard.ts

```typescript
import { resolve } from 'node:path';
import express, { type Express } from 'express';
import { WebSocketServer, WebSocket } from 'ws';
import type { TranscriptChunk } from './stt/types.js';
import type { RiskLevel } from './judge/types.js';
import type { ActivityTone } from './judge/scamWatch.js';

/**
 * Messages pushed server -> browser over the dashboard channel.
 *   'transcript' — a recognized speech chunk (Step 3)
 *   'risk'       — the big risk indicator's state after a verdict is acted on:
 *                  safe / warn ("family notified") / alert ("call ended") (Step 4-5)
 *   'activity'   — one line for the pipeline panel: gate, Claude, and actions
 * The page ignores message types it doesn't know, so adding more won't break it.
 */
export type DashboardMessage =
  | { type: 'status'; state: 'connected' }
  | { type: 'transcript'; chunk: TranscriptChunk }
  | { type: 'risk'; state: RiskLevel; headline: string; detail: string; terminated: boolean }
  | { type: 'activity'; tone: ActivityTone; text: string; intensity: number; at: number };

// npm scripts run from the project root, so the UI lives at ./public there.
const PUBLIC_DIR = resolve(process.cwd(), 'public');

/**
 * The browser-facing websocket channel — deliberately separate from the Twilio
 * media socket. This one carries transcript chunks *out* to family viewers;
 * the Twilio socket carries audio *in*. Different direction, different data,
 * different path (/dashboard vs /twilio/media).
 */
export class BrowserChannel {
  /** noServer mode — upgrades are routed to it by path (see wsRouter). */
  readonly wss: WebSocketServer;

  constructor() {
    this.wss = new WebSocketServer({ noServer: true });
    this.wss.on('connection', (ws) => {
      this.send(ws, { type: 'status', state: 'connected' });
    });
  }

  private send(ws: WebSocket, msg: DashboardMessage): void {
    if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
  }

  /** Fan a message out to every connected browser. */
  broadcast(msg: DashboardMessage): void {
    const data = JSON.stringify(msg);
    for (const client of this.wss.clients) {
      if (client.readyState === WebSocket.OPEN) client.send(data);
    }
  }

  /** Convenience: push one transcript chunk to all viewers. */
  transcript(chunk: TranscriptChunk): void {
    this.broadcast({ type: 'transcript', chunk });
  }

  /** Drive the big risk indicator from the acted-on verdict (Step 4-5). */
  risk(state: RiskLevel, headline: string, detail: string, terminated: boolean): void {
    this.broadcast({ type: 'risk', state, headline, detail, terminated });
  }

  /**
   * Append one line to the pipeline-activity panel. `text` is the plain-language
   * version for the family; `intensity` (0..1) drives the row's green→yellow→red
   * colour. The verbose technical line stays in the server console.
   */
  activity(tone: ActivityTone, text: string, intensity = 0): void {
    this.broadcast({ type: 'activity', tone, text, intensity, at: Date.now() });
  }
}

/**
 * Mount the family dashboard onto an existing Express app + HTTP server:
 *  - serves the single-page UI from ./public
 *  - opens the browser websocket at /dashboard
 *
 * Both entry points (file runner and Twilio server) call this, so the dashboard
 * behaves identically regardless of which audio source feeds the pipeline.
 * The caller routes the /dashboard upgrade to `channel.wss` via routeWebsockets.
 */
export function mountDashboard(app: Express): BrowserChannel {
  // `extensions: ['html']` lets the dashboard page resolve at a clean /app
  // (serving app.html) while / serves the landing index.html. This is static
  // file resolution only — the /dashboard websocket contract is untouched.
  app.use(express.static(PUBLIC_DIR, { extensions: ['html'] }));
  return new BrowserChannel();
}

```

### public/index.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>AIntercept — real-time scam protection</title>
  <link rel="stylesheet" href="/styles.css" />
</head>
<body class="page-landing">
  <canvas id="bg" aria-hidden="true"></canvas>
  <header class="topbar">
    <div class="brand">
      <img src="/logo.png" alt="AIntercept" />
    </div>
    <a class="btn btn-ghost" href="/app">Open dashboard</a>
  </header>

  <main class="hero">
    <div class="hero-inner">
      <span class="eyebrow">Real-time call guardian</span>
      <h1>Scam protection for<br />a <span class="accent">loved one's</span> calls.</h1>
      <p class="lede">
        AIntercept listens to a live call, flags fraud as it unfolds, and steps
        in the moment a scam is confirmed — so your family member never has to
        spot it alone.
      </p>
      <div class="hero-cta">
        <a class="btn btn-primary" href="/app">Launch <span class="arrow">→</span></a>
      </div>
    </div>
  </main>

  <section class="steps" aria-label="How it works">
    <div class="step">
      <span class="num">01</span>
      <h3>Listen</h3>
      <p>Live call audio is transcribed in real time, word by word.</p>
    </div>
    <div class="step">
      <span class="num">02</span>
      <h3>Detect</h3>
      <p>A fast gate filters every line; anything suspicious is escalated to Claude for a verdict.</p>
    </div>
    <div class="step">
      <span class="num">03</span>
      <h3>Intervene</h3>
      <p>On a confirmed scam the call is ended and the family is notified — instantly.</p>
    </div>
  </section>

  <footer class="foot">AIntercept · on-device pipeline · your call data stays yours</footer>

  <script>
    // Ambient dot grid that reacts to the cursor: dots near the pointer brighten,
    // grow, take on the orange accent, and ease away. Non-intrusive and cheap.
    (function () {
      const canvas = document.getElementById('bg');
      if (!canvas) return;
      const ctx = canvas.getContext('2d');
      const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;

      const GAP = 38;     // grid spacing
      const RADIUS = 150; // cursor influence radius
      let w = 0, h = 0, dots = [];
      const target = { x: -9999, y: -9999 };
      const mouse = { x: -9999, y: -9999 };

      function build() {
        const dpr = Math.min(window.devicePixelRatio || 1, 2);
        w = canvas.clientWidth; h = canvas.clientHeight;
        canvas.width = w * dpr; canvas.height = h * dpr;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        dots = [];
        const ox = (w % GAP) / 2, oy = (h % GAP) / 2;
        for (let y = oy; y <= h; y += GAP)
          for (let x = ox; x <= w; x += GAP) dots.push({ x, y });
      }

      function draw() {
        // ease the pointer for smooth motion even after it stops
        mouse.x += (target.x - mouse.x) * 0.15;
        mouse.y += (target.y - mouse.y) * 0.15;
        ctx.clearRect(0, 0, w, h);
        for (const d of dots) {
          const dx = d.x - mouse.x, dy = d.y - mouse.y;
          const dist = Math.hypot(dx, dy) || 1;
          const t = dist < RADIUS ? 1 - dist / RADIUS : 0; // 0..1 proximity
          const push = t * t * 10;                          // ease-in repel
          const x = d.x + (dx / dist) * push;
          const y = d.y + (dy / dist) * push;
          const r = 1.1 + t * 2.4;
          ctx.beginPath();
          ctx.arc(x, y, r, 0, Math.PI * 2);
          // grey at rest → orange near the cursor (accent earns attention)
          ctx.fillStyle = t > 0.001
            ? 'rgba(255, 90, 0, ' + (0.06 + t * 0.55).toFixed(3) + ')'
            : 'rgba(15, 15, 17, 0.06)';
          ctx.fill();
        }
        if (!reduced) requestAnimationFrame(draw);
      }

      window.addEventListener('pointermove', (e) => { target.x = e.clientX; target.y = e.clientY; });
      window.addEventListener('pointerleave', () => { target.x = -9999; target.y = -9999; });
      window.addEventListener('resize', build);
      build();
      draw();
    })();
  </script>
</body>
</html>

```

[31 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]