2ab2e8f327eabd68a52eb316dbe121f492237ef5

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Address code review findings

- Replace the shared 10s HTTP client timeout with per-call context deadlines: world turn 20s, compaction 60s, one-shot side tasks 10s, TTS/ASR 10s.
- Make the opening turn a model-generated area description (StartAdventure) fired by both the TUI and the server start endpoint, with no canned player action.
- Add a total-context guard that drops the oldest closed recaps when the full prompt exceeds budget, keeping at least one recap.
- Stop an in-flight mic capture and remove its temp WAV on TUI shutdown.

Diff

This diff is truncated to protect this page.

  1diff --git a/cmd/kaiwari/main.go b/cmd/kaiwari/main.go
  2index 9285f5e88c3445303c73110ee46726e08855383d..8dfe569e067f0bd4d389f3a2b57c36a0164074a1 100644
  3--- a/cmd/kaiwari/main.go
  4+++ b/cmd/kaiwari/main.go
  5@@ -39,7 +39,8 @@ func main() {
  6 	defer svc.Close()
  7 
  8 	state := game.NewState(svc.Brief)
  9-	orch := buildOrchestrator(svc, state)
 10+	orch, speechIn := buildOrchestrator(svc, state)
 11+	defer speechIn.Close()
 12 
 13 	m := ui.NewModel(state, orch, int(recordCap.Seconds()))
 14 	p := tea.NewProgram(m)
 15@@ -53,7 +54,7 @@ func main() {
 16 	}
 17 }
 18 
 19-func buildOrchestrator(svc *bootstrap.Services, state *game.State) *game.Orchestrator {
 20+func buildOrchestrator(svc *bootstrap.Services, state *game.State) (*game.Orchestrator, *adapters.SpeechInput) {
 21 	gameModel := &adapters.GameModel{Client: svc.Game}
 22 	compactor := &adapters.Compactor{Client: svc.Compaction}
 23 	judgeModel := &adapters.JudgeModel{Client: svc.Judge}
 24@@ -70,7 +71,8 @@ func buildOrchestrator(svc *bootstrap.Services, state *game.State) *game.Orchest
 25 		Player: tts.NewPlayer(),
 26 	}
 27 
 28-	return game.NewOrchestrator(state, gameModel, judgeModel, compactor, sheetModel, askModel, flashModel, speechIn, speechOut)
 29+	orch := game.NewOrchestrator(state, gameModel, judgeModel, compactor, sheetModel, askModel, flashModel, speechIn, speechOut)
 30+	return orch, speechIn
 31 }
 32 
 33 func fatalf(format string, args ...any) {
 34diff --git a/internal/adapters/flashcards.go b/internal/adapters/flashcards.go
 35index 9b473c1422369c0473e2a14ab7ad7af3d36b79f3..51fc02185ff891f650b215687111ee1717824851 100644
 36--- a/internal/adapters/flashcards.go
 37+++ b/internal/adapters/flashcards.go
 38@@ -24,6 +24,8 @@ type FlashcardModel struct {
 39 }
 40 
 41 func (m *FlashcardModel) Generate(ctx context.Context, instructions string) (int, error) {
 42+	ctx, cancel := context.WithTimeout(ctx, sideTaskTimeout)
 43+	defer cancel()
 44 	raw, err := m.Client.Generate(ctx, llm.BuildFlashcardMessages(instructions))
 45 	if err != nil {
 46 		return 0, err
 47diff --git a/internal/adapters/llm.go b/internal/adapters/llm.go
 48index 131e6327202aacfcf01dd2b4859e80f792866815..92a73f74bc67d2c0c72031eb45d2f55e11a046ff 100644
 49--- a/internal/adapters/llm.go
 50+++ b/internal/adapters/llm.go
 51@@ -19,6 +19,15 @@ import (
 52 // owns the terminal and direct stderr writes get mangled by redraws.
 53 const rawLogPath = "kaiwari_raw.log"
 54 
 55+// Per-call model timeouts, applied via context so a slow call cannot hang a turn
 56+// or exhaust a shared client deadline. Compaction reads long closed segments so it
 57+// gets the largest budget; world turns next; one-shot side tasks are fast.
 58+const (
 59+	gameTurnTimeout   = 20 * time.Second
 60+	compactionTimeout = 60 * time.Second
 61+	sideTaskTimeout   = 10 * time.Second // judge, sheet, scratch ask, flashcards
 62+)
 63+
 64 func dumpRaw(label, transcript, raw string) {
 65 	f, err := os.OpenFile(rawLogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
 66 	if err != nil {
 67@@ -35,6 +44,8 @@ type GameModel struct {
 68 }
 69 
 70 func (m *GameModel) Advance(ctx context.Context, msgs []llm.Message) (llm.GameReply, error) {
 71+	ctx, cancel := context.WithTimeout(ctx, gameTurnTimeout)
 72+	defer cancel()
 73 	raw, err := m.Client.Generate(ctx, msgs)
 74 	if err != nil {
 75 		return llm.GameReply{}, err
 76@@ -50,6 +61,8 @@ type Compactor struct {
 77 }
 78 
 79 func (m *Compactor) Summarize(ctx context.Context, location string, msgs []llm.Message) (string, error) {
 80+	ctx, cancel := context.WithTimeout(ctx, compactionTimeout)
 81+	defer cancel()
 82 	raw, err := m.Client.Generate(ctx, llm.BuildCompactionMessages(location, msgs))
 83 	if err != nil {
 84 		return "", err
 85@@ -66,6 +79,8 @@ type SheetModel struct {
 86 }
 87 
 88 func (m *SheetModel) GenerateSheet(ctx context.Context, brief, location, talk, intro, firstLine string) (llm.Sheet, error) {
 89+	ctx, cancel := context.WithTimeout(ctx, sideTaskTimeout)
 90+	defer cancel()
 91 	raw, err := m.Client.Generate(ctx, llm.BuildSheetMessages(brief, location, talk, intro, firstLine))
 92 	if err != nil {
 93 		return llm.Sheet{}, err
 94@@ -81,6 +96,8 @@ type JudgeModel struct {
 95 }
 96 
 97 func (m *JudgeModel) Judge(ctx context.Context, transcript string) (llm.JudgeResult, error) {
 98+	ctx, cancel := context.WithTimeout(ctx, sideTaskTimeout)
 99+	defer cancel()
100 	msgs := llm.BuildJudgeMessages(transcript)
101 	raw, err := m.Client.Generate(ctx, msgs)
102 	if err != nil {
103@@ -97,6 +114,8 @@ type ScratchModel struct {
104 }
105 
106 func (m *ScratchModel) Ask(ctx context.Context, question string) (llm.ScratchAnswer, error) {
107+	ctx, cancel := context.WithTimeout(ctx, sideTaskTimeout)
108+	defer cancel()
109 	raw, err := m.Client.Generate(ctx, llm.BuildScratchMessages(question))
110 	if err != nil {
111 		return llm.ScratchAnswer{}, err
112diff --git a/internal/adapters/speech.go b/internal/adapters/speech.go
113index 10031c1260706364e3c3c88c02bf35e9e2049c7b..74517b9f92ee5f29d2d30aa8de61dd6429684569 100644
114--- a/internal/adapters/speech.go
115+++ b/internal/adapters/speech.go
116@@ -13,11 +13,13 @@ import (
117 
118 // SpeechInput adapts the stt recorder and ASR client to game.SpeechInput.
119 // Begin starts push-to-talk capture; End stops it, uploads the recording for
120-// transcription, and returns the raw transcript.
121+// transcription, and returns the raw transcript. Close stops any in-flight
122+// capture on shutdown so the recorder child and temp file are not orphaned.
123 type SpeechInput struct {
124 	Recorder *stt.Recorder
125 	ASR      *stt.ASRClient
126 
127+	mu  sync.Mutex
128 	cur *stt.Recording
129 }
130 
131@@ -26,21 +28,28 @@ func (s *SpeechInput) Begin(ctx context.Context) error {
132 	if err != nil {
133 		return fmt.Errorf("mic: %w", err)
134 	}
135+	s.mu.Lock()
136 	s.cur = rec
137+	s.mu.Unlock()
138 	return nil
139 }
140 
141 func (s *SpeechInput) End(ctx context.Context) (string, error) {
142-	if s.cur == nil {
143+	s.mu.Lock()
144+	rec := s.cur
145+	s.cur = nil
146+	s.mu.Unlock()
147+	if rec == nil {
148 		return "", fmt.Errorf("mic: recording not started")
149 	}
150-	path, err := s.cur.Stop()
151+	path, err := rec.Stop()
152 	if err != nil {
153 		return "", fmt.Errorf("mic: %w", err)
154 	}
155 
156 	data, err := os.ReadFile(path)
157 	if err != nil {
158+		_ = os.Remove(path)
159 		return "", fmt.Errorf("mic: read recording %s: %w", path, err)
160 	}
161 	defer func() { _ = os.Remove(path) }()
162@@ -52,6 +61,24 @@ func (s *SpeechInput) End(ctx context.Context) (string, error) {
163 	return raw, nil
164 }
165 
166+// Close stops any in-flight recording and removes its temp file. Called on
167+// shutdown so a capture active at quit does not orphan the recorder child or
168+// leak its temp WAV. Safe to call when nothing is recording.
169+func (s *SpeechInput) Close() {
170+	s.mu.Lock()
171+	rec := s.cur
172+	s.cur = nil
173+	s.mu.Unlock()
174+	if rec == nil {
175+		return
176+	}
177+	path, err := rec.Stop()
178+	if err != nil || path == "" {
179+		return
180+	}
181+	_ = os.Remove(path)
182+}
183+
184 // SpeechOutput adapts the tts client and player to game.SpeechOutput. It
185 // synthesizes the NPC reply's kana and plays it without blocking. OnDone, when
186 // set, is forwarded to the player and fires once the clip finishes normally.
187diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go
188index b0274203bf66ef710dd63e8479ab69f6080cf12a..100e72c76bd83257cdff93c3ee66949893e72070 100644
189--- a/internal/bootstrap/bootstrap.go
190+++ b/internal/bootstrap/bootstrap.go
191@@ -16,8 +16,7 @@ import (
192 )
193 
194 const (
195-	requestTimeout = 10 * time.Second
196-	warmupTimeout  = 30 * time.Second
197+	warmupTimeout = 30 * time.Second
198 
199 	// llama-server slot assignments are fixed for this deployment; they are not
200 	// user-configurable. One slot per prompt family keeps each system prompt
201@@ -66,7 +65,7 @@ func New(ctx context.Context, cfg *config.Config) (*Services, error) {
202 		c.AudioConfig.AudioBaseURL = "http://" + services.AudioListen
203 	}
204 
205-	hc := &http.Client{Timeout: requestTimeout}
206+	hc := &http.Client{}
207 	gameClient := newLLMClient(&c, hc, gameSlot)
208 	judgeClient := newLLMClient(&c, hc, judgeSlot)
209 	compactionClient := newLLMClient(&c, hc, compactionSlot)
210diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
211index 5cedf5964db1c78d6d8d722628e5fbfeb33decbd..a0222dde8221ee36489477110916fccac86be3e1 100644
212--- a/internal/game/orchestrator.go
213+++ b/internal/game/orchestrator.go
214@@ -62,6 +62,10 @@ type FlashcardModel interface {
215 // after their character sheet is registered.
216 const newPartnerNudge = "(The scene continues. The person the player is now talking to speaks first.)"
217 
218+// openingNote is the internal directive that produces the very first turn: the
219+// model describes the player's starting area with no player action involved.
220+const openingNote = "(The adventure begins. Describe the player's starting location and their immediate surroundings.)"
221+
222 // ErrEmptyTranscript is returned when push-to-talk produced no usable transcript.
223 var ErrEmptyTranscript = errors.New("game: empty transcript")
224 
225@@ -98,10 +102,12 @@ type Orchestrator struct {
226 	lastKana    string
227 	staticChars int
228 	budget      int
229+	totalBudget int
230 }
231 
232 // NewOrchestrator wires an orchestrator over a state and the eight adapters.
233 func NewOrchestrator(state *State, g GameModel, j JudgeModel, c Compactor, sh SheetModel, ask ScratchModel, flash FlashcardModel, in SpeechInput, out SpeechOutput) *Orchestrator {
234+	policy := llm.DefaultHistoryPolicy()
235 	return &Orchestrator{
236 		state:       state,
237 		game:        g,
238@@ -113,7 +119,8 @@ func NewOrchestrator(state *State, g GameModel, j JudgeModel, c Compactor, sh Sh
239 		speechIn:    in,
240 		speechOut:   out,
241 		staticChars: utf8.RuneCountInString(llm.GameSystemPrompt(state.Brief())),
242-		budget:      llm.DefaultHistoryPolicy().PromptCharBudget,
243+		budget:      policy.PromptCharBudget,
244+		totalBudget: policy.TotalContextBudget,
245 	}
246 }
247 
248@@ -140,13 +147,27 @@ func (o *Orchestrator) GenerateFlashcards(ctx context.Context, action string) (i
249 	return o.flash.Generate(ctx, instructions)
250 }
251 
252-// ActionTurn runs one typed action: record it, advance the game loop, handle any
253-// location change, play speech if the reply includes a line, and log the turn.
254+// ActionTurn runs one typed action: record it, then advance the game loop.
255 func (o *Orchestrator) ActionTurn(ctx context.Context, action string) TurnResult {
256+	o.state.RecordTyped(action)
257+	return o.advance(ctx, action)
258+}
259+
260+// StartAdventure runs the opening turn: record an internal begin note and advance
261+// once so the model describes the player's starting area. No player action is
262+// involved; the reply is a pure narration of where the player begins.
263+func (o *Orchestrator) StartAdventure(ctx context.Context) TurnResult {
264+	o.state.RecordUser(openingNote)
265+	return o.advance(ctx, "")
266+}
267+
268+// advance runs the shared tail of a turn whose user message has already been
269+// recorded in state: shrink context, build the prompt, advance the game loop,
270+// handle any new partner, play speech if present, and log the display entry.
271+func (o *Orchestrator) advance(ctx context.Context, displayAction string) TurnResult {
272 	var res TurnResult
273 
274-	o.state.RecordTyped(action)
275-	_ = o.compactOpenIfOverBudget(ctx)
276+	o.shrinkContext(ctx)
277 	prompt := o.state.Prompt()
278 
279 	reply, err := o.game.Advance(ctx, prompt)
280@@ -164,7 +185,7 @@ func (o *Orchestrator) ActionTurn(ctx context.Context, action string) TurnResult
281 			res.SpeakErr = err
282 		}
283 	}
284-	o.state.RecordDisplay(displayFromTurn(action, shown, desc, llm.JudgeResult{}, false))
285+	o.state.RecordDisplay(displayFromTurn(displayAction, shown, desc, llm.JudgeResult{}, false))
286 	return res
287 }
288 
289@@ -206,7 +227,7 @@ func (o *Orchestrator) FinishSpeak(ctx context.Context) TurnResult {
290 	}
291 
292 	o.state.RecordSpoken(raw)
293-	_ = o.compactOpenIfOverBudget(ctx)
294+	o.shrinkContext(ctx)
295 	prompt := o.state.Prompt()
296 
297 	var (
298@@ -294,6 +315,23 @@ func (o *Orchestrator) compactOpenIfOverBudget(ctx context.Context) error {
299 	return nil
300 }
301 
302+// shrinkContext runs both context guards before a prompt is built: per-location
303+// compaction of an over-long open segment, then dropping the oldest closed recaps
304+// to keep total prompt size within budget. Both are best-effort.
305+func (o *Orchestrator) shrinkContext(ctx context.Context) {
306+	_ = o.compactOpenIfOverBudget(ctx)
307+	o.dropOldestRecapsIfOverBudget()
308+}
309+
310+// dropOldestRecapsIfOverBudget bounds total prompt growth across locations by
311+// dropping the oldest closed-segment recaps until the full prompt fits within
312+// TotalContextBudget. It keeps at least one recap so some continuity survives.
313+func (o *Orchestrator) dropOldestRecapsIfOverBudget() {
314diff --git a/internal/game/state.go b/internal/game/state.go
315index 716cf54093a19ff6da867e35e80217c8be2e0efb..286ce0cae332998e24937f56e64c8857f55debca 100644
316--- a/internal/game/state.go
317+++ b/internal/game/state.go
318@@ -176,6 +176,17 @@ func (s *State) RegisterSummary(location, summary string) {
319 	s.closed = append(s.closed, Segment{Location: location, Summary: summary})
320 }
321 
322+// ClosedCount returns how many closed segments are stored.
323+func (s *State) ClosedCount() int { return len(s.closed) }
324+
325+// DropOldestSummary removes the oldest closed segment's recap to free context
326+// budget for more recent memory. No-op when there are no closed segments.
327+func (s *State) DropOldestSummary() {
328+	if len(s.closed) > 0 {
329+		s.closed = s.closed[1:]
330+	}
331+}
332+
333 // RegisterSheet stores one character sheet in first-met order. The key is the
334 // normalized location + talk pair; if that pair already has a sheet this is a
335 // no-op (the first sheet wins as the consistency anchor).
336@@ -214,13 +225,11 @@ func (s *State) sheetsAt(location string) []sheetEntry {
337 	return out
338 }
339 
340-// Prompt reconstructs the full game-loop message list for the next turn: the
341-// system prompt, then (only when prior context exists) one user "context" message
342-// holding all closed-segment recaps plus the open segment's prefix, then the open
343-// segment's raw messages. Pure; it does not mutate state.
344-func (s *State) Prompt() []llm.Message {
345-	msgs := []llm.Message{{Role: llm.RoleSystem, Content: llm.GameSystemPrompt(s.brief)}}
346-
347+// promptContext assembles the context block (closed recaps + current location's
348+// sheets + open prefix) and the open messages to send after it. Any leading user
349+// messages are dropped when a context block is present, since they would otherwise
350+// follow a user-role context line.
351+func (s *State) promptContext() (string, []Message) {
352 	var ctx strings.Builder
353 	for _, seg := range s.closed {
354 		if seg.Summary != "" {
355@@ -239,17 +248,41 @@ func (s *State) Prompt() []llm.Message {
356 
357 	openMsgs := s.open.Messages
358 	if ctx.Len() > 0 {
359-		msgs = append(msgs, llm.Message{Role: llm.RoleUser, Content: strings.TrimRight(ctx.String(), "\n")})
360 		for len(openMsgs) > 0 && openMsgs[0].Role == roleUser {
361 			openMsgs = openMsgs[1:]
362 		}
363 	}
364+	return strings.TrimRight(ctx.String(), "\n"), openMsgs
365+}
366+
367+// Prompt reconstructs the full game-loop message list for the next turn: the
368+// system prompt, then (only when prior context exists) one user "context" message
369+// holding all closed-segment recaps plus the open segment's prefix, then the open
370+// segment's raw messages. Pure; it does not mutate state.
371+func (s *State) Prompt() []llm.Message {
372+	msgs := []llm.Message{{Role: llm.RoleSystem, Content: llm.GameSystemPrompt(s.brief)}}
373+	ctxBlock, openMsgs := s.promptContext()
374+	if ctxBlock != "" {
375+		msgs = append(msgs, llm.Message{Role: llm.RoleUser, Content: ctxBlock})
376+	}
377 	for _, m := range openMsgs {
378 		msgs = append(msgs, llm.Message{Role: roleOf(m.Role), Content: m.Content})
379 	}
380 	return msgs
381 }
382 
383+// PromptCharCount returns the rune count of the full prompt that Prompt() would
384+// send (system + context block + open messages). Used to bound total context.
385+func (s *State) PromptCharCount() int {
386+	n := utf8.RuneCountInString(llm.GameSystemPrompt(s.brief))
387+	ctxBlock, openMsgs := s.promptContext()
388+	n += utf8.RuneCountInString(ctxBlock)
389+	for _, m := range openMsgs {
390+		n += utf8.RuneCountInString(m.Content)
391+	}
392+	return n
393+}
394+
395 // RecordDisplay appends one turn to the display log.
396 func (s *State) RecordDisplay(e DisplayEntry) { s.display = append(s.display, e) }
397 
398diff --git a/internal/llm/history.go b/internal/llm/history.go
399index 86579b127e68f1968b93620393151c71520b2506..49e7e63c5a6998143c2c476aaa6b09301209c97f 100644
400--- a/internal/llm/history.go
401+++ b/internal/llm/history.go
402@@ -1,18 +1,30 @@
403 package llm
404 
405 const (
406+	// maxContextTokens is the safe per-prompt working budget in tokens, kept at
407+	// about half the model's 32768-token context to leave headroom for output and
408+	// for charsPerTokenEstimate over-counting kana.
409 	maxContextTokens      = 16384
410-	charsPerTokenEstimate = 1
411+	charsPerTokenEstimate = 1 // conservative: count one token per rune
412+	// totalContextTokens caps the whole prompt (all recaps + sheets + open
413+	// segment), set below the model's 32768-token context so a very long session
414+	// drops old recaps instead of overflowing.
415+	totalContextTokens = 24000
416 )
417 
418 // HistoryPolicy bounds how much prior conversation a request may carry. The char
419-// budget is a rough estimate of the whole prompt, derived from the model context
420-// at charsPerTokenEstimate (one token per rune keeps the estimate on the safe side
421-// for kana/kanji and leaves romaji far inside the limit).
422+// budgets are rough estimates of the prompt at charsPerTokenEstimate (one token
423+// per rune keeps the estimate on the safe side for kana/kanji and leaves romaji
424+// far inside the limit). PromptCharBudget triggers per-location compaction of the
425+// open segment; TotalContextBudget triggers dropping the oldest closed recaps.
426 type HistoryPolicy struct {
427-	PromptCharBudget int
428+	PromptCharBudget   int
429+	TotalContextBudget int
430 }
431 
432 func DefaultHistoryPolicy() HistoryPolicy {
433-	return HistoryPolicy{PromptCharBudget: maxContextTokens * charsPerTokenEstimate}
434+	return HistoryPolicy{
435+		PromptCharBudget:   maxContextTokens * charsPerTokenEstimate,
436+		TotalContextBudget: totalContextTokens * charsPerTokenEstimate,
437+	}
438 }
439diff --git a/internal/server/handlers.go b/internal/server/handlers.go
440index 16002a2e56d090505997043a6a8b70cf9c5a3195..5c4f9a46f81dd6dd6fdafe8a863baf00957a35ea 100644
441--- a/internal/server/handlers.go
442+++ b/internal/server/handlers.go
443@@ -81,12 +81,34 @@ func writeError(w http.ResponseWriter, status int, msg string) {
444 	writeJSON(w, status, errorWire{Error: msg})
445 }
446 
447+// createSessionResponse is the start-adventure response: the new session id plus
448+// the opening turn (the model's description of the player's starting area).
449+type createSessionResponse struct {
450+	ID string `json:"id"`
451+	turnResponse
452+}
453+
454 func (s *Server) createSession(w http.ResponseWriter, r *http.Request) {
455 	sess := s.newSession()
456+
457+	sess.mu.Lock()
458+	ctx, cancel := context.WithTimeout(r.Context(), turnTimeout)
459+	res := sess.orch.StartAdventure(ctx)
460+	cancel()
461+	if res.Err != nil {
462+		sess.mu.Unlock()
463+		slog.Error("start adventure failed", "session", sess.id, "err", res.Err)
464+		writeError(w, http.StatusBadGateway, res.Err.Error())
465+		return
466+	}
467+	out := createSessionResponse{ID: sess.id, turnResponse: buildTurnResponse(sess, res)}
468+	sess.mu.Unlock()
469+
470 	s.mu.Lock()
471 	s.sessions[sess.id] = sess
472 	s.mu.Unlock()
473-	writeJSON(w, http.StatusOK, map[string]string{"id": sess.id})
474+
475+	writeJSON(w, http.StatusOK, out)
476 }
477 
478 func (s *Server) getLog(w http.ResponseWriter, r *http.Request) {
479@@ -211,19 +233,9 @@ func normalizeWav(wav []byte) {
480 	}
481 }
482 
483-// finishTurn writes a successful turn response from the display entry the
484-// orchestrator just recorded. The session lock must be held.
485-func finishTurn(w http.ResponseWriter, sess *session, res game.TurnResult) {
486-	if res.Err != nil {
487-		status := http.StatusBadGateway
488-		if errors.Is(res.Err, game.ErrEmptyTranscript) {
489-			status = http.StatusBadRequest
490-		}
491-		slog.Error("turn failed", "session", sess.id, "err", res.Err)
492-		writeError(w, status, res.Err.Error())
493-		return
494-	}
495-
496+// buildTurnResponse assembles the wire response for a completed turn from the last
497+// recorded display entry. The session lock must be held.
498+func buildTurnResponse(sess *session, res game.TurnResult) turnResponse {
499 	entries := sess.state.Display()
500 	out := turnResponse{logEntry: toLogEntry(entries[len(entries)-1])}
501 	out.Location = sess.state.Location()
502@@ -239,5 +251,20 @@ func finishTurn(w http.ResponseWriter, sess *session, res game.TurnResult) {
503 		out.JudgeError = res.JudgeErr.Error()
504 		slog.Warn("judge failed", "session", sess.id, "err", res.JudgeErr)
505 	}
506-	writeJSON(w, http.StatusOK, out)
507+	return out
508+}
509+
510+// finishTurn writes a successful turn response from the display entry the
511+// orchestrator just recorded. The session lock must be held.
512+func finishTurn(w http.ResponseWriter, sess *session, res game.TurnResult) {
513+	if res.Err != nil {
514+		status := http.StatusBadGateway
515+		if errors.Is(res.Err, game.ErrEmptyTranscript) {
516+			status = http.StatusBadRequest
517+		}
518+		slog.Error("turn failed", "session", sess.id, "err", res.Err)
519+		writeError(w, status, res.Err.Error())
520+		return
521+	}
522+	writeJSON(w, http.StatusOK, buildTurnResponse(sess, res))
523 }
524diff --git a/internal/stt/asr.go b/internal/stt/asr.go
525index 07f31562744b7df7f5caeab08d05f722f9a8ccbe..5ed11924d1ddef221011b202b94e8bbc435652d3 100644
526--- a/internal/stt/asr.go
527+++ b/internal/stt/asr.go
528@@ -11,6 +11,7 @@ import (
529 	"mime/multipart"
530 	"net/http"
531 	"strings"
532+	"time"
533 )
534 
535 // ASRModelName is the model id sent in transcription requests.
536@@ -19,6 +20,10 @@ const ASRModelName = "qwen3-asr"
537 // asrLanguage forces Japanese transcription.
538 const asrLanguage = "ja"
539 
540+// asrTimeout bounds one transcription call; ASR is fast and a stuck call should
541+// not hang a turn.
542+const asrTimeout = 10 * time.Second
543+
544 // ASRClient is a multipart client for the externally managed audio.cpp
545 // server's OpenAI-style transcriptions endpoint. It uploads a WAV file and
546 // returns the recognized text.
547@@ -39,6 +44,8 @@ func NewASRClient(baseURL string, hc *http.Client) *ASRClient {
548 // TranscribeBytes sends the WAV bytes to the transcriptions endpoint and
549 // returns the recognized text.
550 func (c *ASRClient) TranscribeBytes(ctx context.Context, wav []byte) (string, error) {
551+	ctx, cancel := context.WithTimeout(ctx, asrTimeout)
552+	defer cancel()
553 	body, contentType, err := encodeMultipart(wav)
554 	if err != nil {
555 		return "", fmt.Errorf("stt: encode multipart: %w", err)
556diff --git a/internal/tts/client.go b/internal/tts/client.go
557index a77cff445e52b9a81687acd4802d75c2880ef484..ee10ee84454f074ae90f2566c9f9119360455f1b 100644
558--- a/internal/tts/client.go
559+++ b/internal/tts/client.go
560@@ -8,6 +8,7 @@ import (
561 	"io"
562 	"net/http"
563 	"strings"
564+	"time"
565 )
566 
567 const (
568@@ -17,6 +18,10 @@ const (
569 	ttsLanguage       = "Japanese"
570 	speakInstructions = "Speak at a natural, conversational pace with clear articulation and natural pauses between sentences."
571 	speechSeed        = 1234
572+
573+	// speechTimeout bounds one synthesis call; TTS is fast and a stuck call should
574+	// not hang a turn.
575+	speechTimeout = 10 * time.Second
576 )
577 
578 // Client is the audio.cpp Qwen3-TTS speech client. BaseURL is the configured
579@@ -46,6 +51,8 @@ type speechRequest struct {
580 
581 // Speech posts kana to the speech endpoint and returns the WAV bytes.
582 func (c *Client) Speech(ctx context.Context, kana string) ([]byte, error) {
583+	ctx, cancel := context.WithTimeout(ctx, speechTimeout)
584+	defer cancel()
585 	body, err := json.Marshal(speechRequest{
586 		Model:          ModelName,
587 		Input:          kana,
588diff --git a/internal/ui/app.go b/internal/ui/app.go
589index 47a85e879235243f5cb6f31584ee3389326be829..2d38a879de6a010a589391cadf955182c74a56bc 100644
590--- a/internal/ui/app.go
591+++ b/internal/ui/app.go
592@@ -18,10 +18,6 @@ import (
593 // tickInterval refreshes the recording elapsed indicator while a capture is open.
594 const tickInterval = 200 * time.Millisecond
595 
596-// openingAction is the scene-setting action fired once at startup so the log
597-// shows where the player begins before any typed input.
598-const openingAction = "Look around."
599-
600 var (
601 	actionStyle     = lipgloss.NewStyle().Foreground(gruvBlue)
602 	narrationStyle  = lipgloss.NewStyle()
603@@ -84,7 +80,7 @@ func NewModel(state *game.State, orch *game.Orchestrator, recordCapSec int) *mod
604 
605 func (m *model) Init() tea.Cmd {
606 	m.phase = phaseProcessing
607-	return actionTurn(m.orch, openingAction)
608+	return startAdventure(m.orch)
609 }
610 
611 func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
612@@ -394,6 +390,12 @@ func actionTurn(o *game.Orchestrator, action string) tea.Cmd {
613 	}
614 }
615 
616+func startAdventure(o *game.Orchestrator) tea.Cmd {
617+	return func() tea.Msg {
618+		return finishResultMsg{res: o.StartAdventure(context.Background())}
619+	}
620+}
621+
622 func askTurn(o *game.Orchestrator, question string) tea.Cmd {
623 	return func() tea.Msg {
624 		return finishResultMsg{res: o.AskTurn(context.Background(), question)}