Diff
1diff --git a/AGENTS.md b/AGENTS.md
2index 7d33100c926b9d1d250b73fc83a6034cee198cdc..5212071c86234eb66653b33dbacd6a13ebe64171 100644
3--- a/AGENTS.md
4+++ b/AGENTS.md
5@@ -81,3 +81,4 @@ almost fully allocated to the chat model.
6 - Only the user may prepare live services for manual integration.
7 - Docs: `README.md` covers running and configuration only. Operator reference
8 for the external services lives in `docs/services.md`.
9+- DO NOT cd into the directory you're already in when running the bash tool
10diff --git a/README.md b/README.md
11index af02aa089876566b7fb79eb8399b2255e4de2bdf..948f9e2ae0bc92745799599bf8e09f41536d04c9 100644
12--- a/README.md
13+++ b/README.md
14@@ -40,14 +40,14 @@ launches a service to make a check pass.
15 Every setting is a flag or an environment variable; flags win over env vars,
16 which win over defaults. Defaults match a local setup.
17
18-| Setting | Flag | Env var | Default | Use |
19-| -------------- | ------------------- | -------------------- | -------------------------------------- | ------------------------------------------------------- |
20-| LLM router URL | `--url` | `JP_LLM_BASE_URL` | `https://llama.home.theedgeofrage.com` | llama.cpp router; `POST /v1/chat/completions` |
21-| Temperature | `--temperature` | `JP_TEMPERATURE` | `1.0` | model sampling temperature |
22-| Max tokens | `--max-tokens` | `JP_MAX_TOKENS` | `256` | max output tokens per reply |
23-| Thinking mode | `--enable-thinking` | `JP_ENABLE_THINKING` | `false` | model thinking (`chat_template_kwargs.enable_thinking`) |
24-| Audio base URL | `--audio-url` | `JP_AUDIO_BASE_URL` | `http://127.0.0.1:8080` | audio.cpp server (TTS + ASR) |
25-| Scenario brief | `--scenario` | `JP_SCENARIO_PATH` | `assets/scenarios/small_city.md` | plain-text scenario brief file |
26+| Setting | Flag | Env var | Default | Use |
27+| -------------- | ----------------------- | ------------------------ | -------------------------------------- | ------------------------------------------------------- |
28+| LLM router URL | `--llm.url` | `JP_LLM_BASE_URL` | `https://llama.home.theedgeofrage.com` | llama.cpp router; `POST /v1/chat/completions` |
29+| Temperature | `--llm.temperature` | `JP_LLM_TEMPERATURE` | `1.0` | model sampling temperature |
30+| Max tokens | `--llm.max-tokens` | `JP_LLM_MAX_TOKENS` | `256` | max output tokens per reply |
31+| Thinking mode | `--llm.enable-thinking` | `JP_LLM_ENABLE_THINKING` | `false` | model thinking (`chat_template_kwargs.enable_thinking`) |
32+| Audio base URL | `--audio.url` | `JP_AUDIO_BASE_URL` | `http://127.0.0.1:8080` | audio.cpp server (TTS + ASR) |
33+| Scenario brief | `--scenario` | `JP_SCENARIO_PATH` | `assets/scenarios/small_city.md` | plain-text scenario brief file |
34
35 Run `jp -help` for the full flag list.
36
37diff --git a/go.mod b/go.mod
38index 6f93f566dbfda9618b760f6d322e316a7d67816c..ddbb7eb0163378b3fdf980b12747b28778fa2c7e 100644
39--- a/go.mod
40+++ b/go.mod
41@@ -11,6 +11,7 @@ require (
42
43 require (
44 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
45+ github.com/charmbracelet/bubbles v1.0.0 // indirect
46 github.com/charmbracelet/colorprofile v0.4.3 // indirect
47 github.com/charmbracelet/x/ansi v0.11.8 // indirect
48 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
49diff --git a/go.sum b/go.sum
50index 5dd27f197d3f1f48651d086f4801abe4d82fc324..c2dccee5f8a38d04788a04044f117b61c301b275 100644
51--- a/go.sum
52+++ b/go.sum
53@@ -1,5 +1,7 @@
54 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
55 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
56+github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
57+github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
58 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
59 github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
60 github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
61diff --git a/internal/adapters/llm.go b/internal/adapters/llm.go
62index fd4d9f396490afc8dc2a34d3671b5242d89f2460..2d8132af494f6d807ac36c0f338d5a7eb004adf5 100644
63--- a/internal/adapters/llm.go
64+++ b/internal/adapters/llm.go
65@@ -91,16 +91,16 @@ func (m *JudgeModel) Judge(ctx context.Context, transcript string) (llm.JudgeRes
66 }
67
68 // ScratchModel adapts an llm.Client to game.ScratchModel. It asks how to say a
69-// thing in Japanese and returns the raw answer text; no world context is sent.
70+// thing in Japanese and parses the three-field answer; no world context is sent.
71 type ScratchModel struct {
72 Client *llm.Client
73 }
74
75-func (m *ScratchModel) Ask(ctx context.Context, question string) (string, error) {
76+func (m *ScratchModel) Ask(ctx context.Context, question string) (llm.ScratchAnswer, error) {
77 raw, err := m.Client.Generate(ctx, llm.BuildScratchMessages(question))
78 if err != nil {
79- return "", err
80+ return llm.ScratchAnswer{}, err
81 }
82 dumpRaw("scratch", question, raw)
83- return strings.TrimSpace(raw), nil
84+ return llm.ParseScratch(raw)
85 }
86diff --git a/internal/config/config.go b/internal/config/config.go
87index f558f32ec596fbda3c078d35e8f5f5447102b733..fffa9fc641428616f776a4047684c17461091c59 100644
88--- a/internal/config/config.go
89+++ b/internal/config/config.go
90@@ -24,8 +24,8 @@ type AudioConfig struct {
91
92 // Config is the shared option group. Parsed with github.com/jessevdk/go-flags.
93 type Config struct {
94- LLMConfig LLMConfig `env-namespace:"JP_LLM"`
95- AudioConfig AudioConfig `env-namespace:"JP_AUDIO"`
96+ LLMConfig LLMConfig `group:"llm" namespace:"llm" env-namespace:"JP_LLM"`
97+ AudioConfig AudioConfig `group:"audio" namespace:"audio" env-namespace:"JP_AUDIO"`
98
99 Scenario string `long:"scenario" env:"JP_SCENARIO_PATH" default:"assets/scenarios/small_city.md" description:"path to the scenario brief file"`
100 }
101diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
102index 28dc7f5c420449c3a25c3f2c3933148a01c84c0f..f4c0f96d8bce8dca6342755f51c3c7997696b2ab 100644
103--- a/internal/game/orchestrator.go
104+++ b/internal/game/orchestrator.go
105@@ -46,7 +46,7 @@ type SheetModel interface {
106 // ScratchModel answers one scratch question about how to say something in
107 // Japanese. It is independent of the game model and sees no world context.
108 type ScratchModel interface {
109- Ask(ctx context.Context, question string) (string, error)
110+ Ask(ctx context.Context, question string) (llm.ScratchAnswer, error)
111 }
112
113 // newPartnerNudge is the user message that pulls a new partner's first line
114@@ -145,7 +145,13 @@ func (o *Orchestrator) AskTurn(ctx context.Context, action string) TurnResult {
115 res.Err = err
116 return res
117 }
118- o.state.RecordDisplay(DisplayEntry{Action: action, Desc: answer})
119+ o.state.RecordDisplay(DisplayEntry{
120+ Action: action,
121+ HasAsk: true,
122+ AskRomaji: answer.Romaji,
123+ AskTranslation: answer.Translation,
124+ AskBreakdown: answer.Breakdown,
125+ })
126 return res
127 }
128
129diff --git a/internal/game/state.go b/internal/game/state.go
130index b3b116441fdd2e5e5a0da215e3ae1138b1c39338..49950b4776caaea666591b6d468b01bee42b3986 100644
131--- a/internal/game/state.go
132+++ b/internal/game/state.go
133@@ -37,17 +37,21 @@ type Segment struct {
134 // are empty and HasSpeech false for pure narration turns. ShowRomaji and
135 // ShowEnglish are independent: either can be revealed in either order.
136 type DisplayEntry struct {
137- Action string
138- Desc string
139- Romaji string
140- English string
141- HasSpeech bool
142- PlayerRomaji string
143- Score int
144- Feedback string
145- HasJudge bool
146- ShowRomaji bool
147- ShowEnglish bool
148+ Action string
149+ Desc string
150+ Romaji string
151+ English string
152+ HasSpeech bool
153+ PlayerRomaji string
154+ Score int
155+ Feedback string
156+ HasJudge bool
157+ ShowRomaji bool
158+ ShowEnglish bool
159+ HasAsk bool
160+ AskRomaji string
161+ AskTranslation string
162+ AskBreakdown string
163 }
164
165 // sheetEntry is one stored character sheet, kept in first-met order so injected
166diff --git a/internal/llm/contract.go b/internal/llm/contract.go
167index f6bf6cc4617d20944c1ad9f9ab343a663a917fb3..1abe5d36465a721dcea63503ae393b8ee1f0c36c 100644
168--- a/internal/llm/contract.go
169+++ b/internal/llm/contract.go
170@@ -47,9 +47,10 @@ func kindString(k ContractErrorKind) string {
171 }
172
173 var (
174- judgeFieldNames = []string{"SCORE", "ROMAJI", "FEEDBACK"}
175- gameFieldNames = []string{"LOCATION", "TALK", "DESC", "ROMAJI", "KANA", "ENGLISH"}
176- sheetFieldNames = []string{"ROLE", "TRAITS", "MOOD", "STYLE", "UNCLEAR", "SAMPLE"}
177+ judgeFieldNames = []string{"SCORE", "ROMAJI", "FEEDBACK"}
178+ gameFieldNames = []string{"LOCATION", "TALK", "DESC", "ROMAJI", "KANA", "ENGLISH"}
179+ sheetFieldNames = []string{"ROLE", "TRAITS", "MOOD", "STYLE", "UNCLEAR", "SAMPLE"}
180+ scratchFieldNames = []string{"ROMAJI", "TRANSLATION", "BREAKDOWN"}
181 )
182
183 // parseFields splits a raw model reply into FIELD|value lines. Surrounding
184@@ -169,6 +170,25 @@ func ParseGameReply(raw string) (GameReply, error) {
185 }, nil
186 }
187
188+type ScratchAnswer struct {
189+ Romaji string
190+ Translation string
191+ Breakdown string
192+}
193+
194+// ParseScratch parses the three-field scratch contract. All three fields are
195+// required and non-empty; a malformed reply is a recoverable *ContractError.
196+func ParseScratch(raw string) (ScratchAnswer, error) {
197+ vals, err := parseFields(raw, scratchFieldNames)
198+ if err != nil {
199+ return ScratchAnswer{}, err
200+ }
201+ if err := requireFields(vals, scratchFieldNames); err != nil {
202+ return ScratchAnswer{}, err
203+ }
204+ return ScratchAnswer{Romaji: vals["ROMAJI"], Translation: vals["TRANSLATION"], Breakdown: vals["BREAKDOWN"]}, nil
205+}
206+
207 // Sheet is one person's compact character profile, parsed from the sheet
208 // contract. All six fields are required and non-empty.
209 type Sheet struct {
210diff --git a/internal/ui/app.go b/internal/ui/app.go
211index 0d62879099ecf0161cccbe1795ee783abb418f5f..91f2d8ddc0122eb2cb38b88d99eb7f5753e5aba8 100644
212--- a/internal/ui/app.go
213+++ b/internal/ui/app.go
214@@ -23,7 +23,6 @@ var (
215 actionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("38")) // cyan
216 narrationStyle = lipgloss.NewStyle() // default
217 playerSaidStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // green
218- judgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) // yellow
219 hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) // gray
220 )
221
222@@ -54,6 +53,7 @@ type model struct {
223 input string
224 status string
225 height int
226+ width int
227 }
228
229 func NewModel(state *game.State, orch *game.Orchestrator, recordCapSec int) *model {
230@@ -87,6 +87,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
231 if v.Height > 0 {
232 m.height = v.Height
233 }
234+ if v.Width > 0 {
235+ m.width = v.Width
236+ }
237 }
238 return m, cmd
239 }
240@@ -189,7 +192,7 @@ func (m *model) View() string {
241 if i > 0 {
242 logLines = append(logLines, "")
243 }
244- logLines = append(logLines, entryLines(e)...)
245+ logLines = append(logLines, entryLines(e, m.width)...)
246 }
247 vs := viewState{
248 logLines: logLines,
249@@ -213,10 +216,33 @@ func (m *model) phaseWord() string {
250 return "idle"
251 }
252
253+// keyedBlock renders a titled block of key/value rows: a gray section rule,
254+// then each row as a gray key padded to the widest key and its value in one
255+// aligned column. width is the terminal width for the header rule; zero means
256+// no rule tail.
257+func keyedBlock(width int, title string, pairs ...[2]string) []string {
258+ maxKey := 0
259+ for _, p := range pairs {
260+ if len(p[0]) > maxKey {
261+ maxKey = len(p[0])
262+ }
263+ }
264+ header := " ── " + title
265+ if pad := width - len(header); pad > 0 {
266+ header += strings.Repeat("─", pad)
267+ }
268+ lines := make([]string, 0, len(pairs)+1)
269+ lines = append(lines, hintStyle.Render(header))
270+ for _, p := range pairs {
271+ lines = append(lines, " "+hintStyle.Render(fmt.Sprintf("%-*s", maxKey, p[0]))+" "+p[1])
272+ }
273+ return lines
274+}
275+
276 // entryLines renders one display-log turn into log lines. Speech fields are
277 // revealed independently (romaji, then english); the judge line follows the turn
278 // that produced it.
279-func entryLines(e game.DisplayEntry) []string {
280+func entryLines(e game.DisplayEntry, width int) []string {
281 var lines []string
282 if e.Action != "" {
283 lines = append(lines, actionStyle.Render("> "+sanitizeRomajiOnly(e.Action)))
284@@ -240,7 +266,15 @@ func entryLines(e game.DisplayEntry) []string {
285 if e.PlayerRomaji != "" {
286 lines = append(lines, playerSaidStyle.Render(" said: "+sanitizeRomajiOnly(e.PlayerRomaji)))
287 }
288- lines = append(lines, judgeStyle.Render(fmt.Sprintf(" judge %d/100 — %s", e.Score, sanitizeRomajiOnly(valueOrNone(e.Feedback)))))
289+ lines = append(lines, keyedBlock(width, "judge",
290+ [2]string{"Score", fmt.Sprintf("%d/100", e.Score)},
291+ [2]string{"Feedback", sanitizeRomajiOnly(valueOrNone(e.Feedback))})...)
292+ }
293+ if e.HasAsk {
294+ lines = append(lines, keyedBlock(width, "ask",
295+ [2]string{"Romaji", sanitizeRomajiOnly(e.AskRomaji)},
296+ [2]string{"Translation", sanitizeRomajiOnly(e.AskTranslation)},
297+ [2]string{"Breakdown", sanitizeRomajiOnly(e.AskBreakdown)})...)
298 }
299 return lines
300 }