20ca0ac5c522995d03fd944c20477d3dabdd0cc2
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/AGENTS.md b/AGENTS.md
2index 0bbed30d9d19c738bade143915cbc3d10a91b8bc..10e6bd5c98ac4e7776ffadfd0e835c2d34d8a181 100644
3--- a/AGENTS.md
4+++ b/AGENTS.md
5@@ -57,8 +57,12 @@ almost fully allocated to the chat model.
6
7 ## Development
8
9-- Verify changes with `go build ./...` and `go vet ./...`. Do not write any
10- tests; there is no test suite in this project.
11+- Clean out dead code by default: when a change leaves functions, types,
12+ fields, or imports unused, remove them — including anything newly orphaned
13+ by that removal.
14+- Verify changes with `go build ./...`, `golangci-lint fmt ./...`, and
15+ `golangci-lint run ./...`. Do not write any tests; there is no test suite in
16+ this project.
17 - Only the user may prepare live services for manual integration.
18 - Docs: `README.md` covers running and configuration only. Operator reference
19 for the external services lives in `docs/services.md`.
20diff --git a/assets/scenarios/city.json b/assets/scenarios/city.json
21index eb8bf01aa7d49a2be21ca250e98cac66da775c94..42ab1491e9ead66edeed40f2ad790e8d96a91bf5 100644
22--- a/assets/scenarios/city.json
23+++ b/assets/scenarios/city.json
24@@ -1,4 +1,4 @@
25 {
26 "name": "city",
27diff --git a/internal/adapters/llm.go b/internal/adapters/llm.go
28index b03da5ac6d9078bf8c7d82dc8bea915da2f24dec..378a097f779c095aa86459980c036cf15cfbd051 100644
29--- a/internal/adapters/llm.go
30+++ b/internal/adapters/llm.go
31@@ -24,8 +24,8 @@ func dumpRaw(label, transcript, raw string) {
32 if err != nil {
33 return
34 }
35- defer f.Close()
36- fmt.Fprintf(f, "--- %s %s ---\ntranscript: %s\n%s\n\n", label, time.Now().Format(time.RFC3339), transcript, raw)
37+ defer f.Close() //nolint:errcheck // best-effort debug log; never block the turn
38+ fmt.Fprintf(f, "--- %s %s ---\ntranscript: %s\n%s\n\n", label, time.Now().Format(time.RFC3339), transcript, raw) //nolint:errcheck
39 }
40
41 // GameModel adapts an llm.Client to game.GameModel. It sends the reconstructed
42diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
43index dc981bfc1b6a8154154712280fc67f57d075866f..e05c606d1975019c949506e401849a043239f2e9 100644
44--- a/internal/game/orchestrator.go
45+++ b/internal/game/orchestrator.go
46@@ -221,6 +221,7 @@ func displayFromReply(action string, r llm.GameReply, j llm.JudgeResult, hasJudg
47 e := DisplayEntry{Action: action, Desc: r.Desc, Romaji: r.Romaji, English: r.English, HasSpeech: hasSpeech(r)}
48 if hasJudge {
49 e.HasJudge = true
50+ e.PlayerRomaji = j.Romaji
51 e.Score = j.Score
52 e.Feedback = j.Feedback
53 }
54diff --git a/internal/game/state.go b/internal/game/state.go
55index db0875d55f0c8617d16b593a1bec5f4b21ea314e..338bf49e306e7c0bda457d3b754bdc4d9d10ec53 100644
56--- a/internal/game/state.go
57+++ b/internal/game/state.go
58@@ -37,16 +37,17 @@ type Segment struct {
59 // are empty and HasSpeech false for pure narration turns. ShowRomaji and
60 // ShowEnglish are independent: either can be revealed in either order.
61 type DisplayEntry struct {
62- Action string
63- Desc string
64- Romaji string
65- English string
66- HasSpeech bool
67- Score int
68- Feedback string
69- HasJudge bool
70- ShowRomaji bool
71- ShowEnglish bool
72+ Action string
73+ Desc string
74+ Romaji string
75+ English string
76+ HasSpeech bool
77+ PlayerRomaji string
78+ Score int
79+ Feedback string
80+ HasJudge bool
81+ ShowRomaji bool
82+ ShowEnglish bool
83 }
84
85 // State is the pure, in-memory session. The LLM owns world consistency; State
86diff --git a/internal/llm/contract.go b/internal/llm/contract.go
87index 0e5c1a4ee6f96c0bb80b3bd7c7c8930b9c442516..6d4007a18388946bd2973fbfb10d0423443fbb8a 100644
88--- a/internal/llm/contract.go
89+++ b/internal/llm/contract.go
90@@ -47,7 +47,6 @@ func kindString(k ContractErrorKind) string {
91 }
92
93 var (
94- npcFieldNames = []string{"ROMAJI", "KANA", "ENGLISH"}
95 judgeFieldNames = []string{"SCORE", "ROMAJI", "FEEDBACK"}
96 gameFieldNames = []string{"LOCATION", "TALK", "DESC", "ROMAJI", "KANA", "ENGLISH"}
97 )
98@@ -96,26 +95,6 @@ func requireFields(vals map[string]string, names []string) error {
99 return nil
100 }
101
102-type NPCReply struct {
103- Romaji string
104- Kana string
105- English string
106-}
107-
108-// ParseNPCReply parses the three-field NPC reply contract. It tolerates
109-// surrounding whitespace and rejects missing or duplicate fields with a
110-// recoverable *ContractError rather than panicking.
111-func ParseNPCReply(raw string) (NPCReply, error) {
112- vals, err := parseFields(raw, npcFieldNames)
113- if err != nil {
114- return NPCReply{}, err
115- }
116- if err := requireFields(vals, npcFieldNames); err != nil {
117- return NPCReply{}, err
118- }
119- return NPCReply{Romaji: vals["ROMAJI"], Kana: vals["KANA"], English: vals["ENGLISH"]}, nil
120-}
121-
122 type JudgeResult struct {
123 Score int
124 Romaji string
125@@ -144,7 +123,7 @@ func ParseJudge(raw string) (JudgeResult, error) {
126
127 type GameReply struct {
128 Location string // stable id of the player's current place, always present
129- Talk string // name of the person being spoken to, or "none"; always present
130+ Talk string // who you are speaking to (role or appearance), or "none"; always present
131 Desc string // English narration; may be empty
132 Romaji string // NPC speech in romaji; part of the spoken triple
133 Kana string // same sentence in kana; part of the spoken triple
134diff --git a/internal/llm/history.go b/internal/llm/history.go
135index 40829d0490e833133785ec2c12ac526a0d6df888..86579b127e68f1968b93620393151c71520b2506 100644
136--- a/internal/llm/history.go
137+++ b/internal/llm/history.go
138@@ -1,57 +1,18 @@
139 package llm
140
141-import "unicode/utf8"
142-
143-// Turn is one exchange: the player's spoken line and the NPC's reply to it.
144-type Turn struct {
145- User string
146- Assistant string
147-}
148-
149 const (
150 maxContextTokens = 16384
151 charsPerTokenEstimate = 1
152- defaultMaxTurns = 8
153 )
154
155-// HistoryPolicy bounds how much prior conversation a request may carry. The
156-// char budget is a rough estimate of the whole prompt, derived from the model
157-// context at charsPerTokenEstimate (one token per rune keeps the estimate on
158-// the safe side for kana/kanji and leaves romaji far inside the limit).
159+// HistoryPolicy bounds how much prior conversation a request may carry. The char
160+// budget is a rough estimate of the whole prompt, derived from the model context
161+// at charsPerTokenEstimate (one token per rune keeps the estimate on the safe side
162+// for kana/kanji and leaves romaji far inside the limit).
163 type HistoryPolicy struct {
164- MaxTurns int
165 PromptCharBudget int
166 }
167
168 func DefaultHistoryPolicy() HistoryPolicy {
169- return HistoryPolicy{MaxTurns: defaultMaxTurns, PromptCharBudget: maxContextTokens * charsPerTokenEstimate}
170-}
171-
172-func runeCount(s string) int { return utf8.RuneCountInString(s) }
173-
174-// BoundHistory returns the newest turns of prior that fit within maxTurns turns
175-// and whose combined character count stays within charBudget. Turns keep their
176-// original order; when the budget is exceeded the oldest turns are dropped
177-// first, so the result honors whichever cap (turns or chars) is tighter.
178-func BoundHistory(prior []Turn, maxTurns int, charBudget int) []Turn {
179- if maxTurns <= 0 || charBudget <= 0 || len(prior) == 0 {
180- return nil
181- }
182- start := 0
183- if n := len(prior); n > maxTurns {
184- start = n - maxTurns
185- }
186- kept := prior[start:]
187- used := 0
188- for _, t := range kept {
189- used += runeCount(t.User) + runeCount(t.Assistant)
190- }
191- for len(kept) > 0 && used > charBudget {
192- used -= runeCount(kept[0].User) + runeCount(kept[0].Assistant)
193- kept = kept[1:]
194- }
195- if len(kept) == 0 {
196- return nil
197- }
198- return kept
199+ return HistoryPolicy{PromptCharBudget: maxContextTokens * charsPerTokenEstimate}
200 }
201diff --git a/internal/llm/prompt.go b/internal/llm/prompt.go
202index 9233815ff846a83a09612a6bc8e0c16454ee4457..50d7eac2d6f1f57d4fd2949f672e72aefa1b7f27 100644
203--- a/internal/llm/prompt.go
204+++ b/internal/llm/prompt.go
205@@ -5,25 +5,6 @@ import (
206 "strings"
207 )
208
209-// npcSystemTemplate wraps the authored persona description and fixes the
210-// three-field reply contract. It never names a game, quest, or scenario,
211-// never refers to grading, and tells the model to speak as a normal person
212-// rather than a teacher.
213-const npcSystemTemplate = `You are %s.
214-
215-Speak naturally, as yourself, in Japanese. Keep your reply short and conversational. Do not teach language, correct mistakes, or mention any evaluation.
216-
217-Your reply must be exactly three lines of plain text, in this order. No extra lines, no markdown, no code fences. Every line starts with its field key, then a pipe character |, then the value. Never omit, rename, or reorder the keys.
218-
219-ROMAJI|<romaji of the sentence you say>
220-KANA|<the same sentence written in kana>
221-ENGLISH|<a natural English translation of that sentence>
222-
223-Example reply:
224-ROMAJI|nee san, chuumon wa?
225-KANA|ねえさん、注文は?
226-ENGLISH|Miss, what is your order?`
227-
228 // judgeSystemTemplate is persona-free and fixes the three-field judge contract.
229 // It scores fluency and naturalness, accepting any natural phrasing, and
230 // transcribes the given line into Hepburn romaji. It never names a game, quest,
231@@ -43,37 +24,9 @@ SCORE|85
232 ROMAJI|konnichiwa
233 FEEDBACK|It's a polite greeting, but you can use a more specific greeting for the time period e.g. "ohayoo gozaimasu"`
234
235-// NPCSystemPrompt renders the NPC system message for one location from its
236-// authored description. Every NPC request for that location starts with this
237-// exact text.
238-func NPCSystemPrompt(description string) string {
239- return fmt.Sprintf(npcSystemTemplate, fallback(description, "a friendly local"))
240-}
241-
242 // JudgeSystemPrompt returns the static judge system message.
243 func JudgeSystemPrompt() string { return judgeSystemTemplate }
244
245-// BuildNPCMessages assembles the NPC request: a system message holding the
246-// stable persona description, bounded prior history, and the current raw
247-// transcript. Judge output never enters this prompt.
248-func BuildNPCMessages(persona, transcript string, prior []Turn, policy HistoryPolicy) []Message {
249- system := NPCSystemPrompt(persona)
250- historyBudget := policy.PromptCharBudget - runeCount(system) - runeCount(transcript)
251- if historyBudget < 0 {
252- historyBudget = 0
253- }
254- turns := BoundHistory(prior, policy.MaxTurns, historyBudget)
255-
256- msgs := make([]Message, 0, len(turns)*2+2)
257- msgs = append(msgs, Message{Role: RoleSystem, Content: system})
258- for _, t := range turns {
259- msgs = append(msgs, Message{Role: RoleUser, Content: t.User})
260- msgs = append(msgs, Message{Role: RoleAssistant, Content: t.Assistant})
261- }
262- msgs = append(msgs, Message{Role: RoleUser, Content: transcript})
263- return msgs
264-}
265-
266 func fallback(value, def string) string {
267 if strings.TrimSpace(value) == "" {
268 return def
269@@ -98,8 +51,13 @@ Established facts about this world:
270 %s
271
272 How to respond
273-- Treat the player's input as their next move and advance the scene from it.
274-- You may introduce new places and people as the player explores, but stay strictly consistent with the established facts and anything earlier in this conversation (names, roles, prices, directions).
275+- The player's typed English text is an instruction to you (the world). It is never speech spoken aloud; no NPC hears it. Advance the scene from it.
276+- Actions like "wait", "sit", "look around" advance the scene. For waiting or pausing, fast-forward: skip the passing time and narrate what happens when the next event occurs.
277+- Questions in English are meta-questions about the world (e.g. "when will my food arrive?"). Answer them in DESC as narration. Do not make an NPC answer a question the player only typed.
278+- A physical action directed at a person ("bow to the cook", "point at the menu") is a gesture: the person may react with a small action or brief word based on context, but this is their choice, not a conversation.
279diff --git a/internal/ui/app.go b/internal/ui/app.go
280index b8ea853342f0d7343aef2c2ef1a191ed9e00b14a..068beddee61d0ba47879f46424d96671ab9642ef 100644
281--- a/internal/ui/app.go
282+++ b/internal/ui/app.go
283@@ -6,6 +6,7 @@ import (
284 "time"
285
286 "github.com/charmbracelet/bubbletea"
287+ "github.com/charmbracelet/lipgloss"
288
289 "japanese/internal/game"
290 )
291@@ -17,6 +18,14 @@ const tickInterval = 200 * time.Millisecond
292 // shows where the player begins before any typed input.
293 const openingAction = "Look around."
294
295+var (
296+ actionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("38")) // cyan
297+ narrationStyle = lipgloss.NewStyle() // default
298+ playerSaidStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // green
299+ judgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) // yellow
300+ hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) // gray
301+)
302+
303 type phase int
304
305 const (
306@@ -205,25 +214,28 @@ func (m *model) phaseWord() string {
307 func entryLines(e game.DisplayEntry) []string {
308 var lines []string
309 if e.Action != "" {
310- lines = append(lines, "> "+sanitizeRomajiOnly(e.Action))
311+ lines = append(lines, actionStyle.Render("> "+sanitizeRomajiOnly(e.Action)))
312 }
313 if e.Desc != "" {
314- lines = append(lines, sanitizeRomajiOnly(e.Desc))
315+ lines = append(lines, narrationStyle.Render(sanitizeRomajiOnly(e.Desc)))
316 }
317 if e.HasSpeech {
318 if e.ShowRomaji {
319 lines = append(lines, " » "+sanitizeRomajiOnly(valueOrNone(e.Romaji)))
320 } else {
321- lines = append(lines, " » [F3] romaji hidden")
322+ lines = append(lines, hintStyle.Render(" » [F3] romaji hidden"))
323 }
324 if e.ShowEnglish {
325 lines = append(lines, " "+sanitizeRomajiOnly(valueOrNone(e.English)))
326 } else {
327- lines = append(lines, " [F4] english hidden")
328+ lines = append(lines, hintStyle.Render(" [F4] english hidden"))
329 }
330 }
331 if e.HasJudge {
332- lines = append(lines, fmt.Sprintf(" judge %d/100 — %s", e.Score, sanitizeRomajiOnly(valueOrNone(e.Feedback))))
333+ if e.PlayerRomaji != "" {
334+ lines = append(lines, playerSaidStyle.Render(" said: "+sanitizeRomajiOnly(e.PlayerRomaji)))
335+ }
336+ lines = append(lines, judgeStyle.Render(fmt.Sprintf(" judge %d/100 — %s", e.Score, sanitizeRomajiOnly(valueOrNone(e.Feedback)))))
337 }
338 return lines
339 }
340diff --git a/internal/ui/render.go b/internal/ui/render.go
341index a16e1f20cfef614587c6c0513f4ab2d42a9df01d..4862ac3d5109d7d2ac4367955bf91adb3772e6ee 100644
342--- a/internal/ui/render.go
343+++ b/internal/ui/render.go
344@@ -54,9 +54,9 @@ func statusLine(status, phaseWord string, recElapsed, recCap int) string {
345 return "thinking…"
346 }
347 if status != "" {
348- return sanitizeRomajiOnly(status) + " " + controlsHint
349+ return sanitizeRomajiOnly(status) + " " + hintStyle.Render(controlsHint)
350 }
351- return controlsHint
352+ return hintStyle.Render(controlsHint)
353 }
354
355 func valueOrNone(s string) string {