a5407cc4be035bc663b5f36aa1340ac922dfa5bb
- 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 10e6bd5c98ac4e7776ffadfd0e835c2d34d8a181..cee212c23d6bfd87b65fdcbf16acf73c73156c4b 100644
3--- a/AGENTS.md
4+++ b/AGENTS.md
5@@ -54,6 +54,8 @@ almost fully allocated to the chat model.
6 constraints, how to verify), not a full file to transcribe. The main context
7 does the design and thinking; the worker turns that into code. Pasting whole
8 files wastes context and defeats the purpose of offloading work.
9+- Workers commit their changes when done: one commit per batch with a short,
10+ polished message describing the change.
11
12 ## Development
13
14diff --git a/README.md b/README.md
15index e0eee2b72b7b76d3951d0ce511966c27b8a3592c..02364a5c1af0c25464ce046909cc817b6b0b0e67 100644
16--- a/README.md
17+++ b/README.md
18@@ -44,6 +44,8 @@ local setup.
19 | Service | Flag | Env var | Default | Use |
20 | ----------------- | ------------------ | ----------------- | ----------------------------------------- | ----------------------------------------------- |
21 | LLM router URL | `--llm-url` | `JP_LLM_BASE_URL` | `https://llama.home.theedgeofrage.com` | `POST /v1/chat/completions` |
22+| LLM game slot | `--llm-game-slot` | `JP_LLM_GAME_SLOT` | `0` | llama-server slot for game-loop requests (`-1` = server decides) |
23+| LLM scratch slot | `--llm-scratch-slot` | `JP_LLM_SCRATCH_SLOT` | `1` | llama-server slot for judge and compaction requests (`-1` = server decides) |
24 | TTS base URL | `--tts-url` | `JP_TTS_BASE_URL` | `http://127.0.0.1:8080/v1` | `POST /audio/speech` |
25 | STT server URL | `--stt-url` | `JP_STT_URL` | `http://127.0.0.1:8178` | multipart `POST /inference` |
26 | STT language | `--stt-language` | `JP_STT_LANGUAGE` | `ja` | Whisper request language (ISO 639-1) |
27diff --git a/assets/scenarios/city.json b/assets/scenarios/city.json
28deleted file mode 100644
29index 42ab1491e9ead66edeed40f2ad790e8d96a91bf5..0000000000000000000000000000000000000000
30--- a/assets/scenarios/city.json
31+++ /dev/null
32@@ -1,4 +0,0 @@
33-{
34- "name": "city",
35diff --git a/assets/scenarios/small_city.md b/assets/scenarios/small_city.md
36new file mode 100644
37index 0000000000000000000000000000000000000000..0a10147040adce89ffe248259dd010df91550b59
38--- /dev/null
39+++ b/assets/scenarios/small_city.md
40@@ -0,0 +1 @@
41+A small Japanese city in the evening. The player is a visitor exploring on foot.
42diff --git a/cmd/jp/main.go b/cmd/jp/main.go
43index d36d64b98b2ef178bd5a0d455017a5785c9d7811..0da8cfa1ad958185d0d5b02001dc707aeb1662b6 100644
44--- a/cmd/jp/main.go
45+++ b/cmd/jp/main.go
46@@ -35,10 +35,13 @@ func main() {
47 }
48
49 hc := &http.Client{Timeout: requestTimeout}
50- llmClient := llm.NewClient(cfg, hc)
51+ gameClient := llm.NewClient(cfg, hc)
52+ gameClient.Slot = cfg.LLMConfig.GameSlot
53+ scratchClient := llm.NewClient(cfg, hc)
54+ scratchClient.Slot = cfg.LLMConfig.ScratchSlot
55
56 llmUp := true
57- if err := llmClient.LoadModel(); err != nil {
58+ if err := gameClient.LoadModel(); err != nil {
59 fmt.Fprintf(os.Stderr, "jp: LLM unavailable: %v\n", err)
60 llmUp = false
61 }
62@@ -51,15 +54,18 @@ func main() {
63
64 if llmUp {
65 ctx, cancel := context.WithTimeout(context.Background(), warmupTimeout)
66- err := llmClient.Warmup(ctx, warmupSystems(sc.Seed))
67+ err := gameClient.Warmup(ctx, []string{llm.GameSystemPrompt(sc.Brief)})
68+ if err == nil {
69+ err = scratchClient.Warmup(ctx, []string{llm.JudgeSystemPrompt(), llm.CompactionPrompt()})
70+ }
71 cancel()
72 if err != nil {
73 fmt.Fprintf(os.Stderr, "jp: LLM warmup failed: %v\n", err)
74 }
75 }
76
77- state := game.NewState(sc.Seed)
78- orch := buildOrchestrator(cfg, state, hc, llmClient)
79+ state := game.NewState(sc.Brief)
80+ orch := buildOrchestrator(cfg, state, hc, gameClient, scratchClient)
81
82 m := ui.NewModel(state, orch, int(recordCap.Seconds()))
83 if _, err := tea.NewProgram(m).Run(); err != nil {
84@@ -67,10 +73,10 @@ func main() {
85 }
86 }
87
88-func buildOrchestrator(cfg *config.Config, state *game.State, hc *http.Client, llmClient *llm.Client) *game.Orchestrator {
89- gameModel := &adapters.GameModel{Client: llmClient}
90- compactor := &adapters.Compactor{Client: llmClient}
91- judgeModel := &adapters.JudgeModel{Client: llmClient}
92+func buildOrchestrator(cfg *config.Config, state *game.State, hc *http.Client, gameClient, scratchClient *llm.Client) *game.Orchestrator {
93+ gameModel := &adapters.GameModel{Client: gameClient}
94+ compactor := &adapters.Compactor{Client: scratchClient}
95+ judgeModel := &adapters.JudgeModel{Client: scratchClient}
96
97 speechIn := &adapters.SpeechInput{
98 Recorder: stt.NewRecorder(cfg.RecordCommand, recordCap),
99@@ -84,17 +90,6 @@ func buildOrchestrator(cfg *config.Config, state *game.State, hc *http.Client, l
100 return game.NewOrchestrator(state, gameModel, judgeModel, compactor, speechIn, speechOut)
101 }
102
103-// warmupSystems lists the three static system prompts the game will send: this
104-// scenario's game-loop prompt, the judge, and the compaction prompt. Warming
105-// them pins each prefix in the server's KV cache.
106-func warmupSystems(seed string) []string {
107- return []string{
108- llm.GameSystemPrompt(seed),
109- llm.JudgeSystemPrompt(),
110- llm.CompactionPrompt(),
111- }
112-}
113-
114 func fatalf(format string, args ...any) {
115 fmt.Fprintf(os.Stderr, "jp: "+format+"\n", args...)
116 os.Exit(1)
117diff --git a/docs/services.md b/docs/services.md
118index e5b902bbb0fb74884346e4695c63c0c207dfb29b..3c7d4ae798af5aa0b7aede4fabe3cb073e4643a0 100644
119--- a/docs/services.md
120+++ b/docs/services.md
121@@ -36,8 +36,29 @@ Model: `unsloth/Qwen3-8B-GGUF:UD-Q4_K_XL`, resolvable by the router as `jp`. The
122 sends OpenAI-compatible requests with `model` set to `jp`, disables Qwen3
123 thinking on every request (`chat_template_kwargs.enable_thinking: false`),
124 sets `cache_prompt: true` so the server keeps prompt prefixes cached between
125-turns, and reads streaming SSE. The model chat template (`--jinja`) is used;
126-ChatML is not built manually by the client.
127+turns, pins each request to a llama-server slot via `id_slot` (see below), and
128+reads streaming SSE. The model chat template (`--jinja`) is used; ChatML is
129+not built manually by the client.
130+
131+### Slot pinning
132+
133+Each request body carries an `id_slot` field that assigns the task to a
134+specific llama-server slot, bypassing the server's LRU/similarity
135+auto-selection. The game uses two slots so scratch requests can never evict
136+the game loop's cached context:
137+
138+| Client use | Flag | Env var | Default |
139+| -------------------------------------------- | ---------------------- | --------------------- | ------- |
140+| Game loop (world replies) | `--llm-game-slot` | `JP_LLM_GAME_SLOT` | `0` |
141+| Scratch (judge, compaction; warmup included) | `--llm-scratch-slot` | `JP_LLM_SCRATCH_SLOT` | `1` |
142+
143+Warmup follows the same split: the game system prompt warms on the game
144+client, the judge and compaction prompts warm on the scratch client, so initial
145+cache placement is deterministic per slot.
146+
147+**Fallback:** if the router does not honor `id_slot`, set both env vars to `-1`
148+(`JP_LLM_GAME_SLOT=-1 JP_LLM_SCRATCH_SLOT=-1`). The server then auto-selects a
149+slot by prompt similarity (`-sps`, default 0.10) and falls back to LRU.
150
151 At startup, before anything else loads, the game preloads the model with
152 `POST {JP_LLM_BASE_URL}/models/load` and body `{"model": "jp"}` (30s timeout).
153diff --git a/internal/config/config.go b/internal/config/config.go
154index 58876d5762901141cf1e186846e1f080e2eec84c..09d040f2f8774ce4665db4729b15430c85bdc054 100644
155--- a/internal/config/config.go
156+++ b/internal/config/config.go
157@@ -17,6 +17,8 @@ type LLMConfig struct {
158 Temperature float64 `long:"temperature" env:"TEMPERATURE" default:"1.0"`
159 MaxTokens int `long:"max-tokens" env:"MAX_TOKENS" default:"256"`
160 EnableThinking bool `long:"enable-thinking" env:"ENABLE_THINKING"`
161+ GameSlot int `long:"llm-game-slot" env:"JP_LLM_GAME_SLOT" default:"0" description:"llama-server slot for game-loop requests (-1 = server decides)"`
162+ ScratchSlot int `long:"llm-scratch-slot" env:"JP_LLM_SCRATCH_SLOT" default:"1" description:"llama-server slot for judge and compaction requests (-1 = server decides)"`
163 }
164
165 // Config is the shared option group. Parsed with github.com/jessevdk/go-flags.
166@@ -28,7 +30,7 @@ type Config struct {
167 STTLanguage string `long:"stt-language" env:"JP_STT_LANGUAGE" default:"ja" description:"Whisper request language (ISO 639-1 code)"`
168 RecordCommand string `long:"record-command" default:"arecord" description:"Recorder command for 16kHz mono S16_LE WAV capture"`
169
170- Scenario string `long:"scenario" env:"JP_SCENARIO_PATH" default:"assets/scenarios/city.json" description:"path to the scenario seed JSON"`
171+ Scenario string `long:"scenario" env:"JP_SCENARIO_PATH" default:"assets/scenarios/small_city.md" description:"path to the scenario brief file"`
172 }
173
174 func ParseConfig() *Config {
175diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
176index e05c606d1975019c949506e401849a043239f2e9..1b0696a44dd87c49e6e645985fee000ec9bec517 100644
177--- a/internal/game/orchestrator.go
178+++ b/internal/game/orchestrator.go
179@@ -78,7 +78,7 @@ func NewOrchestrator(state *State, g GameModel, j JudgeModel, c Compactor, in Sp
180 compactor: c,
181 speechIn: in,
182 speechOut: out,
183- staticChars: utf8.RuneCountInString(llm.GameSystemPrompt(state.Seed())),
184+ staticChars: utf8.RuneCountInString(llm.GameSystemPrompt(state.Brief())),
185 budget: llm.DefaultHistoryPolicy().PromptCharBudget,
186 }
187 }
188diff --git a/internal/game/state.go b/internal/game/state.go
189index 4a5b071f09f452f26b614135e920218d74d71b66..b3b116441fdd2e5e5a0da215e3ae1138b1c39338 100644
190--- a/internal/game/state.go
191+++ b/internal/game/state.go
192@@ -50,25 +50,35 @@ type DisplayEntry struct {
193 ShowEnglish bool
194 }
195
196+// sheetEntry is one stored character sheet, kept in first-met order so injected
197+// lines stay stable across turns (KV cache prefix). location and talk are
198+// normalized (trimmed + lowercased); text is the canonical single-line sheet.
199+type sheetEntry struct {
200+ location string
201+ talk string
202+ text string
203+}
204+
205 // State is the pure, in-memory session. The LLM owns world consistency; State
206 // owns only segmentation (location boundaries), conversation (last TALK), the
207 // over-long-segment compaction bookkeeping, and the display log. No I/O here.
208 type State struct {
209- seed string
210+ brief string
211 talk string
212 closed []Segment
213 open Segment
214 display []DisplayEntry
215+ sheets []sheetEntry
216 }
217
218-// NewState builds a session from an authored world seed. The open segment is
219+// NewState builds a session from the scenario brief. The open segment is
220 // empty until the first reply sets its location.
221-func NewState(seed string) *State {
222- return &State{seed: seed, talk: "none"}
223+func NewState(brief string) *State {
224+ return &State{brief: brief, talk: "none"}
225 }
226
227-// Seed returns the authored world seed (for building the system prompt/warmup).
228-func (s *State) Seed() string { return s.seed }
229+// Brief returns the scenario brief (for building the system prompt/warmup).
230+func (s *State) Brief() string { return s.brief }
231
232 // Location returns the player's current location id ("" before the first reply).
233 func (s *State) Location() string { return s.open.Location }
234@@ -147,12 +157,50 @@ func (s *State) RegisterSummary(location, summary string) {
235 s.closed = append(s.closed, Segment{Location: location, Summary: summary})
236 }
237
238+// RegisterSheet stores one character sheet in first-met order. The key is the
239+// normalized location + talk pair; if that pair already has a sheet this is a
240+// no-op (the first sheet wins as the consistency anchor).
241+func (s *State) RegisterSheet(location, talk, text string) {
242+ loc := normalizeLocation(location)
243+ tk := normalizeLocation(talk)
244+ for _, e := range s.sheets {
245+ if e.location == loc && e.talk == tk {
246+ return
247+ }
248+ }
249+ s.sheets = append(s.sheets, sheetEntry{location: loc, talk: tk, text: text})
250+}
251+
252+// SheetFor returns the stored sheet for a location + talk pair and whether it
253+// exists. Both inputs are normalized before lookup.
254+func (s *State) SheetFor(location, talk string) (string, bool) {
255+ loc := normalizeLocation(location)
256+ tk := normalizeLocation(talk)
257+ for _, e := range s.sheets {
258+ if e.location == loc && e.talk == tk {
259+ return e.text, true
260+ }
261+ }
262+ return "", false
263+}
264+
265+// sheetsAt returns the stored sheets for one normalized location, in order.
266+func (s *State) sheetsAt(location string) []sheetEntry {
267+ var out []sheetEntry
268+ for _, e := range s.sheets {
269+ if e.location == location {
270+ out = append(out, e)
271+ }
272+ }
273+ return out
274+}
275+
276 // Prompt reconstructs the full game-loop message list for the next turn: the
277 // system prompt, then (only when prior context exists) one user "context" message
278 // holding all closed-segment recaps plus the open segment's prefix, then the open
279 // segment's raw messages. Pure; it does not mutate state.
280 func (s *State) Prompt() []llm.Message {
281- msgs := []llm.Message{{Role: llm.RoleSystem, Content: llm.GameSystemPrompt(s.seed)}}
282+ msgs := []llm.Message{{Role: llm.RoleSystem, Content: llm.GameSystemPrompt(s.brief)}}
283
284 var ctx strings.Builder
285 for _, seg := range s.closed {
286@@ -160,6 +208,12 @@ func (s *State) Prompt() []llm.Message {
287 fmt.Fprintf(&ctx, "Recap (%s): %s\n", seg.Location, seg.Summary)
288 }
289 }
290+ if sheets := s.sheetsAt(s.open.Location); len(sheets) > 0 {
291+ ctx.WriteString("Character sheets (internal; never quote or describe these):\n")
292diff --git a/internal/llm/client.go b/internal/llm/client.go
293index 4800a7ae2a2e59a9f0f8bc4b45fd41d735c8c4ab..c13cc8c70c26bc30c075a04836496c1ee958bb0b 100644
294--- a/internal/llm/client.go
295+++ b/internal/llm/client.go
296@@ -37,6 +37,9 @@ type Client struct {
297 Temperature float64
298 MaxTokens int
299 EnableThinking bool
300+ // Slot pins every request (including warmup) to a specific llama-server
301+ // slot via the id_slot field. -1 lets the server choose.
302+ Slot int
303 }
304
305 func NewClient(cfg *config.Config, hc *http.Client) *Client {
306@@ -61,6 +64,7 @@ type chatRequest struct {
307 MaxTokens int `json:"max_tokens"`
308 Stream bool `json:"stream"`
309 CachePrompt bool `json:"cache_prompt"`
310+ IDSlot int `json:"id_slot"`
311 }
312
313 type sseDelta struct {
314@@ -103,6 +107,7 @@ func (c *Client) chat(ctx context.Context, msgs []Message, maxTokens int) (strin
315 MaxTokens: maxTokens,
316 Stream: true,
317 CachePrompt: true,
318+ IDSlot: c.Slot,
319 }
320 payload, err := json.Marshal(body)
321 if err != nil {
322diff --git a/internal/llm/contract.go b/internal/llm/contract.go
323index 6d4007a18388946bd2973fbfb10d0423443fbb8a..f6bf6cc4617d20944c1ad9f9ab343a663a917fb3 100644
324--- a/internal/llm/contract.go
325+++ b/internal/llm/contract.go
326@@ -49,6 +49,7 @@ func kindString(k ContractErrorKind) string {
327 var (
328 judgeFieldNames = []string{"SCORE", "ROMAJI", "FEEDBACK"}
329 gameFieldNames = []string{"LOCATION", "TALK", "DESC", "ROMAJI", "KANA", "ENGLISH"}
330+ sheetFieldNames = []string{"ROLE", "TRAITS", "MOOD", "STYLE", "UNCLEAR", "SAMPLE"}
331 )
332
333 // parseFields splits a raw model reply into FIELD|value lines. Surrounding
334@@ -167,3 +168,44 @@ func ParseGameReply(raw string) (GameReply, error) {
335 English: vals["ENGLISH"],
336 }, nil
337 }
338+
339+// Sheet is one person's compact character profile, parsed from the sheet
340+// contract. All six fields are required and non-empty.
341+type Sheet struct {
342+ Role string // role, age range, one identifying appearance phrase
343+ Traits string // two to three personality traits
344+ Mood string // mood today plus what they want or need right now
345+ Style string // communication style: sentence length, formality, energy
346+ Unclear string // how they handle unclear input: confirm, press on, impatient
347+ Sample string // one short sample line in their voice, romaji
348+}
349+
350+// ParseSheet parses the six-field character-sheet contract. All six fields are
351+// required and non-empty; a malformed reply is a recoverable *ContractError.
352+func ParseSheet(raw string) (Sheet, error) {
353+ vals, err := parseFields(raw, sheetFieldNames)
354+ if err != nil {
355+ return Sheet{}, err
356+ }
357+ if err := requireFields(vals, sheetFieldNames); err != nil {
358+ return Sheet{}, err
359+ }
360+ return Sheet{
361+ Role: vals["ROLE"],
362+ Traits: vals["TRAITS"],
363+ Mood: vals["MOOD"],
364+ Style: vals["STYLE"],
365+ Unclear: vals["UNCLEAR"],
366+ Sample: vals["SAMPLE"],
367+ }, nil
368+}
369+
370+// Text renders the sheet as one space-joined line of its six fields, in order.
371+func (s Sheet) Text() string {
372+ return "ROLE|" + s.Role +
373+ " TRAITS|" + s.Traits +
374+ " MOOD|" + s.Mood +
375+ " STYLE|" + s.Style +
376+ " UNCLEAR|" + s.Unclear +
377+ " SAMPLE|" + s.Sample
378+}
379diff --git a/internal/llm/prompt.go b/internal/llm/prompt.go
380index 50d7eac2d6f1f57d4fd2949f672e72aefa1b7f27..22d2fa5502ad331548e647f636b1d97898c02459 100644
381--- a/internal/llm/prompt.go
382+++ b/internal/llm/prompt.go
383@@ -27,13 +27,6 @@ FEEDBACK|It's a polite greeting, but you can use a more specific greeting for th
384 // JudgeSystemPrompt returns the static judge system message.
385 func JudgeSystemPrompt() string { return judgeSystemTemplate }
386
387-func fallback(value, def string) string {
388- if strings.TrimSpace(value) == "" {
389- return def
390- }
391- return value
392-}
393-
394 // BuildJudgeMessages assembles the separate, persona-free judge request from
395 // the raw transcript. It carries no game or NPC wording.
396 func BuildJudgeMessages(transcript string) []Message {
397@@ -43,24 +36,32 @@ func BuildJudgeMessages(transcript string) []Message {
398 }
399 }
400
401-// gameSystemTemplate is the game-loop system message. It wraps the authored
402-// world seed and fixes the six-field reply contract for advancing the scene.
403+// gameSystemTemplate is the game-loop system message. It wraps the scenario
404+// brief and fixes the six-field reply contract for advancing the scene.
405diff --git a/internal/scenario/scenario.go b/internal/scenario/scenario.go
406index 7bd52c1df66b78e34b79dac9e1ffde3332915740..d2c2c4757be5357b3c854a123f883916c1348966 100644
407--- a/internal/scenario/scenario.go
408+++ b/internal/scenario/scenario.go
409@@ -1,42 +1,37 @@
410-// Package scenario holds the authored world seed that opens the game-loop LLM
411-// context. A scenario names the town, the starting situation, and a few key
412-// people and places; the model may expand on these facts but must stay
413-// consistent with them.
414+// Package scenario loads the plain-text brief that opens the game-loop LLM
415+// context. A scenario file is one to three sentences describing place, time,
416+// and vibe; the model invents everything else on the fly. The file name (base
417+// without extension) is the scenario name.
418 package scenario
419
420 import (
421- "bytes"
422- "encoding/json"
423 "fmt"
424 "os"
425+ "path/filepath"
426 "strings"
427 )
428
429-// Scenario is one authored world: a display name plus the seed text that opens
430-// the game-loop system prompt.
431+// Scenario is one brief: a display name plus the text that opens the
432+// game-loop system prompt.
433 type Scenario struct {
434- Name string `json:"name"`
435- Seed string `json:"seed"`
436+ Name string
437+ Brief string
438 }
439
440-// Load reads and validates a scenario from path. Unknown fields are rejected so
441-// malformed data fails to load with a clear error.
442+// Load reads and validates a scenario from path. Name is the file's base name
443+// without extension; Brief is the trimmed file content. Both must be non-empty.
444 func Load(path string) (Scenario, error) {
445 data, err := os.ReadFile(path)
446 if err != nil {
447 return Scenario{}, fmt.Errorf("scenario: read %s: %w", path, err)
448 }
449- var s Scenario
450- dec := json.NewDecoder(bytes.NewReader(data))
451- dec.DisallowUnknownFields()
452- if err := dec.Decode(&s); err != nil {
453- return Scenario{}, fmt.Errorf("scenario: decode: %w", err)
454- }
455- if s.Name == "" {
456+ brief := strings.TrimSpace(string(data))
457+ name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
458+ if name == "" {
459 return Scenario{}, fmt.Errorf("scenario: name must not be empty")
460 }
461- if strings.TrimSpace(s.Seed) == "" {
462- return Scenario{}, fmt.Errorf("scenario: seed must not be empty")
463+ if brief == "" {
464+ return Scenario{}, fmt.Errorf("scenario: brief must not be empty")
465 }
466- return s, nil
467+ return Scenario{Name: name, Brief: brief}, nil
468 }