Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "[ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\"},\"systemMessage\":\"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files.\"}' || true"
}
]
}
]
}
}
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,12 @@ If there is tension between:
- and a cleaner product seam

choose the cleaner seam.

## graphify

If `graphify-out/` exists for this project, treat it as the preferred map before falling back to raw-file search.

Rules:
- Before answering architecture or codebase questions, read `graphify-out/GRAPH_REPORT.md` when it exists for god nodes and community structure.
- If `graphify-out/wiki/index.md` exists, navigate it instead of reading raw files.
- After modifying code files, refresh the graph only when graphify output already exists or graphify is actively being used for this repo.
30 changes: 26 additions & 4 deletions apps/api-edge/src/routes/concierge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,11 +572,19 @@ conciergeRouter.post('/intent', async (c) => {
: '';

// ── Family / group context ────────────────────────────────────────────────
const requestedTravellers = travelProfile?.requestedTravellers as Array<{
name: string;
relationship: 'adult' | 'child' | 'infant';
}> | undefined;
const familyMembers = travelProfile?.familyMembers as Array<{
id: string; name: string; relationship: string;
dateOfBirth?: string; railcard?: string; documentNumber?: string; documentExpiry?: string; nationality?: string;
}> | undefined;

const requestedTravellerContext = requestedTravellers && requestedTravellers.length > 0
? `\nRequested party for this trip: ${requestedTravellers.map((traveller) => `${traveller.name} (${traveller.relationship})`).join('; ')}. Only plan and price for this party. Do not silently expand to every saved family member unless the customer explicitly asked for that larger group.`
: '\nRequested party for this trip: device owner only unless the customer explicitly asked for a companion or shared trip.';

const familyContext = familyMembers && familyMembers.length > 0
? (() => {
const lines = familyMembers.map(m => {
Expand All @@ -593,11 +601,11 @@ conciergeRouter.post('/intent', async (c) => {
const adultCount = 1 + familyMembers.filter(m => m.relationship === 'adult').length;
const childCount = familyMembers.filter(m => m.relationship === 'child').length;
const hasFamilyRailcard = adultCount >= 2 && childCount >= 1 && childCount <= 4;
return `\nUser's family: ${lines.join('; ')}.${hasFamilyRailcard ? ' Family & Friends Railcard applies — 1/3 off adult fares, 60% off child fares. Apply automatically and mention the saving.' : ''}`;
return `\nSaved family memory: ${lines.join('; ')}.${hasFamilyRailcard ? ' Family & Friends Railcard applies — 1/3 off adult fares, 60% off child fares when the requested party qualifies. Apply it only when the active travellers fit.' : ''}`;
})()
: '';

const systemPrompt = `You are Ace — a travel fixer, not an assistant.${locationContext}${nationalityContext}${railcardContext}${indiaClassContext}${subscriptionContext}${familyContext}
const systemPrompt = `You are Ace — a travel fixer, not an assistant.${locationContext}${nationalityContext}${railcardContext}${indiaClassContext}${subscriptionContext}${requestedTravellerContext}${familyContext}
You've worked every booking desk on earth and left. You know UK railcards, IRCTC tatkal quotas, off-peak windows, coach classes, waitlists. You get things done quietly and tell people after.

CHARACTER:
Expand Down Expand Up @@ -809,12 +817,26 @@ PHASE 2 CONFIRMATION FORMAT (when hire result arrives):
}];
}

// Add family members as additional passengers
const requestedParty = travelProfile?.requestedTravellers as Array<{
name: string;
relationship: 'adult' | 'child' | 'infant';
}> | undefined;

const requestedAdditionalNames = new Set(
(requestedParty ?? [])
.map((traveller) => traveller.name?.trim().toLowerCase())
.filter((name) => name && name !== 'you'),
);

// Add only the explicitly requested saved family members as additional passengers
const familyMems = travelProfile?.familyMembers as Array<{
name: string; relationship: string;
dateOfBirth?: string; documentNumber?: string; documentExpiry?: string; nationality?: string;
}> | undefined;
const additionalPassengers: DuffelPassenger[] = (familyMems ?? []).map((m) => {
const selectedFamilyMems = requestedAdditionalNames.size > 0
? (familyMems ?? []).filter((member) => requestedAdditionalNames.has(member.name.trim().toLowerCase()))
: [];
const additionalPassengers: DuffelPassenger[] = selectedFamilyMems.map((m) => {
const mParts = m.name.trim().split(/\s+/);
const p: DuffelPassenger = {
given_name: mParts[0] ?? m.name,
Expand Down
13 changes: 11 additions & 2 deletions apps/api-edge/src/routes/paymentsSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
* GET /api/payments/methods/:principalId — list saved payment methods
* DELETE /api/payments/methods/:methodId — remove a payment method
*
* Auth: all routes require a valid merchant API key (authenticateApiKey).
* Auth:
* - first-party Ace app via x-bro-key
* - merchant API key via authenticateApiKey
*
* Flow:
* 1. Client calls POST /setup-intent → gets clientSecret
Expand Down Expand Up @@ -100,7 +102,14 @@ async function ensureStripeCustomer(stripe: Stripe, sql: ReturnType<typeof impor

const router = new Hono<{ Bindings: Env; Variables: Variables }>();

router.use('*', authenticateApiKey);
router.use('*', async (c, next) => {
const broKey = c.req.header('x-bro-key') ?? '';
if (c.env.BRO_CLIENT_KEY && broKey === c.env.BRO_CLIENT_KEY) {
await next();
return;
}
return authenticateApiKey(c, next);
});

// ---------------------------------------------------------------------------
// POST /setup-intent — Create a Stripe Setup Intent
Expand Down
209 changes: 209 additions & 0 deletions apps/api-edge/src/routes/voice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,145 @@ export const voiceRouter = new Hono<{ Bindings: Env; Variables: Variables }>();
// Daniel (onwK4e9ZLuTAKqWW03F9) — deeper/more formal, but noticeably slower to first byte.
const DEFAULT_ELEVENLABS_VOICE_ID = 'JBFqnCBsd6RMkjVDRZzb';

type VoiceSessionMode = 'batch' | 'live';
type VoiceSessionProvider = 'batch_proxy' | 'livekit' | 'openai_realtime';

type VoiceSessionConnectResponse = {
mode: 'live';
provider: 'livekit';
serverUrl: string;
roomName: string;
participantIdentity: string;
participantToken: string;
expiresAt: string;
planningToolsAvailableDuringConversation: true;
bookingToolsLockedUntilConfirm: true;
};

function getLiveVoiceProvider(env: Env): VoiceSessionProvider | null {
if (env.LIVE_VOICE_RUNTIME_ENABLED !== 'true') {
return null;
}
if (env.LIVEKIT_URL && env.LIVEKIT_API_KEY && env.LIVEKIT_API_SECRET) {
return 'livekit';
}
if (env.OPENAI_API_KEY && env.OPENAI_REALTIME_MODEL) {
return 'openai_realtime';
}
return null;
}

function base64UrlEncode(input: string | Uint8Array): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : input;
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}

async function signHs256(secret: string, data: string): Promise<string> {
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
return base64UrlEncode(new Uint8Array(signature));
}

function slugPart(value: string, fallback: string): string {
const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return normalized.slice(0, 32) || fallback;
}

async function createLiveKitParticipantToken(params: {
apiKey: string;
apiSecret: string;
roomName: string;
participantIdentity: string;
metadata?: Record<string, unknown>;
ttlSeconds?: number;
}): Promise<{ token: string; expiresAt: string }> {
const now = Math.floor(Date.now() / 1000);
const ttlSeconds = params.ttlSeconds ?? 60 * 60;
const exp = now + ttlSeconds;
const header = { alg: 'HS256', typ: 'JWT' };
const payload = {
iss: params.apiKey,
sub: params.participantIdentity,
nbf: now - 10,
exp,
video: {
room: params.roomName,
roomJoin: true,
canPublish: true,
canSubscribe: true,
canPublishData: true,
},
metadata: params.metadata ? JSON.stringify(params.metadata) : undefined,
};
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signature = await signHs256(params.apiSecret, `${encodedHeader}.${encodedPayload}`);
return {
token: `${encodedHeader}.${encodedPayload}.${signature}`,
expiresAt: new Date(exp * 1000).toISOString(),
};
}

function getVoiceSessionManifest(env: Env): {
mode: VoiceSessionMode;
transport: 'http' | 'webrtc';
provider: VoiceSessionProvider;
ready: boolean;
planningToolsAvailableDuringConversation: boolean;
bookingToolsLockedUntilConfirm: boolean;
supportsInterruptions: boolean;
supportsServerVad: boolean;
premiumVoice: 'elevenlabs' | 'system' | 'none';
fallback: { stt: 'whisper_proxy'; tts: 'elevenlabs_http' | 'none' };
diagnostics: string[];
} {
const liveProvider = getLiveVoiceProvider(env);
const diagnostics: string[] = [];

if (env.LIVE_VOICE_RUNTIME_ENABLED !== 'true') {
diagnostics.push('live_runtime_disabled');
} else if (!liveProvider) {
diagnostics.push('live_runtime_not_configured');
}

if (!env.LIVEKIT_URL || !env.LIVEKIT_API_KEY || !env.LIVEKIT_API_SECRET) {
diagnostics.push('livekit_credentials_missing');
}

if (!env.OPENAI_REALTIME_MODEL) {
diagnostics.push('openai_realtime_model_missing');
}

if (!env.ELEVENLABS_API_KEY) {
diagnostics.push('elevenlabs_unavailable');
}

return {
mode: liveProvider ? 'live' : 'batch',
transport: liveProvider ? 'webrtc' : 'http',
provider: liveProvider ?? 'batch_proxy',
ready: !!liveProvider,
planningToolsAvailableDuringConversation: true,
bookingToolsLockedUntilConfirm: true,
supportsInterruptions: !!liveProvider,
supportsServerVad: !!liveProvider,
premiumVoice: env.ELEVENLABS_API_KEY ? 'elevenlabs' : 'none',
fallback: {
stt: 'whisper_proxy',
tts: env.ELEVENLABS_API_KEY ? 'elevenlabs_http' : 'none',
},
diagnostics,
};
}

function broLog(event: string, data: Record<string, unknown>) {
console.log(
JSON.stringify({
Expand Down Expand Up @@ -87,6 +226,76 @@ function isCfWhisperHallucination(text: string): boolean {
return false;
}

voiceRouter.get('/session', async (c) => {
const unauthorized = requireBroClientKey(c);
if (unauthorized) return unauthorized;

return c.json(getVoiceSessionManifest(c.env));
});

voiceRouter.post('/session/connect', async (c) => {
const unauthorized = requireBroClientKey(c);
if (unauthorized) return unauthorized;

if (c.env.LIVE_VOICE_RUNTIME_ENABLED !== 'true') {
return c.json({ error: 'Live voice runtime is disabled' }, 503);
}

const { LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET } = c.env;
if (!LIVEKIT_URL || !LIVEKIT_API_KEY || !LIVEKIT_API_SECRET) {
return c.json({ error: 'Live voice session is not configured' }, 503);
}

let body: { hirerId?: string; sessionId?: string };
try {
body = await c.req.json();
} catch {
return c.json({ error: 'Invalid JSON body' }, 400);
}

const hirerId = body.hirerId?.trim();
if (!hirerId) {
return c.json({ error: 'hirerId required' }, 400);
}

const roomName = `ace-${slugPart(hirerId, 'guest')}-${slugPart(body.sessionId ?? Date.now().toString(36), 'session')}`;
const participantIdentity = `traveler-${slugPart(hirerId, 'guest')}`;
const { token, expiresAt } = await createLiveKitParticipantToken({
apiKey: LIVEKIT_API_KEY,
apiSecret: LIVEKIT_API_SECRET,
roomName,
participantIdentity,
metadata: {
hirerId,
sessionId: body.sessionId ?? null,
role: 'traveler',
planningToolsAvailableDuringConversation: true,
bookingToolsLockedUntilConfirm: true,
},
});

const response: VoiceSessionConnectResponse = {
mode: 'live',
provider: 'livekit',
serverUrl: LIVEKIT_URL,
roomName,
participantIdentity,
participantToken: token,
expiresAt,
planningToolsAvailableDuringConversation: true,
bookingToolsLockedUntilConfirm: true,
};

broLog('live_session_token_issued', {
hirerId: hirerId.slice(0, 12),
roomName,
participantIdentity,
expiresAt,
});

return c.json(response);
});

voiceRouter.post('/transcribe', async (c) => {
const unauthorized = requireBroClientKey(c);
if (unauthorized) return unauthorized;
Expand Down
12 changes: 12 additions & 0 deletions apps/api-edge/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,18 @@ export interface Env {
OPENAI_API_KEY?: string;
/** ElevenLabs API key — premium server-side TTS for Ace voice replies. */
ELEVENLABS_API_KEY?: string;
/** LiveKit websocket URL for persistent WebRTC voice sessions. */
LIVEKIT_URL?: string;
/** LiveKit API key used to mint session access tokens. */
LIVEKIT_API_KEY?: string;
/** LiveKit API secret paired with LIVEKIT_API_KEY. */
LIVEKIT_API_SECRET?: string;
/** Explicit gate for the live voice runtime. Keep false until an Ace participant actually joins rooms and publishes audio. */
LIVE_VOICE_RUNTIME_ENABLED?: string;
/** Optional OpenAI Realtime model name for live speech sessions. */
OPENAI_REALTIME_MODEL?: string;
/** Optional ElevenLabs voice/agent identifier for live voice rendering. */
ELEVENLABS_VOICE_ID?: string;
/** Google Gemini API key — opt-in paid tier for high-volume extraction. Get at aistudio.google.com. Enable billing to remove RPD limits. */
GEMINI_API_KEY?: string;
/** Firecrawl API key — enables markdown scraping for operators without first-party APIs. */
Expand Down
15 changes: 15 additions & 0 deletions apps/meridian/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"infoPlist": {
"NSMicrophoneUsageDescription": "Ace uses your microphone to capture voice requests.",
"NSSpeechRecognitionUsageDescription": "Ace uses speech recognition to understand your requests.",
"NSCameraUsageDescription": "Ace uses the camera only when you add travel documents or scan booking details.",
"NSFaceIDUsageDescription": "Ace uses Face ID to protect your stored credentials.",
"NSLocationWhenInUseUsageDescription": "Ace uses your location to find your nearest station and suggest quick routes.",
"ITSAppUsesNonExemptEncryption": false
Expand All @@ -102,6 +103,8 @@
"android.permission.USE_FINGERPRINT",
"android.permission.USE_BIOMETRIC",
"android.permission.RECORD_AUDIO",
"android.permission.BLUETOOTH",
"android.permission.BLUETOOTH_CONNECT",
"android.permission.RECEIVE_BOOT_COMPLETED",
"android.permission.VIBRATE",
"android.permission.ACCESS_COARSE_LOCATION",
Expand All @@ -120,6 +123,18 @@
"expo-av",
"expo-secure-store",
"expo-local-authentication",
[
"@livekit/react-native-expo-plugin",
{
"android": {
"audioType": "communication"
},
"ios": {
"enableMultitaskingCameraAccess": false
}
}
],
"@config-plugins/react-native-webrtc",
[
"expo-build-properties",
{
Expand Down
Loading