diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..343fab5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(npm install:*)", + "Bash(npx tsc:*)", + "Bash(node:*)", + "Bash(nvm version:*)", + "Bash(npm run:*)", + "Bash(fnm list:*)", + "Bash(fnm exec:*)", + "WebSearch", + "Bash(npm rebuild:*)", + "Bash(grep:*)" + ] + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1bc1dab --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,193 @@ +# DnD Campaign Hub - Agent Guidelines + +## Project Overview + +Monorepo with a Node.js/Express API + Discord bot and React/Vite frontend. + +**Stack:** +- Server: Node.js + TypeScript + Express + SQLite + Discord.js +- Web: React 19 + TypeScript + Vite + React Router +- Testing: Vitest +- Build: npm workspaces + +## Commands + +### Root (Monorepo) +```bash +npm install # Install all dependencies +npm run dev # Run both server and web in dev mode +npm run build # Build server and web +npm run test # Run all tests +``` + +### Server (`apps/server`) +```bash +npm run dev -w @dnd-hub/server # Dev mode with hot reload (tsx) +npm run build -w @dnd-hub/server # Compile TypeScript +npm run start -w @dnd-hub/server # Run compiled server +npm run test -w @dnd-hub/server # Run server tests +npm run migrate -w @dnd-hub/server # Run DB migrations +``` + +### Web (`apps/web`) +```bash +npm run dev -w @dnd-hub/web # Vite dev server (port 5173) +npm run build -w @dnd-hub/web # Type check + Vite build +npm run preview -w @dnd-hub/web # Preview production build +npm run test -w @dnd-hub/web # Run web tests +``` + +### Running a Single Test +```bash +# Run specific test file +npm run test -w @dnd-hub/server -- test/sessionService.test.ts +npm run test -w @dnd-hub/web -- src/smoke.test.ts + +# Run with filter pattern +npm run test -w @dnd-hub/server -- --run --reporter=verbose +``` + +## Code Style + +### TypeScript +- **Strict mode enabled** in both apps (`strict: true`) +- Target ES2022, use ESNext modules +- Server: `module: NodeNext`, `moduleResolution: NodeNext` +- Web: `module: ESNext`, `moduleResolution: Bundler` +- Always use `.ts`/`.tsx` extensions (no `.js` in src) + +### Imports +- Use ES modules (`import`/`export`) +- Include `.js` extension for relative imports in server code +- Group imports: React/hooks first, then libraries, then local imports +- Example: + ```typescript + import { useEffect, useState } from "react"; + import { Link, Route, Routes } from "react-router-dom"; + import { api } from "./api/client"; + ``` + +### Naming Conventions +- **Files**: camelCase for components/services (e.g., `CharacterSheetDrawer.tsx`, `sessionService.ts`) +- **Components**: PascalCase (e.g., `CharacterPage`, `ToastProvider`) +- **Functions/variables**: camelCase +- **Constants**: UPPER_SNAKE_CASE for config values +- **Types/Interfaces**: PascalCase (e.g., `Me`, `Campaign`) +- **Test files**: `*.test.ts` or `*.test.tsx` alongside or near tested code + +### Formatting +- 2-space indentation +- Semicolons required +- Double quotes for strings (single in JSX when needed) +- Trailing commas in multi-line objects/arrays +- Max line length: ~100 chars (flexible) + +### React Conventions +- Functional components with hooks only (no class components) +- Custom hooks prefixed with `use` (e.g., `useToast`, `useDebugMode`) +- Context providers for global state (Toast, Debug, CharacterSheet) +- Arrow functions for event handlers +- TypeScript interfaces for props + +### Error Handling +- Server: Use try/catch with proper HTTP status codes +- Validate inputs with Zod schemas +- Web: Use Toast context for user-facing errors +- Log errors server-side, show friendly messages client-side +- Never expose stack traces to clients + +### API Design +- RESTful endpoints under `/routes/` +- Use Express middleware for auth/validation +- CORS configured per environment +- Health check at `GET /health` + +### Database +- SQLite via better-sqlite3 (synchronous) +- Migrations in `src/db/migrate.ts` +- Path: `./data/dnd_hub.db` + +### Environment Variables +- Load via dotenv from monorepo root `.env` +- Use `config.ts` wrapper with defaults for dev +- Required vars throw on startup if missing +- Never commit `.env` (use `.env.example`) + +### Testing +- Framework: Vitest +- Pattern: `describe`/`it`/`expect` +- Import from `vitest` +- Tests should be isolated and deterministic +- Smoke tests for basic functionality + +## Project Structure + +``` +dnd-hub/ +├── apps/ +│ ├── server/ +│ │ ├── src/ +│ │ │ ├── routes/ # Express route handlers +│ │ │ ├── services/ # Business logic +│ │ │ ├── db/ # Database schema/migrations +│ │ │ ├── discord/ # Discord bot commands +│ │ │ ├── jobs/ # Scheduled cron jobs +│ │ │ └── main.ts # Entry point +│ │ └── test/ # Test files +│ └── web/ +│ ├── src/ +│ │ ├── components/ # React components +│ │ ├── contexts/ # React context providers +│ │ ├── pages/ # Route page components +│ │ ├── api/ # API client +│ │ └── App.tsx # Root component +│ └── vite.config.ts +├── infra/ # Docker, nginx config +├── scripts/ # Utility scripts +├── .env.example # Environment template +└── package.json # Workspace root +``` + +## Key Features + +### Transcript Sessions +- Discord commands: `/session start`, `/session stop`, `/session status` +- API endpoints: `POST /sessions/*`, `GET /sessions/:id/transcript` +- Cloud sync via `CLOUD_API_BASE_URL` with HMAC signatures + +### Discord Integration +- OAuth2 authentication flow +- Role-based permissions (admin/dm/player) +- Game night notifications via node-cron + +### Recap System +- Configurable providers: ollama (local) or claude (cloud) +- Set via `RECAP_MODE` or `RECAP_PROVIDERS` env vars + +## Common Tasks + +### Adding a New Route +1. Create handler in `apps/server/src/routes/` +2. Register in `app.ts` +3. Add TypeScript types if needed + +### Adding a React Component +1. Create in `apps/web/src/components/` +2. Use TypeScript with typed props +3. Follow existing component patterns + +### Database Changes +1. Update schema in `src/db/migrate.ts` +2. Run `npm run migrate -w @dnd-hub/server` + +### Debug Mode +- Web app has debug toggle to simulate player role +- Controlled via DebugContext + +## Gotchas + +- `.env` is at monorepo root, loaded relative to workspace +- Server uses `.js` extensions in imports (NodeNext module resolution) +- Web uses JSX transform (`jsx: react-jsx`) +- Both apps share Vitest but run independently +- Clean up both `.js` and `.ts` files in web/src (legacy JS exists) diff --git a/VOICE_TRANSCRIPT_PLAN.md b/VOICE_TRANSCRIPT_PLAN.md new file mode 100644 index 0000000..6678746 --- /dev/null +++ b/VOICE_TRANSCRIPT_PLAN.md @@ -0,0 +1,413 @@ +# Discord Voice Transcript Implementation Plan + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Discord Voice Channel │ +│ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │ Player │ │ Player │ │ Player │ │ +│ └───┬────┘ └───┬────┘ └───┬────┘ │ +│ │ │ │ │ +└───────┼───────────┼───────────┼─────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Discord Bot (discord.js + @discordjs/voice) │ +│ - Joins voice channel on /session start │ +│ - Subscribes to each user's audio stream │ +│ - Saves per-user .webm files to temp storage │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ (on /session stop) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Whisper Service (Docker container on Ubuntu server) │ +│ - whisper.cpp or openai/whisper │ +│ - Receives audio files via HTTP POST │ +│ - Returns JSON with timestamps + transcript │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ DnD Hub Server │ +│ - Maps Discord user ID → character (existing logic) │ +│ - Stores voice segments in transcript_segments table │ +│ - Marks segments with source='voice' │ +│ - AI recap includes both text + voice transcripts │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Phase 1: Database Schema (2-3 hours) + +### Migration: `apps/server/src/db/migrations/010_voice_transcripts.sql` + +```sql +-- Add source column to distinguish text vs voice +ALTER TABLE transcript_segments ADD COLUMN source TEXT DEFAULT 'text'; + +-- Table for voice recording metadata +CREATE TABLE voice_recordings ( + id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL, + discord_user_id TEXT NOT NULL, + file_path TEXT NOT NULL, + duration_ms INTEGER, + recorded_at TEXT NOT NULL, + processed_at TEXT, + processing_status TEXT DEFAULT 'pending', + FOREIGN KEY (session_id) REFERENCES sessions(id) +); + +-- Index for efficient lookups +CREATE INDEX idx_voice_recordings_session ON voice_recordings(session_id); +CREATE INDEX idx_transcript_source ON transcript_segments(source); +``` + +**Time:** 30 min schema design + 30 min migration + 1 hr testing + +--- + +## Phase 2: Whisper Docker Service (3-4 hours) + +### Option A: whisper.cpp (recommended for performance) +```yaml +# Add to docker-compose.yml +services: + whisper: + image: ghcr.io/ggerganov/whisper.cpp:server + ports: + - "10888:10888" + volumes: + - ./whisper-models:/models + environment: + - WHISPER_MODEL=/models/ggml-large-v3.bin + restart: unless-stopped +``` + +### Option B: OpenAI Whisper (simpler) +```yaml + whisper: + image: ghcr.io/openai/whisper:latest + ports: + - "10888:10888" + volumes: + - ./whisper-audio:/audio +``` + +**Setup steps:** +1. Add whisper service to `docker-compose.yml` +2. Download model (`ggml-large-v3.bin` ~3GB, best accuracy) +3. Test API endpoint: `POST /inference` with audio file +4. Create `apps/server/src/services/whisperClient.ts` + +**whisperClient.ts:** +```typescript +import FormData from 'form-data'; +import fetch from 'node-fetch'; + +const WHISPER_URL = process.env.WHISPER_BASE_URL ?? 'http://whisper:10888'; + +export async function transcribeAudio(audioBuffer: Buffer, speakerId: string) { + const form = new FormData(); + form.append('file', audioBuffer, { filename: `${speakerId}.webm` }); + form.append('model', 'large-v3'); + form.append('response_format', 'verbose_json'); + form.append('word_timestamps', 'true'); + + const res = await fetch(`${WHISPER_URL}/inference`, { + method: 'POST', + body: form + }); + + return res.json(); // { text, segments: [{start, end, text}] } +} +``` + +**Time:** 2 hrs Docker setup + 1-2 hrs client + testing + +--- + +## Phase 3: Voice Recording in Discord Bot (5-7 hours) + +### Update `discordService.ts` with voice intents: + +```typescript +import { joinVoiceChannel, createAudioSubscriber, VoiceConnectionStatus } from '@discordjs/voice'; +import { AudioReceiveStream } from 'discord.js'; + +// Add to intents +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildMessages + ] +}); +``` + +### New service: `apps/server/src/services/voiceRecorder.ts` + +```typescript +import { joinVoiceChannel, createAudioSubscriber } from '@discordjs/voice'; +import { VoiceConnection } from 'discord.js'; +import { db } from '../db/client.js'; +import { writeFileSync, mkdirSync } from 'fs'; +import path from 'path'; + +const RECORDINGS_DIR = path.resolve('./data/voice-recordings'); + +class VoiceRecorder { + private connection: VoiceConnection | null = null; + private recordingStreams = new Map(); + private sessionId: number | null = null; + + async joinChannel(guildId: number, channelId: string, sessionId: number) { + this.connection = joinVoiceChannel({ + channelId, + guildId, + adapterCreator: getAdapter() // discord.js adapter + }); + + this.connection.on(VoiceConnectionStatus.Ready, () => { + console.log(`Voice connected for session ${sessionId}`); + this.sessionId = sessionId; + }); + + // Subscribe to all users + this.connection.receiver.subscriptions.on('entry', (userId, stream) => { + this.startRecording(userId, stream); + }); + } + + private startRecording(userId: string, stream: AudioReceiveStream) { + const filePath = path.join(RECORDINGS_DIR, `${this.sessionId}_${userId}.webm`); + mkdirSync(RECORDINGS_DIR, { recursive: true }); + + const file = createWriteStream(filePath); + stream.pipe(file); + this.recordingStreams.set(userId, file); + + // Track in DB + db.prepare(` + INSERT INTO voice_recordings (session_id, discord_user_id, file_path, recorded_at) + VALUES (?, ?, ?, datetime('now')) + `).run(this.sessionId, userId, filePath); + } + + async stopRecording() { + // Close all file streams + for (const stream of this.recordingStreams.values()) { + stream.end(); + } + this.recordingStreams.clear(); + + // Leave voice channel + this.connection?.destroy(); + this.connection = null; + } + + async processRecordings(sessionId: number) { + const recordings = db.prepare(` + SELECT * FROM voice_recordings + WHERE session_id = ? AND processing_status = 'pending' + `).all(sessionId); + + for (const rec of recordings) { + await this.processSingleRecording(rec); + } + } + + private async processSingleRecording(recording: any) { + const audioBuffer = readFileSync(recording.file_path); + const result = await transcribeAudio(audioBuffer, recording.discord_user_id); + + // Store transcript segments + for (const segment of result.segments) { + sessionService.appendSegment({ + sessionId: this.sessionId!, + guildId: /* get from session */, + discordUserId: recording.discord_user_id, + text: segment.text, + startedAt: segment.start, + endedAt: segment.end, + confidence: segment.confidence, + source: 'voice' + }); + } + + // Mark as processed + db.prepare(` + UPDATE voice_recordings + SET processing_status = 'completed', processed_at = datetime('now') + WHERE id = ? + `).run(recording.id); + } +} + +export const voiceRecorder = new VoiceRecorder(); +``` + +**Time:** 4-5 hrs discord.js voice API + 2 hrs file handling + 1 hr testing + +--- + +## Phase 4: Discord Command Integration (2-3 hours) + +### Update `/session start` command: + +```typescript +// In discordService.ts handleSessionCommand +if (sub === 'start') { + const result = sessionService.startSession(guildId, user.id); + + // Get the voice channel the user is in + const member = await interaction.guild.members.fetch(interaction.user.id); + const voiceChannel = member.voice.channel; + + if (voiceChannel) { + await voiceRecorder.joinChannel( + interaction.guildId!, + voiceChannel.id, + result.sessionId + ); + } + + await interaction.reply({ + content: `Started session #${result.sessionId}. ${voiceChannel ? '🎤 Recording voice' : '📝 Text only'}`, + ephemeral: false + }); +} +``` + +### Update `/session stop` command: + +```typescript +if (sub === 'stop') { + await voiceRecorder.stopRecording(); + await voiceRecorder.processRecordings(sessionId); + sessionService.stopSession(guildId, user.id); + await interaction.reply({ content: `Stopped session #${active.id}. Processing voice transcripts...`, ephemeral: false }); +} +``` + +**Time:** 2 hrs integration + 1 hr testing + +--- + +## Phase 5: UI for Voice Transcripts (4-6 hours) + +### Update `CampaignDetailPage.tsx`: + +```tsx +// Add filter toggle for transcript sources +const [showVoice, setShowVoice] = useState(true); +const [showText, setShowText] = useState(true); + +// Filter segments +const filteredSegments = segments.filter(s => + (s.source === 'voice' && showVoice) || (s.source === 'text' && showText) +); + +// Add audio player for voice segments +{segment.source === 'voice' && ( +