/** * Tesla Roadtrip — Grok Headless Client * * Now using pure JSON output mode for much more reliable structured itineraries. */ import { spawn } from 'child_process'; import { mkdtemp, rm, access } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; import { createLogger } from '../../lib/logger.js'; import { env } from '../../config/env.js'; import crypto from 'crypto'; const log = createLogger('grok-headless'); const SENTINEL = 'ITINERARY_UPDATE:'; export interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; } export interface GrokResponse { text: string; updatedItinerary?: any; variants?: any[]; selectedVariant?: string; } export type VehicleInput = string | { name: string; rangeKm?: number }; function vehicleName(v: VehicleInput): string { return typeof v === 'string' ? v : v.name; } type Provider = 'local' | 'xai' | 'fallback'; export class GrokHeadlessClient { private provider: Provider; constructor() { this.provider = this.resolveInitialProvider(); log.info({ provider: this.provider, grokBin: env.grokBin, forceXaiApi: env.forceXaiApi }, 'GrokHeadlessClient initialized'); } private resolveInitialProvider(): Provider { if (env.forceXaiApi && env.xaiApiKey) return 'xai'; if (!env.grokEnabled) return env.xaiApiKey ? 'xai' : 'fallback'; return 'local'; } private async getActiveProvider(requestId: string): Promise { if (env.forceXaiApi) { if (env.xaiApiKey) { log.info({ requestId }, 'Provider decision: xAI API (FORCE_XAI_API=true)'); return 'xai'; } return 'fallback'; } if (!env.grokEnabled) { return env.xaiApiKey ? 'xai' : 'fallback'; } try { await access(env.grokBin); log.info({ requestId, bin: env.grokBin }, 'Provider decision: LOCAL personal Grok CLI (your authenticated Heavy account)'); return 'local'; } catch { log.info({ requestId, bin: env.grokBin }, 'Local grok binary not found — using xAI API'); return env.xaiApiKey ? 'xai' : 'fallback'; } } private buildPrompt(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast') { const variantBrief = { fast: 'Fastest — minimise drive time. Pick the most direct route via motorways. Sleep in the car or at a budget hotel with destination charging. Optimise for arriving sooner, not for sightseeing.', scenic: 'Scenic — pick the prettiest practical route even if it adds time. Favour scenic A-roads, viewpoints, charming towns, regional food. Stay at a hotel (not car-sleep). Add an extra hour or two for memorable stops.', cheap: 'Cheapest — minimise cost. Avoid toll roads where possible, prefer off-peak charging, pick budget overnight options (car sleep or basic hotels), and choose cheaper chargers when available. Drive time can be a bit longer to save €.', }[selectedVariant] || 'Fastest — minimise drive time.'; return `You are Grok Drive, an expert Tesla road trip planner for the UK and Europe. You build practical, enjoyable itineraries — not just a list of charging stops. Treat every break as a chance to eat, rest, sightsee, or sleep. Selected route variant: ${selectedVariant.toUpperCase()} ${variantBrief} Current vehicle: ${vehicleName(vehicle)} Current itinerary: ${JSON.stringify(itinerary || {}, null, 2)} Respond with **only** a single valid JSON object in exactly this format. No text before or after. No markdown. { "message": "A friendly, natural reply to the user (1-4 sentences). Highlight one or two of the best combo picks (e.g. 'I picked the Watford Gap Supercharger because the M&S Food Hall is right there').", "itinerary": { "days": [ { "day": 1, "title": "Short day label like 'London → Manchester'", "stops": [ { "id": "unique-string", "name": "Human readable name", "type": "supercharger" | "destination-charger" | "hotel" | "attraction" | "restaurant" | "cafe" | "viewpoint" | "custom", "lat": 51.477, "lng": -0.17, "day": 1, "order": 1, "estArrivalBattery": 25, "chargeMinutes": 25, "durationMin": 45, "combo": "charge + eat" | "charge + coffee" | "stay + destination charging" | "eat + viewpoint" | "charge + shopping" | null, "description": "1-2 sentence reason this stop is a great pick — what's right next to the charger, why this hotel, what's special about this stop", "amenities": ["restaurant", "coffee", "toilets", "shopping", "wifi", "playground", "ev-charging", "destination-charging"], "cuisine": "British pub" | "Italian" | "French" | "Cafe" | null, "priceLevel": 1 | 2 | 3 | 4, "notes": "optional extra hint (booking tips, opening hours, etc.)", "alternatives": [ { "id": "unique-alt-string", "name": "Alternative pick name", "type": "supercharger" | "hotel" | "restaurant" | "cafe" | "attraction" | "destination-charger" | "viewpoint" | "custom", "lat": 51.5, "lng": -0.1, "description": "1-2 sentences explaining why this is a viable swap", "combo": "charge + eat" | "stay + destination charging" | null, "amenities": ["restaurant", "toilets"], "cuisine": "Italian" | null, "priceLevel": 2, "chargeMinutes": 25, "durationMin": 60, "deltaKm": 12, "deltaMin": 9, "reason": "Short reason this is a worthwhile alternative (e.g. 'Cheaper and faster but no restaurant on site')" } ], "nearby": [ { "category": "food" | "do" | "see" | "shop" | "rest", "icon": "coffee" | "restaurant" | "fast-food" | "shopping" | "supermarket" | "viewpoint" | "museum" | "park" | "beach" | "playground" | "toilets" | "wifi", "name": "Boulangerie Pâtisserie L. Marc", "detail": "3 min walk · 4.7★ · open until 19:00" } ], "chargerOptions": [ { "id": "unique-charger-id", "name": "Aire de Beaune Supercharger", "network": "Tesla" | "Ionity" | "Allego" | "TotalEnergies" | "Fastned" | "BP Pulse" | "Other", "stalls": 12, "kw": 250, "pricePerKwh": 0.42, "detourMin": 0, "isCurrent": true, "badge": "Current" | "Faster" | "Cheaper" | "Newer" | "More stalls" | null } ] } ] } ], "summary": { "totalDistanceKm": 650, "estDriveHours": 10.5, "estChargeHours": 1.5, "superchargers": 3, "hotels": 1, "highlights": ["Lunch at Tebay services", "Sunset view at Crummock Water"] } }, "variants": [ { "id": "fast", "label": "Fastest", "tone": "primary", "distanceKm": 2074, "driveHours": 23.5, "chargeHours": 4.5, "costEur": 312, "highlight": "drive" | "cost" | "pretty", "pros": ["8 stops", "Sleep in car · Reims", "1 night", "A26 corridor"] }, { "id": "scenic", "label": "Scenic", "tone": "green", "distanceKm": 2218, "driveHours": 26.2, "chargeHours": 4.8, "costEur": 328, "highlight": "pretty", "pros": ["Via Burgundy + Pyrénées", "Hotel night · Avignon", "10 stops", "+2h 42m"] }, { "id": "cheap", "label": "Cheapest", "tone": "blue", "distanceKm": 2098, "driveHours": 24.0, "chargeHours": 5.2, "costEur": 270, "highlight": "cost", "pros": ["Avoids tolls", "Off-peak charging", "€42 cheaper"] } ] } Strict route planning rules: - Plan stops in the actual order the driver will encounter them on the road. - Choose Superchargers that are realistically reachable given the vehicle range. - Space charging stops sensibly (every 150-250km depending on route and battery). - Calculate realistic estArrivalBattery based on distance driven since last charge. - Every stop MUST have accurate real-world latitude and longitude. - Use realistic daily driving distances (max ~5-6 hours driving per day). - "message" should feel like a helpful human assistant. - If no clear trip is requested yet, set "itinerary" to null. Route variants (REQUIRED): - "variants" must always contain exactly 3 entries with ids "fast", "scenic", "cheap" in that order. - Each variant is a *summary only* — drive/charge/cost/pros — describing what the route would look like if the user picked that variant. The actual stops in "itinerary" reflect the currently-selected variant: "${selectedVariant}". - "distanceKm" (number, km), "driveHours" (number, decimal hours, e.g. 23.5), "chargeHours" (number, decimal hours), "costEur" (number, € for tolls + charging combined). - "pros" is 3-5 short pills (max ~30 chars each) that describe the unique selling points of that variant relative to the others (e.g. "Avoids tolls", "Sleep in car · Reims", "+2h 42m drive"). - "highlight" picks the stat to colour-highlight: "drive" for fastest, "pretty" for scenic, "cost" for cheapest. - The 3 variants must be genuinely different (different stops, different days, different totals). Don't just shuffle the same route. Nearby (REQUIRED for every Supercharger, destination-charger and hotel stop): - Populate "nearby" with 3-6 places within walking distance of the stop. - Categories: "food" (restaurants/cafes/bakeries), "do" (walks, things to do), "see" (sights/viewpoints/museums), "shop" (supermarkets, retail), "rest" (toilets, lounges). - "detail" should include walk time and a quick descriptor or rating (e.g. "3 min walk · 4.5★ · paella", "8 min · UNESCO ruins"). - "icon" should be one of the amenity tokens (coffee, restaurant, fast-food, shopping, supermarket, viewpoint, museum, park, beach, playground, toilets, wifi). - These are real places at or near the stop — pick named establishments where possible. Charger options (REQUIRED for every Supercharger and destination-charger stop): - "chargerOptions" must list 1-4 real charging operators in the immediate area of this stop. The current pick is duplicated as the first entry with isCurrent: true. - "network" must be the real charging network (Tesla / Ionity / Allego / TotalEnergies / Fastned / BP Pulse / Other). - "stalls" is the total number of charging stalls at that location, "kw" is the max charging power, "pricePerKwh" is the public €/kWh price. - "detourMin" is the extra drive time vs the currently-chosen charger (0 for the current pick). - "badge" can be "Faster" (higher kW), "Cheaper" (lower €/kWh), "Newer", "More stalls", or null. Pick one based on the trade-off vs the current pick. - This lets the user swap to a faster but pricier Ionity, or a cheaper Allego, etc. Alternatives (REQUIRED for every Supercharger and hotel stop): - For each Supercharger or hotel stop, populate "alternatives" with 1-3 realistic swap options the driver might prefer. - Each alternative is a fully-formed stop the user could swap to: complete lat/lng, type, name, description. - "deltaKm" is the estimated change in total trip distance vs the chosen stop (positive = adds km, negative = saves km). - "deltaMin" is the estimated change in total drive time vs the chosen stop, in minutes. - "reason" explains the trade-off in one short sentence ("Cheaper hotel, no destination charging" / "Adds 15 mins but has the best food on this stretch of the M6"). - Alternatives must be genuinely different choices a driver would consider — not minor variants. Mix the trade-offs: faster, cheaper, fancier, better food, closer to attractions, etc. - For non-Supercharger/non-hotel stops (a viewpoint, a quick coffee), alternatives are optional. Combo philosophy (THIS IS THE IMPORTANT PART — don't skip): - Whenever possible, pick Superchargers that are co-located with a real restaurant, cafe, services area, supermarket, or visitor attraction. Mention what's there in "description" and tag the stop with combo: "charge + eat" (or similar). - Prefer hotels that offer destination charging (Tesla destination chargers, Type 2, or onsite EV charging). Tag those combo: "stay + destination charging" and add "destination-charging" to amenities. - For meal-time stops, look for a charger close to a great independent restaurant or cafe — not just "the Supercharger has a McDonald's next door" unless that's all there is. - If a stop is just a quick top-up with nothing nearby, that's fine — set combo to null and explain in description. - Use "durationMin" to indicate the total time at that stop (charge time + meal time, or just charging, or just dinner without charging). - "amenities" should be the actual on-site amenities the driver will find. Use lowercase kebab-case tokens from this set: restaurant, cafe, fast-food, supermarket, toilets, shopping, wifi, playground, ev-charging, destination-charging, hotel, coffee, viewpoint, museum, park, beach, gym, pool. Examples of great combo stops to favour when they fit the route: - UK: Tebay Services (M6) — independent local food + Supercharger. Gretna Green — Supercharger + outlet shopping + cafe. - France: Aire de Beaune (A6) — Supercharger + regional bakery + wine country. - Germany: Autohof Lutterberg (A7) — Supercharger + traditional restaurant. - Netherlands: Schoonebeek — Supercharger near restaurant cluster. - Switzerland: St. Gallen — Supercharger + lakeside cafe. Conversation history: ${messages.map(m => `${m.role.toUpperCase()}: ${m.content}`).join('\n')} Respond with ONLY the JSON object.`; } async chat(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast'): Promise { const requestId = crypto.randomUUID().slice(0, 8); log.info({ requestId, vehicle: vehicleName(vehicle), messageCount: messages.length, selectedVariant }, '=== NEW CHAT REQUEST ==='); const activeProvider = await this.getActiveProvider(requestId); if (activeProvider === 'xai') { return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant); } if (activeProvider === 'fallback') { return this.dumbFallback(messages, requestId); } // LOCAL PERSONAL GROK CLI const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant); const tmp = await mkdtemp(join(tmpdir(), 'grok-eu-')); const disallowed = env.nodeEnv === 'development' ? 'search_replace,write_file,Agent,run_terminal_cmd' : 'run_terminal_cmd,search_replace,write_file,Agent'; try { const args = [ '-p', prompt, '--output-format', 'json', '--yolo', '--disallowed-tools', disallowed, '--tools', 'web_search,web_fetch', '--max-turns', '6', '--cwd', tmp, ]; log.info({ requestId, bin: env.grokBin }, 'Spawning local authenticated grok CLI (pure JSON mode)'); const result = await new Promise((resolve, reject) => { const child = spawn(env.grokBin, args, { cwd: tmp, env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; child.stdout.on('data', (d) => { stdout += d; }); child.stderr.on('data', (d) => { stderr += d; }); child.on('close', (code) => { if (code === 0) resolve(stdout); else { log.error({ requestId, code, stderr: stderr.slice(-800) }, 'Local grok CLI exited non-zero'); reject(new Error(`grok exited with code ${code}`)); } }); child.on('error', reject); }); const data = JSON.parse(result) as { text?: string }; const rawText = data.text || ''; const { text: cleanText, itinerary: parsed, variants } = this.parseGrokResponse(rawText); log.info({ requestId, hasItinerary: !!parsed, variantCount: variants?.length || 0 }, 'Local Grok CLI returned JSON response'); return { text: cleanText, updatedItinerary: parsed, variants, selectedVariant }; } catch (err) { log.error({ requestId, err: String(err) }, 'Local authenticated Grok CLI failed — falling back to xAI API'); if (env.xaiApiKey) { return this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant); } return this.dumbFallback(messages, requestId); } finally { await rm(tmp, { recursive: true, force: true }).catch(() => {}); } } private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast'): Promise { const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant); log.info({ requestId, promptLength: prompt.length, model: 'grok-4.3' }, 'Calling xAI API (grok-4.3 + JSON mode)'); try { const response = await fetch('https://api.x.ai/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${env.xaiApiKey}`, }, body: JSON.stringify({ model: 'grok-4.3', messages: [{ role: 'user', content: prompt }], temperature: 0.6, response_format: { type: 'json_object' }, }), }); if (!response.ok) { const text = await response.text(); log.error({ requestId, status: response.status, body: text }, 'xAI API error'); return this.dumbFallback(messages, requestId); } const data = (await response.json()) as { choices?: { message?: { content?: string } }[] }; const rawText = data.choices?.[0]?.message?.content || ''; const { text: cleanText, itinerary: parsed, variants } = this.parseGrokResponse(rawText); return { text: cleanText, updatedItinerary: parsed, variants, selectedVariant }; } catch (err) { log.error({ requestId, err }, 'xAI API call failed'); return this.dumbFallback(messages, requestId); } } private parseGrokResponse(rawText: string): { text: string; itinerary: any | null; variants?: any[] } { try { const cleaned = rawText.trim().replace(/^```json\s*/, '').replace(/```$/, '').trim(); const parsed = JSON.parse(cleaned); if (parsed && typeof parsed === 'object') { return { text: parsed.message || parsed.reply || '', itinerary: parsed.itinerary || null, variants: Array.isArray(parsed.variants) ? parsed.variants : undefined, }; } } catch (e) { log.warn({ err: String(e), raw: rawText.slice(0, 300) }, 'Failed to parse Grok response as JSON'); } // Fallback to old sentinel method return this.extractItineraryUpdate(rawText); } private extractItineraryUpdate(text: string): { text: string; itinerary: any | null; variants?: any[] } { const upperText = text.toUpperCase(); const sentinelIndex = upperText.indexOf(SENTINEL.toUpperCase()); if (sentinelIndex === -1) return { text: text.trim(), itinerary: null }; const after = text.substring(sentinelIndex + SENTINEL.length).trim(); let jsonStart = after.indexOf('{'); if (jsonStart === -1) return { text: text.substring(0, sentinelIndex).trim(), itinerary: null }; let depth = 0, end = -1, inString = false, escape = false; for (let i = jsonStart; i < after.length; i++) { const ch = after[i]; if (escape) { escape = false; continue; } if (ch === '\\') { escape = true; continue; } if (ch === '"') { inString = !inString; continue; } if (!inString) { if (ch === '{') depth++; if (ch === '}') { depth--; if (depth === 0) { end = i; break; } } } } if (end === -1) return { text: text.substring(0, sentinelIndex).trim(), itinerary: null }; const jsonStr = after.substring(jsonStart, end + 1); try { const parsed = JSON.parse(jsonStr); return { text: text.substring(0, sentinelIndex).trim(), itinerary: parsed }; } catch (e) { return { text: text.substring(0, sentinelIndex).trim(), itinerary: null }; } } private async dumbFallback(messages: ChatMessage[], requestId: string): Promise { const last = messages[messages.length - 1]?.content.toLowerCase() || ''; if (['hi', 'hello', 'hey'].some(g => last.includes(g))) { return { text: "Hello! I'm Grok Drive. How can I help plan your UK or European Tesla trip today?", updatedItinerary: null }; } return { text: "I'm ready to plan a great Tesla route for you across the UK and Europe. Tell me where you want to go!", updatedItinerary: null }; } async getStatus() { const localBinExists = await this.localBinaryExists(); let provider: 'local' | 'xai' | 'fallback' = 'fallback'; let label = 'Fallback'; let detail = 'Basic responses only'; if (env.forceXaiApi && env.xaiApiKey) { provider = 'xai'; label = 'grok-4.3 API'; detail = 'Production path (xAI)'; } else if (!env.grokEnabled) { provider = env.xaiApiKey ? 'xai' : 'fallback'; } else if (localBinExists) { provider = 'local'; label = 'Local Heavy'; detail = 'Your authenticated Grok (free)'; } else if (env.xaiApiKey) { provider = 'xai'; label = 'grok-4.3 API'; detail = 'Production path (xAI)'; } return { provider, label, detail, isLocal: provider === 'local', model: provider === 'local' ? 'Heavy (personal)' : 'grok-4.3', bin: env.grokBin }; } private async localBinaryExists(): Promise { try { await access(env.grokBin); return true; } catch { return false; } } } export const grok = new GrokHeadlessClient();