2ab2e8f327eabd68a52eb316dbe121f492237ef5
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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)}