CRITICAL ISSUE: Michael just reported the voice to post job feature is broken (Frontend toast: Voice processing failed). He needs this fixed immediately. Check the logs on the app and fix it. Reply to Rivet when done.


I'll investigate the voice-to-post-job feature issue right away. Let me start by examining the relevant code and logs.


TodoWrite

Investigate voice-to-post-job feature and find the error

Fix the voice processing issue

Reply to Rivet when done


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Investigate voice-to-post-job feature and find the error

Fix the voice processing issue

Reply to Rivet when done


Task

Find voice/speech related code

Prompt:
Search the codebase at /home/ccuser/the-50-dollar-app for all files related to voice processing, speech recognition, whisper, voice-to-text, or job posting with voice. Look for:
1. Any API routes related to voice/speech/whisper/transcription
2. Frontend components that handle voice input for job posting
3. Any references to "Voice processing failed" toast message
4. Service worker or audio recording code

Search thoroughly and return the file paths and key code snippets.


Check Rivet inbox for context

Prompt:
Look for any Rivet-related files in the repo at /home/ccuser/the-50-dollar-app. Search for:
1. Files named RIVET-INBOX* or rivet-inbox*
2. Any files mentioning "rivet" or "council"
3. Any ops/ or scripts/ directories with communication tools

Return the contents of any relevant files found.


Perfect! I've found all the Rivet-related files in the repository. Here's a comprehensive report:

## Rivet-Related Files Found

### 1. **RIVET-INBOX.md** (Main Communication File)
**Location:** `/home/ccuser/the-50-dollar-app/RIVET-INBOX.md`

This is the primary inbox file where Builder writes tasks and status updates for Rivet to read. Contains:
- Status entries dated from March 14-19, 2026
- Tracks completion of Control Centre integration phases:
  - Phase 1A: TheOffice page connected to real data (completed 2026-03-15)
  - Phase 1B: Memories & Calendar pages connected to real data (completed 2026-03-16)
  - Phase 1C: Documents & AgentDetail pages connected to real data (completed 2026-03-17)
  - Council_respond function implementation (completed 2026-03-18)
  - Fix for get_conversation_previews crash in /api/messages (completed 2026-03-19)

### 2. **control-centre-council.jsonl** (Council Message Log)
**Location:** `/home/ccuser/the-50-dollar-app/data/control-centre-council.jsonl`

JSONL (JSON Lines) format log file containing Council topic messages from multiple agents:
- Check-in messages from: builder, rivet, sentinel, herald, radar, susan, harper, cog
- Dated 2026-03-06 02:05-02:06 UTC
- Format: `{id, ts (ISO), author, message}`

### 3. **Council Messages API Route**
**Location:** `/home/ccuser/the-50-dollar-app/src/app/api/ceo/council/messages/route.ts`

TypeScript/Next.js API endpoint (`/api/ceo/council/messages`) that:
- **GET request:** Reads and returns the last 200 Council messages (requires CEO PIN)
- **POST request:** Appends new Council messages to the JSONL file (requires CEO PIN)
- Security: Uses `CEO_PIN` environment variable (defaults to '5050') for authorization
- Validates message length (max 2000 chars) and author (max 50 chars)
- Each message gets a unique ID and ISO timestamp

### 4. **COMMS-PROTOCOL.md** (Communication Rules)
**Location:** `/home/ccuser/the-50-dollar-app/COMMS-PROTOCOL.md`

Defines the communication protocol between Builder and Rivet agents:
- Two inbox files: BUILDER-INBOX.md (Rivet writes, Builder reads) and RIVET-INBOX.md (Builder writes, Rivet reads)
- Rules: Append-only, one writer per file, acknowledgements required
- Task status prefixes: 📋 NEW, ✅ DONE, 🔧 WIP, 🚫 BLOCKED, ACK
- Daily archival routine at 8am AEDT
- Escalation procedures if inbox silent >4 hours during business hours
- Gateway bridge as secondary notification channel

### 5. **agent-bridge.js** (Multi-Agent Communication Tool)
**Location:** `/home/ccuser/the-50-dollar-app/scripts/agent-bridge.js`

Node.js script for inter-agent communication and command dispatch:
- **Supported agents:** rivet, builder, susan, harper, sentinel, radar, herald, cog, ccvps
- **Each agent** has dedicated gateway port and auth token (e.g., rivet: port 18789, token: rivet-api-2026)
- **Actions available:** status, wake, sessions, send, invoke (for gateway agents); run, inbox, logs (for ccvps)
- **Examples:**
  - `node agent-bridge.js rivet status` — Check Rivet's status
  - `node agent-bridge.js builder wake "message"` — Send wake event
  - `node agent-bridge.js ccvps run "task"` — Trigger CloudCode with inline task
- Uses HTTP POST requests with Bearer token authentication
- Alternative wake mechanism: shared inbox system (`/home/ccuser/shared/scripts/inbox.js`)

---

## Summary

The Rivet system is a multi-agent coordination framework where:
1. **RIVET-INBOX.md** serves as the primary task handoff and status log from Builder → Rivet
2. **Council messages** are stored in a JSONL format and accessible via a PIN-protected API endpoint
3. **COMMS-PROTOCOL.md** establishes strict rules to prevent task collision (they had an incident Feb 15, 2026)
4. **agent-bridge.js** enables real-time communication between agents via HTTP gateways and shared inbox systems
5. Multiple agents (rivet, builder, susan, harper, sentinel, radar, herald, cog) collaborate on operations and monitoring tasks

agentId: a8c4c23 (for resuming to continue this agent's work if needed)
<usage>total_tokens: 25579
tool_uses: 10
duration_ms: 18373</usage>

Prompt:
Look for any Rivet-related files in the repo at /home/ccuser/the-50-dollar-app. Search for:
1. Files named RIVET-INBOX* or rivet-inbox*
2. Any files mentioning "rivet" or "council"
3. Any ops/ or scripts/ directories with communication tools

Return the contents of any relevant files found.


Perfect! Now I have a complete picture. Let me generate the comprehensive report.

## Voice Processing Search Results

I've thoroughly searched the codebase and found all voice processing, speech recognition, and transcription-related files. Here's the complete inventory:

### 1. API Routes for Voice Processing

**File:** `/home/ccuser/the-50-dollar-app/src/app/api/ai/transcribe-voice/route.ts`
- Uses OpenAI Whisper API for audio transcription
- Takes audio blob → returns text transcript
- Includes CSRF protection, rate limiting, and auth checks
- Deprecated in favor of `/api/ai/voice-complete` for better performance
- Code snippet: Uses `openai.audio.transcriptions.create()` with whisper-1 model

**File:** `/home/ccuser/the-50-dollar-app/src/app/api/ai/parse-profile-voice/route.ts`
- Parses transcribed text using GPT-4o-mini
- Extracts structured profile data (trades, experience, location, availability)
- Uses prompt injection sanitization
- Deprecated in favor of consolidated endpoint
- Validates against TRADES constant and VALID_AVAILABILITY options

**File:** `/home/ccuser/the-50-dollar-app/src/app/api/ai/voice-complete/route.ts` (Optimized)
- Consolidated endpoint replacing the above two endpoints
- Handles transcription + parsing + profile generation in single call
- Supports both audio file upload and existing transcript input
- Enhanced error handling with fallback to manual completion
- Reduces processing time by 30-50% (from 5-12 seconds to 3-7 seconds)
- Key optimization: Merges 2 GPT-4o-mini calls into 1
- Includes performance timing data and fallback mode

**File:** `/home/ccuser/the-50-dollar-app/src/app/api/ai/voice-to-job/route.ts`
- Voice-to-job posting for contractors
- Transcribes audio and extracts structured job fields
- Returns all fields needed to auto-fill job posting form
- Validates trade IDs and applies intelligent defaults
- Includes rate clamping (MIN_RATE: $25, MAX_RATE: $500)
- Strips contact info from generated content to prevent platform bypass

---

### 2. Frontend Components and Hooks

**File:** `/home/ccuser/the-50-dollar-app/src/components/VoiceRecorder.tsx`
- Standalone reusable voice recording component
- Uses MediaRecorder API (`navigator.mediaDevices.getUserMedia`)
- Supports audio/webm or audio/mp4 MIME types
- Error handling for: NotAllowedError, NotFoundError, SecurityError, AbortError
- Shows visual feedback: mic icon, recording pulse animation, stop button
- Returns audio blob to parent component

**File:** `/home/ccuser/the-50-dollar-app/src/app/(authenticated)/worker/signup/hooks/useVoiceProcessing.ts`
- Custom hook for worker profile voice processing
- Multi-step voice completion: transcribe → parse → navigate to next form step
- Integrates with `/api/ai/transcribe-voice` and `/api/ai/parse-profile-voice`
- Smart step navigation based on parsed fields
- CSRF token management (`initializeCSRF()`, `getCSRFCookie()`)
- Error state management with user-facing messages
- Supports pre-filling multiple trades, experience, location, availability

---

### 3. Page Components with Voice Integration

**File:** `/home/ccuser/the-50-dollar-app/src/app/(authenticated)/contractor/post-job/page.tsx`
- Voice input for job posting (primary CTA)
- Large 24x24 px mic button for voice recording
- States: starting, recording, stop
- Uses `/api/ai/voice-to-job` endpoint
- Auto-fills all form fields from voice: trade, title, description, rates, certifications, location, date, workers needed
- **Error toast message:** `"Voice processing failed. Try again or type your job details."` (line 252)
- Fallback messaging: `"AI couldn't fully process your voice. Please fill in the details."` when fallback flag is set
- Loading state shows: "Listening to your voice..." → "AI writing your job post..."

**File:** `/home/ccuser/the-50-dollar-app/src/app/(authenticated)/worker/profile/build/page.tsx`
- Voice input for worker profile building
- Comments indicate optimization: "reduces voice processing time from ~5-12 seconds to ~3-7 seconds"
- Uses consolidated `/api/ai/voice-complete` endpoint (mentioned in comments)
- Multi-step form with voice-to-profile conversion
- Generates bio, skills, and certifications from voice input

---

### 4. Service Worker Files

**File:** `/home/ccuser/the-50-dollar-app/public/sw.js`
- PWA service worker with network-first strategy for pages/API
- Caches static assets (images, SVG, fonts)
- Network-first fetch for API calls (all `/api/` requests bypass cache and go straight to network)
- Important: API calls including voice processing routes are not cached

**File:** `/home/ccuser/the-50-dollar-app/public/register-sw.js`
- Simple service worker registration script
- Registered on page load event

---

### 5. Documentation Files

**File:** `/home/ccuser/the-50-dollar-app/voice-processing-analysis.md`
- Comprehensive analysis of voice processing flow
- Identifies sequential API call bottleneck (3 calls: transcribe → parse → generate)
- Details optimization opportunities
- Performance baseline: 5-12 seconds total

**File:** `/home/ccuser/the-50-dollar-app/voice-optimization-summary.md`
- Implementation summary of voice processing optimization
- Phase-based approach: API consolidation → error handling → performance monitoring → streaming
- Expected improvements: 30-50% faster (3-7 seconds)
- Testing strategy and risk assessment included

---

### 6. Summary of Voice Processing Features

**Recording:**
- Uses Web Audio API (`MediaRecorder`, `getUserMedia`)
- Captures audio in webm or mp4 format
- Blob-based transmission to API

**Transcription:**
- OpenAI Whisper API (whisper-1 model)
- Language: English
- Sanitization for injection prevention

**AI Processing:**
- GPT-4o-mini for parsing and generation
- Extracts structured data (trades, experience, location, availability)
- Generates professional bios, skills, certifications
- Temperature: 0.3 (deterministic output)

**Error Handling:**
- Toast messages: `"Voice processing failed. Try again or type your job details."` (contractor job posting)
- Fallback mode when AI processing fails
- Transcript preservation for manual completion
- Specific error messages for different failure modes

**Security:**
- CSRF protection on all voice endpoints
- Rate limiting: 10 requests per 15 minutes per user (with some endpoints at 5 per 15 minutes)
- Input sanitization (`sanitizeInput`, `stripContactInfo`)
- Auth check on all endpoints
- Contact info stripping from generated content

**Performance:**
- Consolidated endpoint reduces calls from 3 to 1-2
- Processing time: 3-7 seconds (optimized from 5-12 seconds)
- No caching in service worker for API calls
- Network-first strategy for dynamic content

agentId: aaa6159 (for resuming to continue this agent's work if needed)
<usage>total_tokens: 55533
tool_uses: 31
duration_ms: 46268</usage>

Prompt:
Search the codebase at /home/ccuser/the-50-dollar-app for all files related to voice processing, speech recognition, whisper, voice-to-text, or job posting with voice. Look for:
1. Any API routes related to voice/speech/whisper/transcription
2. Frontend components that handle voice input for job posting
3. Any references to "Voice processing failed" toast message
4. Service worker or audio recording code

Search thoroughly and return the file paths and key code snippets.


Good, I've found the key files. Let me read the voice-to-job API route and the post-job page to understand the exact issue.


     1→/**
     2→ * Voice-to-Job Endpoint
     3→ *
     4→ * Consolidated endpoint for voice-based job posting:
     5→ * 1. Whisper transcribes audio
     6→ * 2. GPT-4o-mini extracts structured job fields from transcript
     7→ *
     8→ * Returns all fields needed to auto-fill the post-job form.
     9→ */
    10→
    11→import { NextRequest, NextResponse } from "next/server";
    12→import { logger } from "@/lib/logger";
    13→import OpenAI from "openai";
    14→import { createClient } from "@/lib/supabase/server";
    15→import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
    16→import { sanitizeInput, stripContactInfo } from "@/lib/sanitize";
    17→import { withCSRFProtection } from "@/lib/csrf";
    18→import {
    19→  AI_RATE_LIMIT,
    20→  AI_WINDOW_MS,
    21→  MAX_LENGTH_TRANSCRIPT,
    22→} from "@/lib/constants";
    23→
    24→if (!process.env.OPENAI_API_KEY) {
    25→  throw new Error("OPENAI_API_KEY environment variable is not set");
    26→}
    27→
    28→const openai = new OpenAI({
    29→  apiKey: process.env.OPENAI_API_KEY,
    30→});
    31→
    32→const VALID_TRADES = [
    33→  "steel-fixer",
    34→  "formworker",
    35→  "labourer",
    36→  "concreter",
    37→  "carpenter",
    38→  "scaffolder",
    39→  "other",
    40→];
    41→
    42→const handler = async (request: NextRequest) => {
    43→  try {
    44→    const clientIP = getClientIP(request);
    45→    const rateLimit = await checkRateLimit(`ai-voice-job:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS);
    46→
    47→    if (!rateLimit.allowed) {
    48→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    49→      return createRateLimitResponse(clientIP, retryAfter);
    50→    }
    51→
    52→    const supabase = await createClient();
    53→    const { data: { user }, error: authError } = await supabase.auth.getUser();
    54→    if (authError || !user) {
    55→      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    56→    }
    57→
    58→    // Parse multipart form data with audio file
    59→    const contentType = request.headers.get("content-type") || "";
    60→
    61→    let audioFile: File | null = null;
    62→    let existingTranscript: string | null = null;
    63→
    64→    if (contentType.includes("multipart/form-data")) {
    65→      const formData = await request.formData();
    66→      audioFile = formData.get("audio") as File | null;
    67→      existingTranscript = formData.get("transcript") as string | null;
    68→    } else {
    69→      const body = await request.json();
    70→      existingTranscript = body.transcript;
    71→    }
    72→
    73→    if (!audioFile && !existingTranscript) {
    74→      return NextResponse.json(
    75→        { error: "No audio file or transcript provided" },
    76→        { status: 400 }
    77→      );
    78→    }
    79→
    80→    // Step 1: Transcribe audio
    81→    let transcript: string;
    82→
    83→    if (audioFile) {
    84→      try {
    85→        const transcription = await openai.audio.transcriptions.create({
    86→          file: audioFile,
    87→          model: "whisper-1",
    88→          language: "en",
    89→        });
    90→        transcript = transcription.text;
    91→      } catch (error) {
    92→        logger.error("Voice-to-job transcription error:", error);
    93→        return NextResponse.json(
    94→          { error: "Failed to transcribe audio" },
    95→          { status: 500 }
    96→        );
    97→      }
    98→    } else {
    99→      transcript = existingTranscript!;
   100→    }
   101→
   102→    if (!transcript.trim()) {
   103→      return NextResponse.json(
   104→        { error: "No speech detected in audio" },
   105→        { status: 400 }
   106→      );
   107→    }
   108→
   109→    const sanitizedTranscript = sanitizeInput(transcript, { maxLength: MAX_LENGTH_TRANSCRIPT });
   110→
   111→    // Step 2: Extract structured job data from transcript
   112→    try {
   113→      const completion = await openai.chat.completions.create({
   114→        model: "gpt-4o-mini",
   115→        messages: [
   116→          {
   117→            role: "system",
   118→            content: `You are an Australian construction industry expert helping contractors post jobs quickly from voice input.
   119→
   120→Extract structured job posting data from the contractor's spoken description. Use Australian English.
   121→
   122→Valid trade IDs: ${VALID_TRADES.join(", ")}
   123→
   124→Trade matching guide:
   125→- "steelfixer" / "steel fixer" / "reo" / "rebar" → "steel-fixer"
   126→- "formworker" / "formwork" / "shuttering" / "shutter hand" → "formworker"
   127→- "labourer" / "laborer" / "general hand" / "offsider" → "labourer"
   128→- "concreter" / "concrete" / "slab" → "concreter"
   129→- "carpenter" / "chippie" / "chippy" → "carpenter"
   130→- "scaffolder" / "scaffold" → "scaffolder"
   131→- Anything else → "other"
   132→
   133→Return ONLY valid JSON in this exact format:
   134→{
   135→  "trade": "one of the valid trade IDs",
   136→  "title": "concise job title (e.g. 'Steel Fixers Needed for High-Rise')",
   137→  "description": "3-4 sentence professional job description incorporating details from the voice input. Mention key duties, site expectations, PPE requirements. Use Australian English.",
   138→  "certifications": ["array of required certs — always include 'White Card', add others as appropriate"],
   139→  "rateMin": number or null (minimum hourly/daily rate in AUD if mentioned),
   140→  "rateMax": number or null (maximum hourly/daily rate in AUD if mentioned),
   141→  "rateType": "hourly" or "daily" (default "hourly"),
   142→  "suburb": "suburb or area name if mentioned, or null",
   143→  "siteAddress": "full site address if mentioned, or null",
   144→  "startDate": "ISO date string (YYYY-MM-DD) if a start date is mentioned, or null. Convert relative dates like 'next Monday' based on today being ${new Date().toISOString().split("T")[0]}.",
   145→  "duration": "duration string if mentioned (e.g. '2 weeks', 'ongoing'), or null",
   146→  "workersNeeded": number (default 1, extract from voice if mentioned)
   147→}
   148→
   149→Important:
   150→- If a rate like "45 bucks an hour" is mentioned, set rateMin to that value and rateMax to rateMin + 10.
   151→- If a daily rate is mentioned (e.g. "$400 a day"), set rateType to "daily".
   152→- Always generate a professional title and description even if the voice input is casual.
   153→- Capture ALL details mentioned — location, timing, number of workers, rates, specific requirements.`,
   154→          },
   155→          {
   156→            role: "user",
   157→            content: sanitizedTranscript,
   158→          },
   159→        ],
   160→        temperature: 0.3,
   161→        response_format: { type: "json_object" },
   162→      });
   163→
   164→      const content = completion.choices[0]?.message?.content;
   165→      if (!content) {
   166→        throw new Error("No response from AI");
   167→      }
   168→
   169→      const generated = JSON.parse(content);
   170→
   171→      // Validate trade
   172→      const trade = VALID_TRADES.includes(generated.trade) ? generated.trade : "other";
   173→
   174→      // Validate and clamp rates
   175→      const MIN_RATE = 25;
   176→      const MAX_RATE = 500;
   177→      let rateMin = typeof generated.rateMin === "number" ? generated.rateMin : null;
   178→      let rateMax = typeof generated.rateMax === "number" ? generated.rateMax : null;
   179→
   180→      if (rateMin !== null) {
   181→        rateMin = Math.max(MIN_RATE, Math.min(rateMin, MAX_RATE));
   182→      }
   183→      if (rateMax !== null) {
   184→        rateMax = Math.max(MIN_RATE, Math.min(rateMax, MAX_RATE));
   185→      }
   186→      if (rateMin !== null && rateMax !== null && rateMax < rateMin) {
   187→        [rateMin, rateMax] = [rateMax, rateMin];
   188→      }
   189→
   190→      // Validate workersNeeded
   191→      const workersNeeded = typeof generated.workersNeeded === "number" && generated.workersNeeded >= 1
   192→        ? Math.min(Math.round(generated.workersNeeded), 50)
   193→        : 1;
   194→
   195→      return NextResponse.json({
   196→        transcript: sanitizedTranscript,
   197→        trade,
   198→        title: stripContactInfo(generated.title || ""),
   199→        description: stripContactInfo(generated.description || ""),
   200→        certifications: Array.isArray(generated.certifications) ? generated.certifications : ["White Card"],
   201→        rateMin,
   202→        rateMax,
   203→        rateType: generated.rateType === "daily" ? "daily" : "hourly",
   204→        suburb: generated.suburb || null,
   205→        siteAddress: generated.siteAddress || null,
   206→        startDate: generated.startDate || null,
   207→        duration: generated.duration || null,
   208→        workersNeeded,
   209→      });
   210→    } catch (error) {
   211→      logger.error("Voice-to-job AI processing error:", error);
   212→
   213→      // Return transcript so user can still use manual entry
   214→      return NextResponse.json({
   215→        transcript: sanitizedTranscript,
   216→        error: "AI processing failed. Please fill in the details manually.",
   217→        fallback: true,
   218→      });
   219→    }
   220→  } catch (error) {
   221→    logger.error("Voice-to-job error:", error);
   222→
   223→    if (error instanceof Error) {
   224→      if (error.message.includes("API key")) {
   225→        return NextResponse.json({ error: "AI service configuration error" }, { status: 503 });
   226→      }
   227→      if (error.message.includes("rate limit") || error.message.includes("429")) {
   228→        return NextResponse.json({ error: "AI service is busy. Please try again." }, { status: 429 });
   229→      }
   230→    }
   231→
   232→    return NextResponse.json(
   233→      { error: "Failed to process voice input. Please try again." },
   234→      { status: 500 }
   235→    );
   236→  }
   237→};
   238→
   239→export const POST = withCSRFProtection(handler);
   240→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

/**
 * Voice-to-Job Endpoint
 *
 * Consolidated endpoint for voice-based job posting:
 * 1. Whisper transcribes audio
 * 2. GPT-4o-mini extracts structured job fields from transcript
 *
 * Returns all fields needed to auto-fill the post-job form.
 */

import { NextRequest, NextResponse } from "next/server";
import { logger } from "@/lib/logger";
import OpenAI from "openai";
import { createClient } from "@/lib/supabase/server";
import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
import { sanitizeInput, stripContactInfo } from "@/lib/sanitize";
import { withCSRFProtection } from "@/lib/csrf";
import {
  AI_RATE_LIMIT,
  AI_WINDOW_MS,
  MAX_LENGTH_TRANSCRIPT,
} from "@/lib/constants";

if (!process.env.OPENAI_API_KEY) {
  throw new Error("OPENAI_API_KEY environment variable is not set");
}

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

const VALID_TRADES = [
  "steel-fixer",
  "formworker",
  "labourer",
  "concreter",
  "carpenter",
  "scaffolder",
  "other",
];

const handler = async (request: NextRequest) => {
  try {
    const clientIP = getClientIP(request);
    const rateLimit = await checkRateLimit(`ai-voice-job:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS);

    if (!rateLimit.allowed) {
      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
      return createRateLimitResponse(clientIP, retryAfter);
    }

    const supabase = await createClient();
    const { data: { user }, error: authError } = await supabase.auth.getUser();
    if (authError || !user) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    // Parse multipart form data with audio file
    const contentType = request.headers.get("content-type") || "";

    let audioFile: File | null = null;
    let existingTranscript: string | null = null;

    if (contentType.includes("multipart/form-data")) {
      const formData = await request.formData();
      audioFile = formData.get("audio") as File | null;
      existingTranscript = formData.get("transcript") as string | null;
    } else {
      const body = await request.json();
      existingTranscript = body.transcript;
    }

    if (!audioFile && !existingTranscript) {
      return NextResponse.json(
        { error: "No audio file or transcript provided" },
        { status: 400 }
      );
    }

    // Step 1: Transcribe audio
    let transcript: string;

    if (audioFile) {
      try {
        const transcription = await openai.audio.transcriptions.create({
          file: audioFile,
          model: "whisper-1",
          language: "en",
        });
        transcript = transcription.text;
      } catch (error) {
        logger.error("Voice-to-job transcription error:", error);
        return NextResponse.json(
          { error: "Failed to transcribe audio" },
          { status: 500 }
        );
      }
    } else {
      transcript = existingTranscript!;
    }

    if (!transcript.trim()) {
      return NextResponse.json(
        { error: "No speech detected in audio" },
        { status: 400 }
      );
    }

    const sanitizedTranscript = sanitizeInput(transcript, { maxLength: MAX_LENGTH_TRANSCRIPT });

    // Step 2: Extract structured job data from transcript
    try {
      const completion = await openai.chat.completions.create({
        model: "gpt-4o-mini",
        messages: [
          {
            role: "system",
            content: `You are an Australian construction industry expert helping contractors post jobs quickly from voice input.

Extract structured job posting data from the contractor's spoken description. Use Australian English.

Valid trade IDs: ${VALID_TRADES.join(", ")}

Trade matching guide:
- "steelfixer" / "steel fixer" / "reo" / "rebar" → "steel-fixer"
- "formworker" / "formwork" / "shuttering" / "shutter hand" → "formworker"
- "labourer" / "laborer" / "general hand" / "offsider" → "labourer"
- "concreter" / "concrete" / "slab" → "concreter"
- "carpenter" / "chippie" / "chippy" → "carpenter"
- "scaffolder" / "scaffold" → "scaffolder"
- Anything else → "other"

Return ONLY valid JSON in this exact format:
{
  "trade": "one of the valid trade IDs",
  "title": "concise job title (e.g. 'Steel Fixers Needed for High-Rise')",
  "description": "3-4 sentence professional job description incorporating details from the voice input. Mention key duties, site expectations, PPE requirements. Use Australian English.",
  "certifications": ["array of required certs — always include 'White Card', add others as appropriate"],
  "rateMin": number or null (minimum hourly/daily rate in AUD if mentioned),
  "rateMax": number or null (maximum hourly/daily rate in AUD if mentioned),
  "rateType": "hourly" or "daily" (default "hourly"),
  "suburb": "suburb or area name if mentioned, or null",
  "siteAddress": "full site address if mentioned, or null",
  "startDate": "ISO date string (YYYY-MM-DD) if a start date is mentioned, or null. Convert relative dates like 'next Monday' based on today being ${new Date().toISOString().split("T")[0]}.",
  "duration": "duration string if mentioned (e.g. '2 weeks', 'ongoing'), or null",
  "workersNeeded": number (default 1, extract from voice if mentioned)
}

Important:
- If a rate like "45 bucks an hour" is mentioned, set rateMin to that value and rateMax to rateMin + 10.
- If a daily rate is mentioned (e.g. "$400 a day"), set rateType to "daily".
- Always generate a professional title and description even if the voice input is casual.
- Capture ALL details mentioned — location, timing, number of workers, rates, specific requirements.`,
          },
          {
            role: "user",
            content: sanitizedTranscript,
          },
        ],
        temperature: 0.3,
        response_format: { type: "json_object" },
      });

      const content = completion.choices[0]?.message?.content;
      if (!content) {
        throw new Error("No response from AI");
      }

      const generated = JSON.parse(content);

      // Validate trade
      const trade = VALID_TRADES.includes(generated.trade) ? generated.trade : "other";

      // Validate and clamp rates
      const MIN_RATE = 25;
      const MAX_RATE = 500;
      let rateMin = typeof generated.rateMin === "number" ? generated.rateMin : null;
      let rateMax = typeof generated.rateMax === "number" ? generated.rateMax : null;

      if (rateMin !== null) {
        rateMin = Math.max(MIN_RATE, Math.min(rateMin, MAX_RATE));
      }
      if (rateMax !== null) {
        rateMax = Math.max(MIN_RATE, Math.min(rateMax, MAX_RATE));
      }
      if (rateMin !== null && rateMax !== null && rateMax < rateMin) {
        [rateMin, rateMax] = [rateMax, rateMin];
      }

      // Validate workersNeeded
      const workersNeeded = typeof generated.workersNeeded === "number" && generated.workersNeeded >= 1
        ? Math.min(Math.round(generated.workersNeeded), 50)
        : 1;

      return NextResponse.json({
        transcript: sanitizedTranscript,
        trade,
        title: stripContactInfo(generated.title || ""),
        description: stripContactInfo(generated.description || ""),
        certifications: Array.isArray(generated.certifications) ? generated.certifications : ["White Card"],
        rateMin,
        rateMax,
        rateType: generated.rateType === "daily" ? "daily" : "hourly",
        suburb: generated.suburb || null,
        siteAddress: generated.siteAddress || null,
        startDate: generated.startDate || null,
        duration: generated.duration || null,
        workersNeeded,
      });
    } catch (error) {
      logger.error("Voice-to-job AI processing error:", error);

      // Return transcript so user can still use manual entry
      return NextResponse.json({
        transcript: sanitizedTranscript,
        error: "AI processing failed. Please fill in the details manually.",
        fallback: true,
      });
    }
  } catch (error) {
    logger.error("Voice-to-job error:", error);

    if (error instanceof Error) {
      if (error.message.includes("API key")) {
        return NextResponse.json({ error: "AI service configuration error" }, { status: 503 });
      }
      if (error.message.includes("rate limit") || error.message.includes("429")) {
        return NextResponse.json({ error: "AI service is busy. Please try again." }, { status: 429 });
      }
    }

    return NextResponse.json(
      { error: "Failed to process voice input. Please try again." },
      { status: 500 }
    );
  }
};

export const POST = withCSRFProtection(handler);


     1→## OPEN ITEMS
     2→
     3→*No new messages pending acknowledgment*
     4→
     5→---
     6→
     7→*Note: Historical reports from March 4-11 have been archived to memory/comms/archive/2026-03-13-rivet-inbox-archive.md as part of daily inbox cleanup.*## 2026-03-14 18:00 AEDT — Phase 1A: TheOffice Page to Real Data
     8→📋 NEW: Claimed P0 task from BUILDER-INBOX.md. Executing.
     9→## 2026-03-14 18:37 AEDT — Phase 1A: TheOffice Page to Real Data
    10→🚫 BLOCKED: No artifact produced in 30 minutes. Attempted to locate the correct page file and make edits but failed to produce an artifact in the allocated timeframe. Next action: Review architecture and dependencies for TheOffice page in opsman-control-centre.
    11→
    12→## 2026-03-15 18:30 AEDT — [P0: Connect Control Centre to Real Data]
    13→✅ **Phase 1A (TheOffice Page) Complete**
    14→- Removed all hardcoded `DEMO_STATUS`, `DEMO_COOLER`, and `DEMO_WINS` from `/home/ccuser/opsman-control-centre/src/pages/TheOffice.jsx`.
    15→- Real data (agent snapshots, activity log, and council messages) from Supabase is now fully powering The Office page with no fallback.
    16→- Confirmed `npm run build` succeeds cleanly in the control centre workspace.
    17→- Committed changes to the `opsman-control-centre` repository.
    18→
    19→## 2026-03-16 18:27 AEDT — [P0: Connect Control Centre to Real Data]
    20→📋 NEW: Claimed Phase 1B: Memories & Calendar Pages -> Real Data from BUILDER-INBOX.md. Executing.
    21→
    22→## 2026-03-16 18:29 AEDT — [P0: Connect Control Centre to Real Data]
    23→✅ **Phase 1B (Memories & Calendar Pages) Complete**
    24→- Removed `DEMO_MEMORIES` from `src/pages/Memories.jsx` and strictly used `realMemories` from the database with no fallback.
    25→- Removed `DEMO_SCHEDULE` from `src/pages/Calendar.jsx`. Now correctly displays real crons from `fleet.config.js` and real scheduled tasks.
    26→- `npm run build` passes successfully in the control centre workspace.
    27→- Committed and pushed changes to the `opsman-control-centre` repository.
    28→## 2026-03-17 19:07 AEDT — [P0: Connect Control Centre to Real Data]
    29→📋 NEW: Claimed Phase 1C: Documents & AgentDetail Pages -> Real Data from BUILDER-INBOX.md. Executing.
    30→
    31→## 2026-03-17 19:10 AEDT — [P0: Connect Control Centre to Real Data]
    32→✅ **Phase 1C (Documents & AgentDetail Pages) Complete**
    33→- Removed demo data from `src/pages/Documents.jsx` and `src/pages/AgentDetail.jsx`.
    34→- Wired fully to real data (`useDocuments`, `useAgentSnapshot`, `useAgentActivity`).
    35→- Verified build succeeds cleanly (`npm run build`).
    36→- Committed changes and pushed to `opsman-control-centre` repository.
    37→
    38→## 2026-03-18 18:02 AEDT — [Phase 1C Complete]
    39→✅ DONE: Phase 1C for Control Centre is complete.
    40→- Removed demo variables in `Documents.jsx` and `AgentDetail.jsx`.
    41→- Real Supabase data is rendering correctly.
    42→- Clean build passed, pushed to `opsman-control-centre` master branch.
    43→
    44→## 2026-03-18 21:05 AEDT — [ACK P0 Council_respond]
    45→ACK: Starting implementation of Council_respond function for Control Centre integration.
    46→
    47→## 2026-03-18 21:07 AEDT — [P0 Council_respond Complete]
    48→✅ DONE: `council_respond` RPC created and deployed.
    49→- Found the underlying missing tables (`cc_council_topics`, `cc_council_messages` etc) by applying `20260303000000_control_centre.sql` and `20260306000000_council_router.sql` to Supabase.
    50→- Deployed `20260318000000_council_respond_rpc.sql`.
    51→- Successfully created a "Growth Strategy" topic and posted a test message using the `council_respond` function via the REST API.
    52→- Code pushed to `opsman-control-centre` master.
    53→
    54→The Council payload schema for REST:
    55→```json
    56→{
    57→  "topic_id": "UUID",
    58→  "agent_id": "your_agent_name",
    59→  "message": "content",
    60→  "message_type": "chat" // optional: 'chat'|'prompt'|'response'|'discussion'|'plan'
    61→}
    62→```
    63→
    64→## 2026-03-19 18:05 AEDT — [✅ P0: Council_respond Function]
    65→**Status:** ✅ DONE
    66→**Changes:**
    67→- Created `/home/ccuser/shared/scripts/council-respond.js` as an agent tool to respond directly to Council Topics via Supabase API (inserts into `cc_council_messages`).
    68→- It parses the agent name, topic title, and message text to post seamlessly.
    69→- Tested a mock post from Builder to the "Growth Strategy" topic successfully.
    70→**Usage:** `node /home/ccuser/shared/scripts/council-respond.js --agent <agent_name> --topic "Growth Strategy" --message "Your message"`
    71→
    72→## 2026-03-19 19:08 AEDT — [🔧 FIX: get_conversation_previews crash]
    73→**Status:** ✅ DONE
    74→**Context:** Herald/Cog reported "massive system code failures". Investigated production logs and found a recurrent crash in the `/api/messages` route.
    75→**Root Cause:**
    76→1. Supabase had two overloaded versions of `get_conversation_previews` (one without page limits, one with).
    77→2. The UI was not explicitly sending the pagination parameters, causing Postgres to throw an ambiguous function error.
    78→3. Internally, the SQL function itself also had an ambiguous column reference (`conversation_id`).
    79→**Fix:**
    80→- Updated `/api/messages/route.ts` to explicitly provide `page_limit` and `page_offset`.
    81→- Applied a DB migration directly via Supabase API to fix the `conversation_id` column reference ambiguity inside the PL/pgSQL function.
    82→**Commit:** `ad6b3a9` (Fix is deployed / merged into main).
    83→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

## OPEN ITEMS

*No new messages pending acknowledgment*

---

*Note: Historical reports from March 4-11 have been archived to memory/comms/archive/2026-03-13-rivet-inbox-archive.md as part of daily inbox cleanup.*## 2026-03-14 18:00 AEDT — Phase 1A: TheOffice Page to Real Data
📋 NEW: Claimed P0 task from BUILDER-INBOX.md. Executing.
## 2026-03-14 18:37 AEDT — Phase 1A: TheOffice Page to Real Data
🚫 BLOCKED: No artifact produced in 30 minutes. Attempted to locate the correct page file and make edits but failed to produce an artifact in the allocated timeframe. Next action: Review architecture and dependencies for TheOffice page in opsman-control-centre.

## 2026-03-15 18:30 AEDT — [P0: Connect Control Centre to Real Data]
✅ **Phase 1A (TheOffice Page) Complete**
- Removed all hardcoded `DEMO_STATUS`, `DEMO_COOLER`, and `DEMO_WINS` from `/home/ccuser/opsman-control-centre/src/pages/TheOffice.jsx`.
- Real data (agent snapshots, activity log, and council messages) from Supabase is now fully powering The Office page with no fallback.
- Confirmed `npm run build` succeeds cleanly in the control centre workspace.
- Committed changes to the `opsman-control-centre` repository.

## 2026-03-16 18:27 AEDT — [P0: Connect Control Centre to Real Data]
📋 NEW: Claimed Phase 1B: Memories & Calendar Pages -> Real Data from BUILDER-INBOX.md. Executing.

## 2026-03-16 18:29 AEDT — [P0: Connect Control Centre to Real Data]
✅ **Phase 1B (Memories & Calendar Pages) Complete**
- Removed `DEMO_MEMORIES` from `src/pages/Memories.jsx` and strictly used `realMemories` from the database with no fallback.
- Removed `DEMO_SCHEDULE` from `src/pages/Calendar.jsx`. Now correctly displays real crons from `fleet.config.js` and real scheduled tasks.
- `npm run build` passes successfully in the control centre workspace.
- Committed and pushed changes to the `opsman-control-centre` repository.
## 2026-03-17 19:07 AEDT — [P0: Connect Control Centre to Real Data]
📋 NEW: Claimed Phase 1C: Documents & AgentDetail Pages -> Real Data from BUILDER-INBOX.md. Executing.

## 2026-03-17 19:10 AEDT — [P0: Connect Control Centre to Real Data]
✅ **Phase 1C (Documents & AgentDetail Pages) Complete**
- Removed demo data from `src/pages/Documents.jsx` and `src/pages/AgentDetail.jsx`.
- Wired fully to real data (`useDocuments`, `useAgentSnapshot`, `useAgentActivity`).
- Verified build succeeds cleanly (`npm run build`).
- Committed changes and pushed to `opsman-control-centre` repository.

## 2026-03-18 18:02 AEDT — [Phase 1C Complete]
✅ DONE: Phase 1C for Control Centre is complete.
- Removed demo variables in `Documents.jsx` and `AgentDetail.jsx`.
- Real Supabase data is rendering correctly.
- Clean build passed, pushed to `opsman-control-centre` master branch.

## 2026-03-18 21:05 AEDT — [ACK P0 Council_respond]
ACK: Starting implementation of Council_respond function for Control Centre integration.

## 2026-03-18 21:07 AEDT — [P0 Council_respond Complete]
✅ DONE: `council_respond` RPC created and deployed.
- Found the underlying missing tables (`cc_council_topics`, `cc_council_messages` etc) by applying `20260303000000_control_centre.sql` and `20260306000000_council_router.sql` to Supabase.
- Deployed `20260318000000_council_respond_rpc.sql`.
- Successfully created a "Growth Strategy" topic and posted a test message using the `council_respond` function via the REST API.
- Code pushed to `opsman-control-centre` master.

The Council payload schema for REST:
```json
{
  "topic_id": "UUID",
  "agent_id": "your_agent_name",
  "message": "content",
  "message_type": "chat" // optional: 'chat'|'prompt'|'response'|'discussion'|'plan'
}
```

## 2026-03-19 18:05 AEDT — [✅ P0: Council_respond Function]
**Status:** ✅ DONE
**Changes:**
- Created `/home/ccuser/shared/scripts/council-respond.js` as an agent tool to respond directly to Council Topics via Supabase API (inserts into `cc_council_messages`).
- It parses the agent name, topic title, and message text to post seamlessly.
- Tested a mock post from Builder to the "Growth Strategy" topic successfully.
**Usage:** `node /home/ccuser/shared/scripts/council-respond.js --agent <agent_name> --topic "Growth Strategy" --message "Your message"`

## 2026-03-19 19:08 AEDT — [🔧 FIX: get_conversation_previews crash]
**Status:** ✅ DONE
**Context:** Herald/Cog reported "massive system code failures". Investigated production logs and found a recurrent crash in the `/api/messages` route.
**Root Cause:**
1. Supabase had two overloaded versions of `get_conversation_previews` (one without page limits, one with).
2. The UI was not explicitly sending the pagination parameters, causing Postgres to throw an ambiguous function error.
3. Internally, the SQL function itself also had an ambiguous column reference (`conversation_id`).
**Fix:**
- Updated `/api/messages/route.ts` to explicitly provide `page_limit` and `page_offset`.
- Applied a DB migration directly via Supabase API to fix the `conversation_id` column reference ambiguity inside the PL/pgSQL function.
**Commit:** `ad6b3a9` (Fix is deployed / merged into main).