Files
tony cff52b4b9e feat: travel dates + sea-crossing chooser, Tesla in-car polish, Fleet API stub
- Travel dates: TopBar chip + popover (outbound/return/travellers); sent to
  Grok prompt; itinerary.needsTravelDates drives a nudge banner; cache and
  prefetch ledger invalidate when dates change
- Sea crossings: CrossingOption schema (Eurotunnel, DFDS, P&O, Brittany,
  Stena Line); CrossingSwapBlock under tunnel/ferry/crossing stops with
  trip-impact deltas and Book links; prompt requires 3-5 real options for
  every UK ↔ mainland route; picking a crossing triggers silent re-plan
- Tesla in-car polish: UA + heuristic detection sets <html class="incar">;
  CSS overrides kill backdrop-filter, scale fonts, enforce 44px tap targets,
  disable hover flicker; geolocation + reverse geocode + crosshair button
  inside the From input; up/down arrow reorder buttons replace touch-broken
  HTML5 drag-and-drop
- Tesla Fleet API stub: /.well-known/appspecific/com.tesla.3p.public-key.pem
  served from TESLA_FLEET_PUBLIC_KEY for partner domain verification;
  OAuth callback + vehicle_data stub return 503 until partner approval
- Dockerfile + .dockerignore for Dokku deployment; server now serves
  client/dist in production
2026-05-31 21:38:27 +01:00

795 lines
39 KiB
TypeScript

/**
* 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 };
export interface StreamEvent {
type: 'thinking' | 'partial' | 'done' | 'error';
message?: string;
itinerary?: any;
variants?: any[];
text?: string;
selectedVariant?: string;
error?: string;
}
/**
* Lenient JSON parser — given a truncated/in-progress JSON string,
* balance open brackets/quotes and try to parse. Returns null on failure.
*
* Closes nested structures in correct stack order (innermost first).
*/
export function tryPartialJsonParse(input: string): any | null {
const first = input.indexOf('{');
if (first === -1) return null;
const stripped = input.slice(first).replace(/^```json\s*/, '').replace(/```\s*$/, '');
// Walk the buffer to record open structures and look for a complete top-level object
const stack: ('{' | '[')[] = [];
let inStr = false, escape = false;
let cleanEnd = -1;
let lastSafePos = -1; // last position where we're outside strings + at a "safe" punctuation
for (let i = 0; i < stripped.length; i++) {
const c = stripped[i];
if (escape) { escape = false; continue; }
if (inStr) {
if (c === '\\') { escape = true; continue; }
if (c === '"') { inStr = false; lastSafePos = i; }
continue;
}
if (c === '"') { inStr = true; continue; }
if (c === '{' || c === '[') { stack.push(c as '{' | '['); continue; }
if (c === '}' || c === ']') {
stack.pop();
if (stack.length === 0) cleanEnd = i;
lastSafePos = i;
continue;
}
if (c === ',' || c === ':') { lastSafePos = i; }
}
if (cleanEnd !== -1) {
try { return JSON.parse(stripped.slice(0, cleanEnd + 1)); } catch { /* fall through */ }
}
// Build repaired buffer:
// 1) Truncate to lastSafePos (a comma, colon, quote-close, or bracket-close)
// 2) Strip trailing comma OR a dangling key (e.g. `,"foo"` or `:"foo`)
// 3) Close stack in reverse order
let repaired = stripped;
if (inStr) {
// We're mid-string — chop everything from the opening quote
let q = repaired.length - 1;
while (q >= 0 && repaired[q] !== '"') q--;
if (q >= 0) repaired = repaired.slice(0, q);
inStr = false;
} else if (lastSafePos !== -1 && lastSafePos < repaired.length - 1) {
repaired = repaired.slice(0, lastSafePos + 1);
}
// Trim trailing comma, colon, or partial key/value
// e.g. `"name":` → drop the `:` and the preceding `"name"` (it'd be a key with no value)
for (let pass = 0; pass < 4; pass++) {
const before = repaired.length;
// Trailing colon (incomplete key) — drop the key
repaired = repaired.replace(/,?\s*"[^"]*"\s*:\s*$/, '');
// Trailing comma
repaired = repaired.replace(/,\s*$/, '');
// Dangling identifier (true/false/null/number-ish) after colon — drop
repaired = repaired.replace(/,?\s*"[^"]*"\s*:\s*[a-zA-Z0-9.\-+eE]+$/, '');
if (repaired.length === before) break;
}
// Rebuild the open-stack on the REPAIRED buffer (in case the trims changed it)
const finalStack: ('{' | '[')[] = [];
inStr = false; escape = false;
for (let i = 0; i < repaired.length; i++) {
const c = repaired[i];
if (escape) { escape = false; continue; }
if (inStr) {
if (c === '\\') { escape = true; continue; }
if (c === '"') inStr = false;
continue;
}
if (c === '"') { inStr = true; continue; }
if (c === '{' || c === '[') { finalStack.push(c as '{' | '['); continue; }
if (c === '}') { if (finalStack[finalStack.length - 1] === '{') finalStack.pop(); continue; }
if (c === ']') { if (finalStack[finalStack.length - 1] === '[') finalStack.pop(); continue; }
}
// Close in reverse stack order — '{' → '}', '[' → ']'
for (let i = finalStack.length - 1; i >= 0; i--) {
repaired += finalStack[i] === '{' ? '}' : ']';
}
try { return JSON.parse(repaired); } catch { return null; }
}
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<Provider> {
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', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}) {
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.';
const odBlock = (opts.origin && opts.destination)
? `\nTRIP ENDPOINTS (these are the ground truth — your itinerary MUST start exactly here and end exactly here):\n Origin: ${opts.origin}\n Destination: ${opts.destination}\n`
: '';
const td = opts.travelDates;
const hasDates = !!(td && (td.outbound || td.return));
const datesBlock = hasDates
? `\nTRAVEL DATES (use these for crossing/ferry/hotel pricing — peak vs off-peak vs weekend):\n Outbound: ${td!.outbound || '(not set)'}\n Return: ${td!.return || '(one-way)'}\n Travellers: ${td!.travellers ?? 'unknown'}\n`
: `\nTRAVEL DATES: not yet provided by the user. Use ballpark off-peak prices for now and set "needsTravelDates": true on the itinerary so the UI prompts the user to add dates for accurate pricing.\n`;
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)}${odBlock}${datesBlock}
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
}
],
"crossingOptions": [
{
"id": "unique-crossing-id",
"operator": "Eurotunnel Le Shuttle" | "DFDS" | "P&O Ferries" | "Brittany Ferries" | "Stena Line" | "Irish Ferries" | "Other",
"mode": "tunnel" | "ferry",
"fromPort": "Folkestone, UK",
"toPort": "Coquelles (Calais), FR",
"durationMin": 35,
"priceEur": 180,
"frequency": "every 30 min, 24/7",
"pros": ["Fastest", "Drive on/off, no walking"],
"cons": ["Most expensive"],
"badge": "Fastest" | "Cheapest" | "Most scenic" | "Overnight" | "Frequent" | null,
"detourMin": 0,
"detourKm": 0,
"isCurrent": true,
"bookingUrl": "https://www.eurotunnel.com"
}
]
}
]
}
],
"summary": {
"totalDistanceKm": 650,
"estDriveHours": 10.5,
"estChargeHours": 1.5,
"superchargers": 3,
"hotels": 1,
"highlights": ["Lunch at Tebay services", "Sunset view at Crummock Water"]
},
"needsTravelDates": true
},
"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.
- THE FIRST STOP MUST BE THE EXACT ORIGIN THE USER GAVE. THE LAST STOP MUST BE THE EXACT DESTINATION THE USER GAVE. Never start the trip in a different city, country, port or service station. If the user said "from MK78PJ" or "from Milton Keynes", the very first stop must be at that postcode/town with type "custom" (or "supercharger"/"hotel" if it genuinely is one), real lat/lng, and a name like "Start · Milton Keynes (MK7 8PJ)".
- UK postcodes (e.g. MK7 8PJ, SW1A 1AA, EH1 1YZ) are valid starting points. Geocode them precisely — the first letters are the postal area (MK = Milton Keynes, SW = London SW, EH = Edinburgh). Do not skip the UK leg of the trip just because chargers are sparse there.
- For UK → mainland Europe trips: include the UK departure point (e.g. Folkestone Eurotunnel, Dover ferry, or Hull ferry) and the corresponding mainland arrival point (Calais/Coquelles, Dunkirk, Rotterdam, etc.) as explicit stops. Note the crossing in the description and add a sensible duration. Do NOT begin the itinerary in Calais — that erases the UK side of the journey.
- For mainland Europe → UK trips: same but in reverse.
- 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.
Travel dates & pricing:
- If the user has NOT provided travel dates, set "needsTravelDates": true on the itinerary object. In "message", briefly mention that adding dates will sharpen the crossing/ferry and hotel pricing. Use moderate off-peak prices in the meantime.
- If dates ARE provided, set "needsTravelDates": false and lean prices to the right tier (weekend, school holidays, peak summer, etc.) and mention that in "message" where relevant.
- Hotel prices in "description" can include a rough nightly rate when known ("Premier Inn from £75/night on those dates"). Don't fabricate exact prices for specific rooms.
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.
Sea crossings (REQUIRED for every UK ↔ mainland Europe trip, and any other route that crosses water):
- When the route includes a Channel crossing or any other sea/tunnel crossing, insert a dedicated stop with type "crossing" (use "tunnel" for Eurotunnel, "ferry" for ferries) at the appropriate point in the day's stops.
- That crossing stop MUST populate "crossingOptions" with 3-5 genuinely different real-world options the user could pick from. The currently-chosen one is duplicated as the first entry with isCurrent: true.
- For UK ↔ France: at minimum include Eurotunnel Le Shuttle (Folkestone→Coquelles), DFDS Dover→Calais, P&O Dover→Calais (or Dover→Dunkirk), and at least one longer/scenic option (DFDS Newhaven→Dieppe, Brittany Ferries Portsmouth→Caen/Le Havre/Cherbourg, or Brittany Ferries Plymouth→Roscoff) when they make geographic sense for the route.
- For UK ↔ Netherlands/Belgium: include P&O Hull→Rotterdam, Stena Line Harwich→Hook of Holland, DFDS Newcastle→Amsterdam where appropriate.
- For UK ↔ Ireland: include Irish Ferries / Stena Line Holyhead→Dublin, Liverpool→Belfast/Dublin, Fishguard→Rosslare etc.
- "priceEur" should reflect a realistic ballpark for a Tesla with the given travellers. If travel dates are provided, lean toward the right pricing tier (weekday off-peak vs weekend peak vs school holidays). If no dates yet, use moderate off-peak pricing.
- "durationMin" is the crossing time itself (35 min for Eurotunnel, ~90 min for Dover-Calais ferry, ~14 h for an overnight Hull-Rotterdam, etc.).
- "frequency" is a one-line text description of departure cadence (e.g. "every 30 min, 24/7", "8 sailings/day", "1 overnight sailing/day").
- "badge" picks the trade-off: "Fastest" for Eurotunnel, "Cheapest" for the cheapest sensible ferry, "Most scenic" for routes that swap a few hours of UK driving for a longer but prettier crossing (e.g. Portsmouth→Caen), "Overnight" for sleeper ferries (frees up a hotel night), "Frequent" for high-cadence operators.
- "detourMin" and "detourKm" express the change in TOTAL trip distance + drive time vs the current chosen crossing (positive = adds, negative = saves). E.g. Portsmouth→Caen vs Dover→Calais might save 200 km of French driving but add 150 km of UK driving and a 6 h crossing.
- Always set a sensible "bookingUrl" (operator's main booking page).
- If the user already picked a crossing, keep its choice as isCurrent: true and adjust the rest of the itinerary's routing accordingly.
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', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}): Promise<GrokResponse> {
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, opts);
}
if (activeProvider === 'fallback') {
return this.dumbFallback(messages, requestId);
}
// LOCAL PERSONAL GROK CLI
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant, opts);
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<string>((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, opts);
}
return this.dumbFallback(messages, requestId);
} finally {
await rm(tmp, { recursive: true, force: true }).catch(() => {});
}
}
/**
* Streaming chat — yields incremental partial itineraries as Grok produces output.
* Falls back to non-streaming if local CLI is unavailable.
*/
async *chatStream(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}): AsyncGenerator<StreamEvent> {
const requestId = crypto.randomUUID().slice(0, 8);
log.info({ requestId, vehicle: vehicleName(vehicle), selectedVariant }, '=== NEW STREAMING CHAT REQUEST ===');
const activeProvider = await this.getActiveProvider(requestId);
if (activeProvider !== 'local') {
// No real streaming for xAI/fallback yet — just do the regular call and emit a single done event
yield { type: 'thinking', message: 'Asking Grok…' };
const result = activeProvider === 'xai'
? await this.callXaiApi(messages, itinerary, vehicle, requestId, selectedVariant, opts)
: await this.dumbFallback(messages, requestId);
yield {
type: 'done',
text: result.text,
itinerary: result.updatedItinerary,
variants: result.variants,
selectedVariant: result.selectedVariant ?? selectedVariant,
};
return;
}
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant, opts);
const tmp = await mkdtemp(join(tmpdir(), 'grok-eu-stream-'));
const disallowed = env.nodeEnv === 'development'
? 'search_replace,write_file,Agent,run_terminal_cmd'
: 'run_terminal_cmd,search_replace,write_file,Agent';
const args = [
'-p', prompt,
'--output-format', 'streaming-json',
'--yolo',
'--disallowed-tools', disallowed,
'--tools', 'web_search,web_fetch',
'--max-turns', '6',
'--cwd', tmp,
];
log.info({ requestId }, 'Spawning grok with streaming-json output');
const child = spawn(env.grokBin, args, {
cwd: tmp,
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
type LineEvent = { type: string; data?: string; message?: string };
const lineQueue: LineEvent[] = [];
const errorChunks: string[] = [];
let lineBuffer = '';
let textBuffer = '';
let lastParseLen = 0;
let lastEmittedStops = 0;
let lastEmittedDays = 0;
let thoughtBuffer = '';
let lastEmittedThought = '';
let lastEmittedThoughtLen = 0;
let closed = false;
let closeCode: number | null = null;
let waker: (() => void) | null = null;
const pushLine = (raw: string) => {
if (!raw) return;
try {
const ev = JSON.parse(raw) as LineEvent;
lineQueue.push(ev);
if (waker) { const w = waker; waker = null; w(); }
} catch (e) {
log.warn({ requestId, raw: raw.slice(0, 200) }, 'Failed to parse grok stream line');
}
};
child.stdout.on('data', (chunk: Buffer) => {
lineBuffer += chunk.toString('utf8');
let nl: number;
while ((nl = lineBuffer.indexOf('\n')) !== -1) {
const line = lineBuffer.slice(0, nl).trim();
lineBuffer = lineBuffer.slice(nl + 1);
if (line) pushLine(line);
}
});
child.stderr.on('data', (chunk: Buffer) => { errorChunks.push(chunk.toString('utf8')); });
child.on('error', (err) => {
log.error({ requestId, err: String(err) }, 'grok child spawn error');
errorChunks.push(`spawn error: ${err}`);
closed = true;
closeCode = -1;
if (waker) { const w = waker; waker = null; w(); }
});
child.on('close', (code, signal) => {
if (lineBuffer.trim()) pushLine(lineBuffer.trim());
log.info({ requestId, code, signal, partialsEmitted: lastEmittedStops > 0 ? `${lastEmittedStops} stops` : 'none', bufferLen: textBuffer.length }, 'grok stream complete');
closed = true;
closeCode = code ?? 0;
if (waker) { const w = waker; waker = null; w(); }
});
const waitForLine = () => new Promise<void>((resolve) => {
if (lineQueue.length > 0 || closed) return resolve();
waker = resolve;
});
try {
yield { type: 'thinking', message: 'Connected to Grok — composing itinerary…' };
while (true) {
if (lineQueue.length === 0) {
if (closed) break;
await waitForLine();
continue;
}
const ev = lineQueue.shift()!;
if (ev.type === 'text' && typeof ev.data === 'string') {
textBuffer += ev.data;
// Parse every ~120 chars to keep CPU sane while still catching new stops fast
if (textBuffer.length - lastParseLen > 120) {
lastParseLen = textBuffer.length;
const partial = tryPartialJsonParse(textBuffer);
if (partial && partial.itinerary && Array.isArray(partial.itinerary.days)) {
const stopCount = partial.itinerary.days.reduce(
(sum: number, d: any) => sum + (Array.isArray(d?.stops)
? d.stops.filter((s: any) => s && typeof s.name === 'string' && typeof s.lat === 'number' && typeof s.lng === 'number').length
: 0),
0,
);
const dayCount = partial.itinerary.days.length;
if (stopCount > lastEmittedStops || dayCount > lastEmittedDays) {
log.debug({ requestId, stopCount, dayCount, bufLen: textBuffer.length }, 'emitting partial');
lastEmittedStops = stopCount;
lastEmittedDays = dayCount;
yield {
type: 'partial',
itinerary: partial.itinerary,
variants: Array.isArray(partial.variants) ? partial.variants : undefined,
message: partial.message,
};
}
}
}
} else if (ev.type === 'thought' && typeof ev.data === 'string') {
thoughtBuffer += ev.data;
// Emit on newline boundaries or when we've buffered enough; show the last
// line trimmed and capped so the UI gets a steady stream of short snippets.
const lastNewline = thoughtBuffer.lastIndexOf('\n');
const shouldEmit = lastNewline >= 0 || thoughtBuffer.length - lastEmittedThoughtLen > 200;
if (shouldEmit) {
const tail = lastNewline >= 0 ? thoughtBuffer.slice(lastNewline + 1) : thoughtBuffer;
const snippet = (tail || thoughtBuffer).trim().replace(/\s+/g, ' ').slice(-220);
if (snippet && snippet !== lastEmittedThought) {
lastEmittedThought = snippet;
lastEmittedThoughtLen = thoughtBuffer.length;
yield { type: 'thinking', message: snippet };
}
if (lastNewline >= 0) thoughtBuffer = thoughtBuffer.slice(lastNewline + 1);
}
} else if (ev.type === 'error') {
log.error({ requestId, msg: ev.message }, 'grok streaming error event');
yield { type: 'error', error: ev.message || 'Grok stream error' };
}
}
if (closeCode !== 0) {
log.error({ requestId, closeCode, stderr: errorChunks.join('').slice(-400) }, 'grok stream exited non-zero');
yield { type: 'error', error: `grok exited with code ${closeCode}` };
return;
}
const final = this.parseGrokResponse(textBuffer);
yield {
type: 'done',
text: final.text,
itinerary: final.itinerary,
variants: final.variants,
selectedVariant,
};
} catch (err) {
log.error({ requestId, err: String(err) }, 'grok stream crashed');
yield { type: 'error', error: String(err) };
} finally {
try { if (!child.killed) child.kill(); } catch { /* ignore */ }
await rm(tmp, { recursive: true, force: true }).catch(() => {});
}
}
private async callXaiApi(messages: ChatMessage[], itinerary: any, vehicle: VehicleInput, requestId: string, selectedVariant: string = 'fast', opts: { origin?: string; destination?: string; travelDates?: { outbound?: string | null; return?: string | null; travellers?: number } } = {}): Promise<GrokResponse> {
const prompt = this.buildPrompt(messages, itinerary, vehicle, selectedVariant, opts);
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<GrokResponse> {
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<boolean> {
try { await access(env.grokBin); return true; } catch { return false; }
}
}
export const grok = new GrokHeadlessClient();