Diff
1diff --git a/AGENTS.md b/AGENTS.md
2index 11023772477a0d8d92168657988e7e2e1bfb1ed1..20b6fffba42db0b9dc55437fd343b5c61454b0ae 100644
3--- a/AGENTS.md
4+++ b/AGENTS.md
5@@ -1,17 +1,28 @@
6-# jp — Japanese learning RPG (TUI)
7+# jp — Japanese learning RPG (TUI + HTTP server)
8
9 ## Overview
10
11-A terminal game for practicing spoken Japanese. The UI is an adventure log:
12+A game for practicing spoken Japanese. The play loop is an adventure log:
13 the player types English actions to explore an LLM-narrated world, speaks
14 Japanese to people at locations through push-to-talk (each spoken line gets
15 separate fluency feedback from a judge), and can ask scratch questions with
16 `?question` for how to say something in Japanese. New conversation partners get
17 a hidden character sheet generated from their introducing reply so they stay
18-consistent across visits. The UI renders romaji only; kana and kanji are never
19-shown.
20+consistent across visits. Only romaji is ever shown to the player; kana and kanji stay internal.
21
22-The app is an HTTP client for two externally managed model services:
23+Two entry points share one game core (`internal/game`), the LLM layer
24+(`internal/llm`), config, and bootstrap:
25+
26+- **`cmd/jp`** — a Bubble Tea TUI. Runs the game in-process: it builds its own
27+ state + orchestrator with local adapters (mic capture via `arecord`, local
28+ audio playback) and talks straight to the model services.
29+- **`cmd/jp-server`** — an HTTP server exposing in-memory game sessions for a
30+ thin external client (e.g. iOS). The client records and plays audio; the
31+ server does ASR transcription, the game loop, the judge, and TTS synthesis
32+ (returns the NPC line as WAV bytes; replay is client-side). Turns serialize
33+ per session; sessions die with the process. All requests need a bearer token.
34+
35+Both entry points are HTTP clients for two externally managed model services:
36
37 - **LLM** — llama.cpp router (OpenAI-compatible chat completions) for NPC
38 dialogue, the judge, compaction, character sheets, and scratch questions.
39@@ -21,8 +32,8 @@ The app is an HTTP client for two externally managed model services:
40 transcript stays internal; it is what the LLM sees as the player's lines) and
41 an OpenAI-compatible speech endpoint that plays the NPC's kana.
42
43-Turn flow (spoken): record → transcribe → judge + NPC reply in parallel → play
44-kana → record the turn in per-location history. Typed actions skip recording,
45+Turn flow (spoken): record → transcribe → judge + NPC reply in parallel → synthesize the NPC's kana to audio → record
46+the turn in per-location history. Typed actions skip recording,
47 transcription, and the judge; scratch questions only touch the scratch model
48 and never enter world state. Per-location history is compacted into summaries
49 when it grows past budget (`internal/game/state.go`,
50@@ -41,7 +52,7 @@ almost fully allocated to the chat model.
51
52 ### Product invariants
53
54-- Render romaji only; never show kana or kanji in the TUI.
55+- Render romaji only; never expose kana or kanji to the player — not in the TUI and not across the jp-server wire.
56 - NPCs behave as normal people, not language teachers. Grading stays separate
57 from NPC dialogue; judge output never enters the NPC prompt.
58 - Persona data contains no game, player, NPC, quest, or scenario context.
59@@ -51,6 +62,9 @@ almost fully allocated to the chat model.
60
61 - Small single-purpose packages under `internal/`; adapters implement the small
62 interfaces defined at their point of use in `internal/game/orchestrator.go`.
63+- Speech has two adapter pairs over the same interface: local (mic via
64+ `arecord` + player) in `internal/adapters` for the TUI, and remote (uploaded
65+ WAV in, returned WAV out) in `internal/server/speech.go` for jp-server.
66 - LLM replies are parsed from strict `FIELD|value` contracts, never freeform
67 JSON.
68 - Errors wrap with `%w`. Startup failures print one stderr line naming the
69diff --git a/cmd/jp-server/main.go b/cmd/jp-server/main.go
70index 50b6bb4242eb521c8e874afd94ab3774cadcc957..e488c5fa7bb4477d2a70441e6011091863f410ee 100644
71--- a/cmd/jp-server/main.go
72+++ b/cmd/jp-server/main.go
73@@ -2,6 +2,7 @@ package main
74
75 import (
76 "fmt"
77+ "log/slog"
78 "net/http"
79 "os"
80
81@@ -11,6 +12,7 @@ import (
82 )
83
84 func main() {
85+ slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))
86 cfg := config.ParseServerConfig()
87 if cfg.Token == "" {
88 fatalf("token is required: set --token or JP_SERVER_TOKEN")
89diff --git a/internal/config/config.go b/internal/config/config.go
90index df40b5ec2548177bb1614d5bbf9f03d3c8dccb0f..d09e76bc5d4e3ffb4fcf3230097a725556ab25a2 100644
91--- a/internal/config/config.go
92+++ b/internal/config/config.go
93@@ -35,7 +35,7 @@ type Config struct {
94 type ServerConfig struct {
95 Config
96
97- Listen string `long:"listen" env:"JP_SERVER_LISTEN" default:"127.0.0.1:8081" description:"address to listen on"`
98+ Listen string `long:"listen" env:"JP_SERVER_LISTEN" default:"0.0.0.0:8081" description:"address to listen on"`
99 Token string `long:"token" env:"JP_SERVER_TOKEN" description:"bearer token required on all requests"`
100 }
101
102diff --git a/internal/server/handlers.go b/internal/server/handlers.go
103index 08888a61e01927f91e45292904f6d560d66a3c6b..b913a6150db64828f985b6b7eb2e03c7ad65c3f7 100644
104--- a/internal/server/handlers.go
105+++ b/internal/server/handlers.go
106@@ -6,6 +6,7 @@ import (
107 "encoding/json"
108 "errors"
109 "io"
110+ "log/slog"
111 "net/http"
112 "strings"
113
114@@ -144,6 +145,7 @@ func (s *Server) postAction(w http.ResponseWriter, r *http.Request) {
115 }
116 count, err := sess.orch.GenerateFlashcards(ctx, action)
117 if err != nil {
118+ slog.Error("flashcards failed", "session", sess.id, "err", err)
119 writeError(w, http.StatusBadGateway, err.Error())
120 return
121 }
122@@ -193,6 +195,7 @@ func finishTurn(w http.ResponseWriter, sess *session, res game.TurnResult) {
123 if errors.Is(res.Err, game.ErrEmptyTranscript) {
124 status = http.StatusBadRequest
125 }
126+ slog.Error("turn failed", "session", sess.id, "err", res.Err)
127 writeError(w, status, res.Err.Error())
128 return
129 }
130@@ -206,9 +209,11 @@ func finishTurn(w http.ResponseWriter, sess *session, res game.TurnResult) {
131 }
132 if res.SpeakErr != nil {
133 out.SpeakError = res.SpeakErr.Error()
134+ slog.Warn("speak failed", "session", sess.id, "err", res.SpeakErr)
135 }
136 if res.JudgeErr != nil {
137 out.JudgeError = res.JudgeErr.Error()
138+ slog.Warn("judge failed", "session", sess.id, "err", res.JudgeErr)
139 }
140 writeJSON(w, http.StatusOK, out)
141 }
142diff --git a/internal/server/server.go b/internal/server/server.go
143index f63e3b05d1d9fa552054cc84394999eb1c16b921..67bc2feb49b077c481dfbaadbddb5bcfba4cb174 100644
144--- a/internal/server/server.go
145+++ b/internal/server/server.go
146@@ -7,6 +7,7 @@ import (
147 "crypto/hmac"
148 "crypto/rand"
149 "encoding/hex"
150+ "log/slog"
151 "net/http"
152 "strings"
153 "sync"
154@@ -45,7 +46,7 @@ func (s *Server) Handler() http.Handler {
155 mux.HandleFunc("GET /v1/sessions/{id}/log", s.getLog)
156 mux.HandleFunc("POST /v1/sessions/{id}/actions", s.postAction)
157 mux.HandleFunc("POST /v1/sessions/{id}/speech", s.postSpeech)
158- return auth(s.token, mux)
159+ return logging(auth(s.token, mux))
160 }
161
162 func auth(token []byte, next http.Handler) http.Handler {
163@@ -59,6 +60,52 @@ func auth(token []byte, next http.Handler) http.Handler {
164 })
165 }
166
167+// statusRecorder captures the final response status for request logging.
168+type statusRecorder struct {
169+ http.ResponseWriter
170+ status int
171+ wroteHeader bool
172+}
173+
174+func (r *statusRecorder) WriteHeader(code int) {
175+ if !r.wroteHeader {
176+ r.status = code
177+ r.wroteHeader = true
178+ }
179+ r.ResponseWriter.WriteHeader(code)
180+}
181+
182+// logging logs every request with its final status and duration, and turns a
183+// handler panic into a logged 500 instead of a bare connection reset.
184+func logging(next http.Handler) http.Handler {
185+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
186+ start := time.Now()
187+ rec := &statusRecorder{ResponseWriter: w}
188+ defer func() {
189+ if p := recover(); p != nil {
190+ slog.Error("panic", "method", r.Method, "path", r.URL.Path, "err", p)
191+ if !rec.wroteHeader {
192+ writeError(rec, http.StatusInternalServerError, "internal error")
193+ }
194+ }
195+ status := rec.status
196+ if status == 0 {
197+ status = http.StatusOK
198+ }
199+ reqLog := slog.With("method", r.Method, "path", r.URL.Path, "status", status, "duration", time.Since(start))
200+ switch {
201+ case status >= 500:
202+ reqLog.Error("request")
203+ case status >= 400:
204+ reqLog.Warn("request")
205+ default:
206+ reqLog.Info("request")
207+ }
208+ }()
209+ next.ServeHTTP(rec, r)
210+ })
211+}
212+
213 // session is one in-memory game: a fresh state and orchestrator over the
214 // shared service clients. The mutex serializes that session's turns.
215 type session struct {