diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz
index c1ead49..7288008 100644
Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..233beab
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,30 @@
+# OpenSquawk - Project Guide
+
+## Architecture
+- **Nuxt 4** (Vue 3 SFC) frontend in `/app`
+- **H3 server** handlers in `/server`
+- **Shared types/utils** in `/shared`
+- MongoDB models in `/server/models`
+
+## Key Files
+- `/shared/utils/communicationsEngine.ts` — Core state machine composable (used by `/pm` live ATC)
+- `/server/utils/openai.ts` — LLM decision router (`routeDecision()`)
+- `/server/services/decisionFlowService.ts` — Builds runtime decision trees from MongoDB
+- `/app/pages/pm.vue` — Live ATC page (speech-to-text, PTT, text input)
+- `/app/pages/classroom.vue` — Classroom learning mode (separate system, does NOT use communicationsEngine)
+
+## Live ATC Flow (/pm)
+1. User inputs (PTT or text) → `handlePilotTransmission()`
+2. `processPilotTransmission()` logs the pilot message
+3. `buildLLMContext()` builds candidates from `nextCandidates`
+4. POST `/api/llm/decide` → `routeDecision()` selects next state
+5. `applyLLMDecision()` moves to next state, updates vars/flags
+6. `collectAtcStatesUntilPilotTurn()` advances through ATC/system states
+7. Each ATC `say_tpl` is spoken via TTS (`scheduleControllerSpeech`)
+
+## Decision Tree States
+States have `role: 'pilot' | 'atc' | 'system'`. ATC states have `say_tpl` (what controller says). Pilot states have `utterance_tpl` (expected pilot response). Transitions: `next`, `ok_next`, `bad_next`, `timer_next`.
+
+## Commands
+- `bun run dev` — dev server
+- Decision trees are stored in MongoDB and fetched via `/api/decision-flows/runtime`
diff --git a/app/pages/pm.vue b/app/pages/pm.vue
index bf80f91..4730e0f 100644
--- a/app/pages/pm.vue
+++ b/app/pages/pm.vue
@@ -474,6 +474,25 @@
append-inner-icon="mdi-send"
@click:append-inner="sendPilotText"
/>
+
+
+
+
Suggested phrases
+
+
+ {{ phrase.text }}
+
+
+
+
For emergencies when PTT fails or for testing
@@ -997,7 +1016,9 @@ const {
moveTo: forceMove,
normalizeATCText,
renderATCMessage,
- getStateDetails
+ getStateDetails,
+ collectAtcStatesUntilPilotTurn,
+ expectedPilotPhrases,
} = engine
const lastTransmission = ref('')
@@ -1892,9 +1913,27 @@ const handlePilotTransmission = async (message: string, source: 'text' | 'ptt' =
applyLLMDecision(decision, normalizedTrace ?? null)
+ // If decision explicitly has controller_say_tpl, speak it
if (decision.controller_say_tpl && !decision.radio_check) {
scheduleControllerSpeech(decision.controller_say_tpl)
}
+
+ // Auto-advance through ATC/system states and speak any say_tpl messages.
+ // This is the key fix: after a decision moves us to a new state, we need
+ // to walk through all non-pilot states (ATC replies, system transitions)
+ // until we reach the next pilot state, speaking each ATC message via TTS.
+ if (!decision.radio_check) {
+ await nextTick()
+ const atcMessages = collectAtcStatesUntilPilotTurn()
+ for (const msg of atcMessages) {
+ // Don't double-speak if the decision already had controller_say_tpl
+ // for this exact template
+ if (decision.controller_say_tpl && msg.say_tpl === decision.controller_say_tpl) {
+ continue
+ }
+ scheduleControllerSpeech(msg.say_tpl)
+ }
+ }
} catch (e) {
console.error('LLM decision failed', e)
setLastTransmission(`${prefix}: ${transcript} (LLM failed)`)
@@ -1951,6 +1990,18 @@ const startMonitoring = async (flightPlan: any) => {
}
await fetchAirportFrequencies(flightPlan.dep || flightPlan.departure)
+
+ // If the start state is an ATC state, auto-advance and speak its message
+ // so the pilot sees the first prompt immediately after connecting.
+ try {
+ await nextTick()
+ const startMessages = collectAtcStatesUntilPilotTurn()
+ for (const msg of startMessages) {
+ scheduleControllerSpeech(msg.say_tpl)
+ }
+ } catch (err) {
+ console.warn('Initial state advance failed:', err)
+ }
}
const startDemoFlight = () => {
diff --git a/server/utils/openai.ts b/server/utils/openai.ts
index 0ed51df..a0e0005 100644
--- a/server/utils/openai.ts
+++ b/server/utils/openai.ts
@@ -175,6 +175,42 @@ function buildSpokenVariants(key: string, value: string): string[] {
return Array.from(variants)
}
+/**
+ * Quick heuristic readback check: verifies that the pilot's utterance
+ * contains the required fields (dest, runway, squawk, etc.) by matching
+ * against spoken variants of the expected values.
+ * Returns 'ok' if all required fields are present, 'missing' with the
+ * list of missing keys otherwise.
+ */
+function quickReadbackCheck(
+ utterance: string,
+ readbackKeys: string[],
+ variables: Record
+): { status: 'ok' | 'missing'; missing: string[] } {
+ if (!readbackKeys.length) return { status: 'ok', missing: [] }
+
+ const sanitized = sanitizeForQuickMatch(utterance)
+ const missing: string[] = []
+
+ for (const key of readbackKeys) {
+ const expected = resolveReadbackValue(key, { variables } as any)
+ if (!expected) continue // Can't verify if no expected value
+
+ const variants = buildSpokenVariants(key, expected)
+ const found = variants.some(variant =>
+ sanitized.includes(sanitizeForQuickMatch(variant))
+ )
+ if (!found) {
+ missing.push(key)
+ }
+ }
+
+ return {
+ status: missing.length === 0 ? 'ok' : 'missing',
+ missing,
+ }
+}
+
function pickTransition(
transitions: Array<{ to: string }> | undefined,
candidates: Array<{ id: string; state: any }>
@@ -529,13 +565,10 @@ async function prepareDecisionCandidates(
}
}
- for (const [flowSlug, tree] of Object.entries(system.flows || {})) {
- const startStateId = tree.start_state
- if (!startStateId) continue
- const indexed = index.get(startStateId)
- const state = indexed?.state ? { ...indexed.state } : tree.states?.[startStateId]
- addCandidate(startStateId, flowSlug, state)
- }
+ // Note: Previously, all flow start states were added as candidates here.
+ // This was removed because it polluted the candidate pool and caused the
+ // LLM to pick unrelated flow starts. Flow switches should be defined via
+ // explicit transitions in the decision tree instead.
const candidates = Array.from(candidateMap.values())
const context = { variables: input.variables || {}, flags: input.flags || {} }
@@ -906,9 +939,21 @@ function extractJsonObject(text: string): any | null {
}
}
+function buildDecisionObject(stateId: string, candidate: DecisionCandidate | undefined, index: Map): LLMDecisionResult['decision'] {
+ const decision: LLMDecisionResult['decision'] = { next_state: stateId }
+ // Attach the say_tpl from the chosen state so the frontend can speak it
+ // without an extra lookup. Checks the candidate first, then the runtime index.
+ const sayTpl = candidate?.state?.say_tpl ?? index.get(stateId)?.state?.say_tpl
+ if (sayTpl) {
+ decision.controller_say_tpl = sayTpl
+ }
+ return decision
+}
+
export async function routeDecision(input: LLMDecisionInput): Promise {
const utterance = (input.pilot_utterance || '').trim()
const prepared = await prepareDecisionCandidates(input, utterance)
+ const { index } = await getRuntimeSystemIndex()
const trace: LLMDecisionTrace = {
calls: [],
@@ -916,6 +961,34 @@ export async function routeDecision(input: LLMDecisionInput): Promise 0 && utterance) {
+ const check = quickReadbackCheck(utterance, readbackKeys, input.variables || {})
+ if (check.status === 'missing' && check.missing.length > 0) {
+ // Readback incomplete — try to route to bad_next (repeat instruction)
+ const badTargets = (input.state?.bad_next ?? []).map((t: any) => t?.to).filter(Boolean)
+ const badCandidate = badTargets.length > 0
+ ? prepared.candidateIndex.get(badTargets[0]) ?? null
+ : null
+ if (badCandidate) {
+ trace.autoSelection = {
+ id: badCandidate.id,
+ flow: badCandidate.flow,
+ reason: `Readback missing fields: ${check.missing.join(', ')}`,
+ }
+ return {
+ decision: buildDecisionObject(badCandidate.id, badCandidate, index),
+ trace,
+ pilot_intent: 'incomplete_readback',
+ }
+ }
+ }
+ }
+
if (prepared.autoSelected) {
trace.autoSelection = {
id: prepared.autoSelected.id,
@@ -923,7 +996,7 @@ export async function routeDecision(input: LLMDecisionInput): Promise ({ id, state: states.value[id], flow: runtime.slug }))
.filter(candidate => candidate.state)
+ // "Look through" auto-behavior check states (e.g. CD_READBACK_CHECK).
+ // If ALL candidates are check_readback/monitor states, expand them to
+ // their ok_next + bad_next targets so the LLM can evaluate the pilot's
+ // readback directly and route to the correct outcome.
+ const allAutoCheck = candidates.length > 0 && candidates.every(c =>
+ c.state.auto === 'check_readback' || c.state.auto === 'monitor'
+ )
+ if (allAutoCheck) {
+ const expanded: typeof candidates = []
+ const seen = new Set()
+ for (const c of candidates) {
+ const targets = [
+ ...(c.state.ok_next ?? []),
+ ...(c.state.bad_next ?? []),
+ ...(c.state.next ?? []),
+ ]
+ for (const t of targets) {
+ if (!t?.to || seen.has(t.to)) continue
+ seen.add(t.to)
+ const targetState = states.value[t.to]
+ if (targetState) {
+ expanded.push({ id: t.to, state: targetState, flow: runtime.slug })
+ }
+ }
+ }
+ if (expanded.length > 0) {
+ candidates = expanded
+ }
+ }
+
return {
state_id: s.id,
state: { ...s },
@@ -895,6 +925,133 @@ export default function useCommunicationsEngine() {
return processPilotTransmission(transcript)
}
+ /**
+ * After a decision lands on a state, walk forward through all non-pilot
+ * states (ATC replies, system checks, handoffs) collecting ATC messages
+ * that need TTS playback, until we reach the next pilot state.
+ *
+ * This is the core auto-advance mechanism for the live ATC flow.
+ * It handles:
+ * - ATC states with say_tpl (collect for TTS, advance)
+ * - System states (check_readback, monitor, etc) — advance via ok_next
+ * - Handoff states (advance automatically)
+ * - States with single unambiguous transitions
+ */
+ function collectAtcStatesUntilPilotTurn(maxHops = 30): Array<{ stateId: string; say_tpl: string; rendered: string; normalized: string }> {
+ const messages: Array<{ stateId: string; say_tpl: string; rendered: string; normalized: string }> = []
+ const visited = new Set()
+ let hops = 0
+
+ while (hops++ < maxHops) {
+ const s = currentState.value
+ if (!s) break
+
+ // Prevent infinite loops
+ if (visited.has(s.id)) break
+ visited.add(s.id)
+
+ // If we're on a pilot state, stop — it's the pilot's turn to speak
+ if (s.role === 'pilot') break
+
+ // If this is an end state, stop
+ const endStates = tree.value?.end_states ?? []
+ if (endStates.includes(s.id)) break
+
+ // Collect ATC/system messages for TTS
+ if (s.say_tpl) {
+ messages.push({
+ stateId: s.id,
+ say_tpl: s.say_tpl,
+ rendered: renderTpl(s.say_tpl, exposeCtx()),
+ normalized: normalizeATCText(s.say_tpl, exposeCtxFlat()),
+ })
+ }
+
+ // Determine the next state to advance to.
+ // Strategy:
+ // 1. For auto-behavior states (check_readback, monitor, pop_stack),
+ // prefer ok_next (assume success for auto-advance)
+ // 2. For states with a single eligible transition, take it
+ // 3. For ambiguous states (multiple eligible), stop
+ let nextId: string | null = null
+
+ const auto = s.auto
+ if (auto === 'check_readback' || auto === 'monitor' || auto === 'pop_stack_or_route_by_intent') {
+ // Auto-behavior states: prefer ok_next, then next
+ const okTransitions = (s.ok_next ?? []).filter(t => {
+ if (!t?.to) return false
+ if (t.when && !evaluateConditionExpression(t.when)) return false
+ if (t.guard && !evaluateConditionExpression(t.guard)) return false
+ return true
+ })
+ if (okTransitions.length > 0) {
+ nextId = okTransitions[0].to
+ }
+ }
+
+ if (!nextId) {
+ // Collect all eligible transitions
+ const allTransitions = [
+ ...(s.next ?? []),
+ ...(s.ok_next ?? []),
+ ] as Array<{ to?: string; when?: string; guard?: string }>
+
+ const eligible = allTransitions.filter(t => {
+ if (!t?.to) return false
+ if (t.when && !evaluateConditionExpression(t.when)) return false
+ if (t.guard && !evaluateConditionExpression(t.guard)) return false
+ return true
+ })
+
+ // Only advance if there's exactly one unambiguous path
+ if (eligible.length === 1) {
+ nextId = eligible[0].to!
+ }
+ }
+
+ if (!nextId || !states.value[nextId]) break
+
+ // Advance to the next state (moveTo handles logging, actions, handoffs)
+ moveTo(nextId)
+ }
+
+ return messages
+ }
+
+ /**
+ * Computed: expected pilot phrases for the current state.
+ * If we're on a pilot state, returns that state's utterance_tpl rendered.
+ * If we're on an ATC state, looks at the next candidates that are pilot states.
+ */
+ const expectedPilotPhrases = computed>(() => {
+ const s = currentState.value
+ if (!s) return []
+
+ // If current state IS a pilot state with utterance_tpl, show it
+ if (s.role === 'pilot' && s.utterance_tpl) {
+ return [{
+ stateId: s.id,
+ text: renderTpl(s.utterance_tpl, exposeCtx()),
+ normalized: normalizeATCText(s.utterance_tpl, exposeCtxFlat()),
+ }]
+ }
+
+ // Otherwise look at next candidates that are pilot states
+ const results: Array<{ stateId: string; text: string; normalized: string }> = []
+ for (const id of nextCandidates.value) {
+ const state = states.value[id]
+ if (!state) continue
+ if (state.role === 'pilot' && state.utterance_tpl) {
+ results.push({
+ stateId: id,
+ text: renderTpl(state.utterance_tpl, exposeCtx()),
+ normalized: normalizeATCText(state.utterance_tpl, exposeCtxFlat()),
+ })
+ }
+ }
+ return results
+ })
+
function resolveTelemetryValue(parameter: string) {
const value = (telemetry.value as any)[parameter]
if (value !== undefined) return value
@@ -1256,6 +1413,8 @@ export default function useCommunicationsEngine() {
processUserTransmission,
buildLLMContext,
applyLLMDecision,
+ collectAtcStatesUntilPilotTurn,
+ expectedPilotPhrases,
// Flow Control
moveTo,