ttrpg-npc 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ttrpg_npc-1.0.0/.claude/settings.local.json +16 -0
- ttrpg_npc-1.0.0/.gitignore +10 -0
- ttrpg_npc-1.0.0/ARCHITECTURE.md +102 -0
- ttrpg_npc-1.0.0/CHANGELOG.md +59 -0
- ttrpg_npc-1.0.0/CLAUDE.md +58 -0
- ttrpg_npc-1.0.0/CONTRIBUTING.md +59 -0
- ttrpg_npc-1.0.0/LICENSE +661 -0
- ttrpg_npc-1.0.0/PKG-INFO +462 -0
- ttrpg_npc-1.0.0/README.md +425 -0
- ttrpg_npc-1.0.0/ROADMAP.md +69 -0
- ttrpg_npc-1.0.0/examples/glass-monolith/adventure.md +13 -0
- ttrpg_npc-1.0.0/examples/glass-monolith/characters/tokk.md +29 -0
- ttrpg_npc-1.0.0/examples/glass-monolith/characters/vess.md +29 -0
- ttrpg_npc-1.0.0/examples/glass-monolith/config.toml +9 -0
- ttrpg_npc-1.0.0/examples/glass-monolith/lore/vess/monolith.md +16 -0
- ttrpg_npc-1.0.0/examples/glass-monolith/secrets/vess.md +17 -0
- ttrpg_npc-1.0.0/examples/rusty-lantern/adventure.md +14 -0
- ttrpg_npc-1.0.0/examples/rusty-lantern/character.md +39 -0
- ttrpg_npc-1.0.0/examples/rusty-lantern/config.toml +9 -0
- ttrpg_npc-1.0.0/examples/rusty-lantern/lore/tavern.md +24 -0
- ttrpg_npc-1.0.0/examples/rusty-lantern/secrets.md +21 -0
- ttrpg_npc-1.0.0/pyproject.toml +75 -0
- ttrpg_npc-1.0.0/scripts/probe_narration.py +60 -0
- ttrpg_npc-1.0.0/scripts/probe_secrets.py +194 -0
- ttrpg_npc-1.0.0/scripts/record_demo.py +108 -0
- ttrpg_npc-1.0.0/scripts/render_samples.py +48 -0
- ttrpg_npc-1.0.0/src/npc/__init__.py +3 -0
- ttrpg_npc-1.0.0/src/npc/app.py +861 -0
- ttrpg_npc-1.0.0/src/npc/audio/__init__.py +0 -0
- ttrpg_npc-1.0.0/src/npc/audio/player.py +52 -0
- ttrpg_npc-1.0.0/src/npc/audio/recorder.py +211 -0
- ttrpg_npc-1.0.0/src/npc/cli.py +551 -0
- ttrpg_npc-1.0.0/src/npc/config.py +157 -0
- ttrpg_npc-1.0.0/src/npc/doctor.py +309 -0
- ttrpg_npc-1.0.0/src/npc/events.py +317 -0
- ttrpg_npc-1.0.0/src/npc/hotkey.py +127 -0
- ttrpg_npc-1.0.0/src/npc/llm.py +295 -0
- ttrpg_npc-1.0.0/src/npc/overlay.py +125 -0
- ttrpg_npc-1.0.0/src/npc/roster.py +156 -0
- ttrpg_npc-1.0.0/src/npc/session/__init__.py +0 -0
- ttrpg_npc-1.0.0/src/npc/session/history.py +29 -0
- ttrpg_npc-1.0.0/src/npc/session/logbook.py +97 -0
- ttrpg_npc-1.0.0/src/npc/session/lore.py +87 -0
- ttrpg_npc-1.0.0/src/npc/session/prompt.py +203 -0
- ttrpg_npc-1.0.0/src/npc/session/secrets.py +269 -0
- ttrpg_npc-1.0.0/src/npc/session/sentences.py +39 -0
- ttrpg_npc-1.0.0/src/npc/static/overlay.html +276 -0
- ttrpg_npc-1.0.0/src/npc/stt.py +135 -0
- ttrpg_npc-1.0.0/src/npc/templates/adventure.md +18 -0
- ttrpg_npc-1.0.0/src/npc/templates/character.md +40 -0
- ttrpg_npc-1.0.0/src/npc/templates/config.toml +50 -0
- ttrpg_npc-1.0.0/src/npc/templates/logbook.md +4 -0
- ttrpg_npc-1.0.0/src/npc/templates/secrets.md +34 -0
- ttrpg_npc-1.0.0/src/npc/tts.py +97 -0
- ttrpg_npc-1.0.0/tests/conftest.py +18 -0
- ttrpg_npc-1.0.0/tests/test_app_pipeline.py +818 -0
- ttrpg_npc-1.0.0/tests/test_cli_hotkey.py +119 -0
- ttrpg_npc-1.0.0/tests/test_cli_ux.py +171 -0
- ttrpg_npc-1.0.0/tests/test_config.py +97 -0
- ttrpg_npc-1.0.0/tests/test_doctor.py +141 -0
- ttrpg_npc-1.0.0/tests/test_examples.py +31 -0
- ttrpg_npc-1.0.0/tests/test_history.py +22 -0
- ttrpg_npc-1.0.0/tests/test_hotkey.py +48 -0
- ttrpg_npc-1.0.0/tests/test_integration.py +43 -0
- ttrpg_npc-1.0.0/tests/test_llm.py +302 -0
- ttrpg_npc-1.0.0/tests/test_logbook.py +74 -0
- ttrpg_npc-1.0.0/tests/test_lore.py +96 -0
- ttrpg_npc-1.0.0/tests/test_multi_npc.py +287 -0
- ttrpg_npc-1.0.0/tests/test_overlay.py +145 -0
- ttrpg_npc-1.0.0/tests/test_player.py +83 -0
- ttrpg_npc-1.0.0/tests/test_prompt.py +170 -0
- ttrpg_npc-1.0.0/tests/test_recorder.py +119 -0
- ttrpg_npc-1.0.0/tests/test_roster.py +189 -0
- ttrpg_npc-1.0.0/tests/test_secrets.py +162 -0
- ttrpg_npc-1.0.0/tests/test_sentences.py +40 -0
- ttrpg_npc-1.0.0/tests/test_stt.py +64 -0
- ttrpg_npc-1.0.0/tests/test_tts.py +72 -0
- ttrpg_npc-1.0.0/uv.lock +1156 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(uv run *)",
|
|
5
|
+
"Bash(git add *)",
|
|
6
|
+
"Bash(git restore *)",
|
|
7
|
+
"Bash(git commit -m ' *)",
|
|
8
|
+
"Bash(git push *)",
|
|
9
|
+
"Bash(curl -s --max-time 2 http://localhost:11434/api/version)",
|
|
10
|
+
"Bash(ollama list *)",
|
|
11
|
+
"Read(//home/kenneth/.local/share/npc/voices/**)",
|
|
12
|
+
"Bash(git -C /home/kenneth/documents/github/npc log --oneline -5)",
|
|
13
|
+
"Bash(grep -n \"^#\\\\{1,3\\\\} \\\\|!\\\\[\\\\|badge\\\\|shields.io\\\\|uv sync\\\\|pip install\\\\|git clone\" README.md)"
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
`npc` is a fully-offline voice agent that role-plays a TTRPG NPC: hold a
|
|
4
|
+
key, speak, and a local LLM answers aloud in character. This document is the
|
|
5
|
+
map for people who want to read or change the code.
|
|
6
|
+
|
|
7
|
+
## The pipeline
|
|
8
|
+
|
|
9
|
+
```mermaid
|
|
10
|
+
flowchart LR
|
|
11
|
+
PTT[push-to-talk\nevdev hotkey] --> REC[Recorder\nsounddevice]
|
|
12
|
+
REC --> STT[faster-whisper\nsv/en auto-detect]
|
|
13
|
+
STT --> PROMPT[prompt assembly\nsheet + lore + secrets\n+ logbook + GM notes]
|
|
14
|
+
PROMPT --> LLM[local LLM\nOllama / OpenAI-compatible]
|
|
15
|
+
LLM -->|token stream| SENT[sentence regrouper]
|
|
16
|
+
SENT --> CLEAN[dialogue sanitizer\nstrips narration & markers]
|
|
17
|
+
CLEAN --> TTS[Piper TTS]
|
|
18
|
+
TTS --> OUT[speaker]
|
|
19
|
+
LLM -. events .-> BUS
|
|
20
|
+
STT -. events .-> BUS
|
|
21
|
+
BUS[(event bus)] --> CLI[terminal UI]
|
|
22
|
+
BUS --> WS[overlay websocket\nOBS / table display]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Everything runs on one machine. Nothing ever calls the internet.
|
|
26
|
+
|
|
27
|
+
## Threads and state
|
|
28
|
+
|
|
29
|
+
Four threads, coordinated by `NPCApp` (`src/npc/app.py`), a state machine
|
|
30
|
+
`IDLE → RECORDING → PROCESSING → SPEAKING`:
|
|
31
|
+
|
|
32
|
+
1. **Main** — the `prompt_toolkit` REPL (`gm>`), where typed lines are
|
|
33
|
+
out-of-character GM instructions and `/commands`.
|
|
34
|
+
2. **Hotkey listener** — evdev key-down/key-up (terminals don't deliver
|
|
35
|
+
key-up, which is why this exists). Pressing the key while the NPC speaks
|
|
36
|
+
is barge-in: playback stops, recording starts.
|
|
37
|
+
3. **Worker** — a single queue serializes every turn (STT → LLM → TTS) and
|
|
38
|
+
every state-touching command, so nothing races.
|
|
39
|
+
4. **Audio callback** — sounddevice's thread, which only copies samples.
|
|
40
|
+
|
|
41
|
+
Voice input is *always* in-character player dialogue; the keyboard is
|
|
42
|
+
*always* the GM. That split is the core interaction design.
|
|
43
|
+
|
|
44
|
+
## Events, not prints
|
|
45
|
+
|
|
46
|
+
The app never prints. It emits typed events (`src/npc/events.py`) —
|
|
47
|
+
`PlayerSpoke`, `NpcReplied`, `SecretRevealRequested`, … — to one callback;
|
|
48
|
+
the CLI renders them, tests assert on them, and the overlay broadcasts them
|
|
49
|
+
as JSON. Two class-level flags form the exposure contract:
|
|
50
|
+
|
|
51
|
+
- `dm_only` — never leaves the machine (everything about secrets).
|
|
52
|
+
- `table_safe` — the only events broadcast when the overlay listens on the
|
|
53
|
+
LAN; GM notes, status, and errors are neither and stay home.
|
|
54
|
+
|
|
55
|
+
## The campaign directory
|
|
56
|
+
|
|
57
|
+
A campaign is a folder of plain markdown — the whole "database":
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
character.md who the NPC is (first # heading = display name)
|
|
61
|
+
characters/*.md or several NPCs; /npc switches at the table
|
|
62
|
+
adventure.md GM background
|
|
63
|
+
secrets.md GM-gated clues — see below
|
|
64
|
+
lore/ reference docs injected as established fact
|
|
65
|
+
logbook.md per-NPC session memory, written by the LLM
|
|
66
|
+
sessions/ raw transcripts, appended crash-safe every turn
|
|
67
|
+
config.toml models, voices, hotkey — all optional
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Every piece of per-NPC state (sheet, history, GM notes, logbook, secrets,
|
|
71
|
+
lore, voice) lives on one `CharacterSlot` (`src/npc/roster.py`); switching
|
|
72
|
+
NPCs swaps the slot. Memory is strictly per-NPC, in-session and across
|
|
73
|
+
sessions.
|
|
74
|
+
|
|
75
|
+
## Five guarantees
|
|
76
|
+
|
|
77
|
+
1. **Fully offline.** Whisper, the LLM, and Piper are all local; the only
|
|
78
|
+
network listener is the overlay, loopback-bound unless you opt in.
|
|
79
|
+
2. **Per-NPC isolation.** What players tell one NPC never reaches another —
|
|
80
|
+
not in the prompt, not in the logbook, not in the summaries.
|
|
81
|
+
3. **Secrets can't leak.** Gated clues never enter the prompt until the GM
|
|
82
|
+
approves at the console; the model cannot reveal text it has never seen.
|
|
83
|
+
(`scripts/probe_secrets.py` pins the behavior against a real model.)
|
|
84
|
+
4. **English out, always.** Players can speak Swedish; replies are forced to
|
|
85
|
+
English (the TTS voice's language) by prompt rules plus a detector with
|
|
86
|
+
a corrective re-ask — the prompt alone doesn't survive baiting.
|
|
87
|
+
5. **Crash-safe writes.** Logbooks and secrets write via temp-file +
|
|
88
|
+
`os.replace`; transcripts append per turn. A crash costs nothing.
|
|
89
|
+
|
|
90
|
+
## Seams
|
|
91
|
+
|
|
92
|
+
Every hardware/model dependency sits behind a small Protocol —
|
|
93
|
+
`Transcriber`, `Speaker`, `Recorder`, the LLM clients — and the entire test
|
|
94
|
+
suite (280+ tests) runs against fakes: no GPU, no microphone, no server.
|
|
95
|
+
`tests/test_app_pipeline.py` is the best starting point for how the pieces
|
|
96
|
+
meet; `npc doctor` is the runtime mirror of the same seams.
|
|
97
|
+
|
|
98
|
+
## Reading order
|
|
99
|
+
|
|
100
|
+
`events.py` → `app.py` → `session/prompt.py` → `roster.py`, then whichever
|
|
101
|
+
seam you care about (`stt.py`, `tts.py`, `llm.py`, `overlay.py`,
|
|
102
|
+
`session/secrets.py`, `session/lore.py`).
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.0.0] — 2026-07-19
|
|
9
|
+
|
|
10
|
+
First public release, published to PyPI as
|
|
11
|
+
[`ttrpg-npc`](https://pypi.org/project/ttrpg-npc/) (the installed command is
|
|
12
|
+
`npc`). Everything below works fully offline.
|
|
13
|
+
|
|
14
|
+
### Voice pipeline
|
|
15
|
+
|
|
16
|
+
- Push-to-talk (hold) and tap-to-talk (voice-activity detection) recording via
|
|
17
|
+
evdev — works on Wayland, barge-in interrupts a speaking NPC.
|
|
18
|
+
- Speech-to-text with faster-whisper, Swedish/English auto-detect, with a
|
|
19
|
+
three-layer guard against whisper hallucinations on near-silence (energy
|
|
20
|
+
gate, no-speech segment filter, phantom-phrase blocklist).
|
|
21
|
+
- Local LLM replies via Ollama (native) or any OpenAI-compatible server
|
|
22
|
+
(Jan, LM Studio, llama.cpp, vLLM), with timeouts and retry.
|
|
23
|
+
- Text-to-speech with Piper (British English `en_GB-alba-medium` by default),
|
|
24
|
+
streamed sentence-by-sentence for ~3.4× faster time-to-first-sound.
|
|
25
|
+
- Replies locked to in-character, voice-only English: narration/stage-direction
|
|
26
|
+
sanitizer and a foreign-language re-ask that survives direct baiting.
|
|
27
|
+
|
|
28
|
+
### At the table
|
|
29
|
+
|
|
30
|
+
- Multiple NPCs per campaign (`characters/`, `/npc <name>` switching), each
|
|
31
|
+
with its own Piper voice, memory, logbook, and session summaries.
|
|
32
|
+
- GM-gated secrets: locked clue bodies never enter the prompt until the GM
|
|
33
|
+
approves at the console (`/yes`, `/no`, `/later`, `/reveal`, `/secrets`) —
|
|
34
|
+
the model mechanically cannot leak text it never saw.
|
|
35
|
+
- Lore documents (`lore/`, .md/.txt/.pdf) injected as established fact, with
|
|
36
|
+
`[llm] num_ctx` support and doctor measuring the real prompt size.
|
|
37
|
+
- OBS/table overlay over WebSocket with an opt-in LAN mode for a tablet at
|
|
38
|
+
the table; DM-only events never cross the network.
|
|
39
|
+
- Crash-safe session transcripts and per-session logbook summaries.
|
|
40
|
+
|
|
41
|
+
### Tooling & onboarding
|
|
42
|
+
|
|
43
|
+
- `npc init` guided campaign wizard, campaign-aware quick-start on `npc run`,
|
|
44
|
+
grouped `/help`, did-you-mean command suggestions.
|
|
45
|
+
- `npc doctor [--fix]` checks (and safely sets up) models, voices, audio, and
|
|
46
|
+
permissions; prints the exact `num_ctx` to configure.
|
|
47
|
+
- `npc run --timings` per-stage latency instrumentation; last/avg in `/status`.
|
|
48
|
+
- Debug helpers: `npc say`, `npc transcribe`.
|
|
49
|
+
- Example campaign gallery (`examples/`), recorded demo, `ARCHITECTURE.md`.
|
|
50
|
+
|
|
51
|
+
### Pre-release history
|
|
52
|
+
|
|
53
|
+
Development milestones before this release were tracked internally as
|
|
54
|
+
v1.x (timeouts, hallucination guard, streaming), v2.0 (voice-only hardening,
|
|
55
|
+
tap-to-talk, overlay, doctor --fix, timings), v2.1 (multiple NPCs, gated
|
|
56
|
+
secrets, UTF-8 + onboarding, lore), and v2.2 (LAN overlay, docs) — see
|
|
57
|
+
`ROADMAP.md` for the map.
|
|
58
|
+
|
|
59
|
+
[1.0.0]: https://github.com/seetee/npc/releases/tag/v1.0.0
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project
|
|
6
|
+
|
|
7
|
+
`npc` — a fully-offline terminal voice agent that role-plays a TTRPG NPC at the game table. Hold a push-to-talk key → mic records → faster-whisper transcribes (Swedish/English auto-detect) → a local LLM via Ollama replies in character → Piper speaks it aloud (British English, `en_GB-alba-medium`). Voice input is ALWAYS in-character player dialogue; typed terminal lines are out-of-character GM instructions to the LLM. Design doc: `/home/kenneth/.claude/plans/the-project-is-as-squishy-pond.md`.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv sync --group dev --extra cuda # install (cuda extra = GPU whisper; this machine has an RTX 3060)
|
|
13
|
+
uv run pytest # unit tests — hardware-free, fakes injected
|
|
14
|
+
uv run pytest -m integration # real whisper/piper/ollama smoke tests (auto-skip if missing)
|
|
15
|
+
uv run pytest tests/test_app_pipeline.py::test_voice_turn_end_to_end # single test
|
|
16
|
+
uv run ruff check .
|
|
17
|
+
uv run npc init|doctor|run <campaign-dir> # scaffold / setup-check / play
|
|
18
|
+
uv run npc doctor --fix <dir> # also offer safe fixes (model pull, voice download); sudo fixes stay print-only
|
|
19
|
+
uv run npc run <dir> --timings --overlay # per-turn stage timings / OBS-overlay server
|
|
20
|
+
uv run npc say "text" <dir> / npc transcribe file.wav <dir> # debug TTS / STT
|
|
21
|
+
uv run python scripts/probe_narration.py # re-check the prompt against real Ollama after prompt/model changes
|
|
22
|
+
uv run python scripts/probe_secrets.py # re-check the secrets gate (markers, leaks) after locked_block/deny_note changes
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Architecture
|
|
26
|
+
|
|
27
|
+
The pipeline is orchestrated by `src/npc/app.py` (`NPCApp`) — a state machine (`IDLE → RECORDING → PROCESSING → SPEAKING`) with four threads: the prompt_toolkit REPL (main), an evdev hotkey listener (`hotkey.py`), a single worker that serializes STT → LLM → TTS turns via one `queue.Queue`, and sounddevice's callback thread. Push-to-talk during SPEAKING is barge-in (stops playback, starts recording); during PROCESSING it reports busy.
|
|
28
|
+
|
|
29
|
+
Multiple NPCs (`src/npc/roster.py`): the roster = legacy `character.md` (keeps `logbook.md`) ∪ `characters/*.md` (each gets `logbooks/<stem>.md`); a `CharacterSlot` holds ALL per-NPC state (sheet, history, OOC notes, own logbook, summary turn buffer, `[tts.voices]` voice) and `NPCApp.active` points at one — `history`/`npc_name`/etc. are delegating properties. Memory is strictly separate per NPC, in-session AND across sessions: summaries are per-NPC from per-NPC turn buffers (dirty-gated so checkpoints skip unchanged NPCs); the session number is the max across all logbooks. `/npc` switches serialize through the worker queue (like bare OOC lines — so a note typed right after `/npc` lands on the right character); the switch assigns `self.speaker` BEFORE `self.active` for barge-in coherence. Speakers are cached per voice via the `make_speaker` factory; ALL PiperSpeakers must share ONE `AudioPlayer` (the persistent-PCM-stream crash guarantee).
|
|
30
|
+
|
|
31
|
+
The app never prints: it emits typed events (`events.py` — `PlayerSpoke`, `NpcReplied`, `StateChanged`, `LogbookWritten`, …) to a single `on_event` callback; the CLI renders them with `format_event`, tests assert on event objects, and a future OBS overlay/web remote subscribes to the same stream (`StateChanged` is deliberately not rendered in the terminal). Emit events outside `self._lock`; `_emit` swallows subscriber exceptions so a broken subscriber can't kill a session.
|
|
32
|
+
|
|
33
|
+
Key seams (all Protocol-typed, faked in `tests/test_app_pipeline.py`):
|
|
34
|
+
- `audio/recorder.py` — `Recorder` protocol; `PushToTalkRecorder` (hold) and `VadRecorder` (`[hotkey] mode = "tap"`: tap to start, trailing silence or `stt.vad_max_seconds` fires `on_auto_stop(clip)` from a finalizer thread — the sounddevice callback never closes the stream, and an internal lock guarantees at-most-once delivery). `app.on_auto_stop` reuses the RECORDING→PROCESSING CAS so exactly one of {second tap, VAD} enqueues; tap-toggle semantics live in `cli._ptt_callbacks`, not the app. The pure `SilenceTracker` is the tunable part (threshold = `stt.silence_threshold_db`).
|
|
35
|
+
- `overlay.py` — `[overlay] enabled`/`npc run --overlay`: one daemon thread + private asyncio loop serving `static/overlay.html` over HTTP and broadcasting events as JSON over `/ws`. Binds `[overlay] listen` (default 127.0.0.1; LAN is an explicit opt-in). The exposure contract is TWO ClassVar tiers gated in `cli.py:on_event`: `dm_only` events never reach the websocket anywhere, and beyond loopback ONLY `table_safe` events cross (the assignment block at the end of events.py is the reviewable table-facing surface — GM notes/status/errors stay home). `cli.py:overlay_announcement` prints the tablet URL + warning (`lan_address` resolves 0.0.0.0 via the UDP-connect trick). `publish()` hops threads via `call_soon_threadsafe`; `websockets.broadcast` drops slow clients so OBS can never stall a turn; event class/field renames break external consumers.
|
|
36
|
+
- `stt.py` / `tts.py` / `llm.py` — `Transcriber`, `Speaker`, and the LLM clients. `llm.py:make_llm_client` picks the backend from `[llm].backend`: native `OllamaClient` (default) or `OpenAICompatClient` for Jan/LM Studio/llama.cpp/vLLM (tested via httpx MockTransport in `test_llm.py`). `stt.py` preloads pip-installed CUDA libs and falls back to CPU if CUDA breaks; resamples non-16kHz input. Whisper hallucinates YouTube outros/subtitle credits on near-silence, so three guards protect the LLM: an energy gate in `app.py:_handle_utterance` (`AudioClip.dbfs()` vs `stt.silence_threshold_db`, skips whisper entirely), a per-segment `no_speech_prob` filter (`stt.py:join_segments`), and the `PHANTOM_PHRASES` blocklist (`looks_like_hallucination`, en+sv, fires only when the WHOLE transcript is phantom).
|
|
37
|
+
|
|
38
|
+
Streaming replies (`[llm] stream = true`, the default): `chat_stream()` on both clients → `session/sentences.py:iter_sentences` regroups tokens into ≥25-char sentences → `strip_decoration` per sentence (empties skipped, so a leading `*bows*` is never spoken) → `tts.py:say_stream`, where synthesis (worker thread) runs up to 3 sentences ahead of playback (internal thread); `stop()` cancels both, so barge-in needs no special casing. History/transcript/`NpcReplied` get the whole cleaned reply AFTER the stream ends — after a barge-in, only the sentences the table heard. `NpcReplyChunk` events stream for overlays (CLI renders them as None). Connection failures retry only before the first token; a server that rejects streaming (`StreamingNotSupported`) falls back to non-streaming `chat()`.
|
|
39
|
+
|
|
40
|
+
Prompt assembly (`session/prompt.py`): system prompt is rebuilt every turn from `character.md` + `adventure.md` + logbook tail + accumulated OOC notes. OOC lines appear twice on purpose — inline in history (timing) and in the standing-instructions block (survives history trimming, `session/history.py`). The logbook (`session/logbook.py`) upserts one `## Session N — date` section per session (checkpoints/`/save`/`/end` re-summarize and replace, never duplicate); raw turns are appended crash-safe to `sessions/*-transcript.md`. Logbook writes are hardened against three failure modes — keep them that way: atomic temp-file+`os.replace` (crash can't truncate), `re.sub` with a lambda (backslashes in LLM output must stay literal), and demotion of `## Session N` lines inside summary bodies (would corrupt section parsing).
|
|
41
|
+
|
|
42
|
+
GM-gated secrets (`session/secrets.py`): `secrets.md` (legacy) / `secrets/<stem>.md` (NOT `characters/` — the roster glob would load it as a character) holds `## id` + `hint:` + optional `mode: hesitate|deflect` + body. THE LEAK GUARANTEE IS MECHANICAL: only hints enter the prompt (`locked_block`); the body joins `revealed_block` after the GM approves — the model can't leak text it never saw. The model flags a locked topic by ending its stall line with `[CHECK:id]`; detection runs on RAW LLM output (`find_markers`) because `strip_decoration`'s `_BRACKETED` rule deletes the marker from everything spoken/recorded (which is also why the marker needs no TTS special-casing — but `strip_markers` must run before `extract_dialogue`, whose de-asterisk fallback would otherwise speak it, and `MarkerScrubber` filters `NpcReplyChunk`, whose raw feed reaches the table screen, holding back split markers across chunks). Flow: `_handle_markers` sets `slot.pending_secret` (per-slot, survives `/npc`) + emits `SecretRevealRequested`; `/yes [rider]`→ worker `_resolve_secret`: write-back `revealed:` (atomic), then a delivery turn (`_deliver_secret` → shared `_reply_turn`) with a ONE-SHOT GM instruction (never a standing note); `/no [rider]`→ standing deny note via `_add_ooc(emit=False)`, denial is session-scoped, id in `slot.denied_secrets`. Secret events are `dm_only=True` (ClassVar) and `cli.py:on_event` skips `overlay.publish` for them — ids/hints must NEVER reach the table websocket; `SecretPondering` (hesitate mode only) is the content-free overlay signal. Re-validate wording changes to `locked_block` with `scripts/probe_secrets.py`.
|
|
43
|
+
|
|
44
|
+
Lore (`session/lore.py`): per-NPC reference docs — `lore/*` (legacy NPC; subdirs ignored) / `lore/<stem>/*` (`.md`/`.txt`/`.pdf`, pypdf lazy-imported, per-file failures collected not raised so a broken PDF can't block a session). Whole-document injection (tier 1, no RAG): `lore_block` goes into the prompt DIRECTLY after the character sheet — sheet+lore change only on `/reload`, keeping Ollama's KV prefix cache warm; mid-session-mutable blocks (secrets, logbook, OOC) come after. The budget is the context window: `[llm] num_ctx` (Ollama `options`, per-request → applies live on `/reload`; OpenAI-compat ignores it), `estimate_tokens` = chars//4, and TWO guards against Ollama's silent truncation of oversized prompts (which plays like the NPC forgetting its instructions): doctor's `npc_lore_checks` builds the real prompt and prints the `num_ctx` to set (`suggest_num_ctx`: 8192/16384/32768 with 25% headroom), and `app._warn_if_over_budget` warns once per session. Both gate on `config.llm.backend == "ollama"` (string compare — testable with fakes).
|
|
45
|
+
|
|
46
|
+
A campaign directory (see `src/npc/templates/`) is the unit of play; `config.py` loads its optional `config.toml` over dataclass defaults. The NPC's display name is the first `# heading` of `character.md`. `/reload` re-reads the markdown files AND `config.toml`: the LLM model applies live (it's per-request), while `[stt]`/`[tts]`/`[hotkey]` changes are flagged as needing a restart (`app.py:_reload_config`).
|
|
47
|
+
|
|
48
|
+
## Gotchas
|
|
49
|
+
|
|
50
|
+
- Terminals never deliver key-up events — that's why hotkeys use evdev (works on Wayland, needs the user in the `input` group). `evdev.list_devices()` silently hides devices you lack permission for; `find_ptt_devices` compensates.
|
|
51
|
+
- A held spacebar still types spaces into the REPL; `cli.py:_ptt_callbacks` snapshots/restores the prompt buffer around each press, and treats a press as typing (no recording) when the line already has text — so spaces typed inside `/say …` or an OOC note never trigger PTT. Both mitigations apply only to typing keys (`_key_types_text`); F12/right-ctrl/a grabbed USB button still allow PTT mid-line. A dedicated USB button should set `hotkey.grab = true`.
|
|
52
|
+
- In `config.toml` templates, top-level keys must stay above `[sections]` (TOML semantics).
|
|
53
|
+
- `sounddevice` needs the system package `libportaudio2` on Linux.
|
|
54
|
+
- Replies must always be English (Alba voice) even for Swedish input — enforced in `ROLE_FRAMING`, tested in `test_prompt.py`. The prompt alone does NOT survive direct baiting ("svara på svenska!" flips the model 4/4), so `looks_foreign` (session/prompt.py; foreign function words + non-ASCII letters, ≥2 combined hits) is the real lock: non-stream replies get one corrective re-ask, a streaming reply that OPENS foreign is abandoned before any audio (`_NotEnglish` → non-stream fallback → re-ask), and a mid-stream foreign sentence is skipped from TTS only. Verified live: the re-ask converts real flips to English.
|
|
55
|
+
- Replies must be voice-only: `ROLE_FRAMING` forbids narration/stage directions/assistant behavior (incl. a WRONG/RIGHT few-shot pair — 7B models need it), and `session/prompt.py:extract_dialogue` strips what small models slip through anyway (`*actions*`, `(parentheticals)`, speaker labels, quote wrapping) before the reply reaches history/transcript/TTS. When narration surrounds double-quoted speech, `_extract_quoted_dialogue` keeps only the quoted spans — the tell is terminal punctuation inside the quotes, so quoted titles (`"The Broken Crown"`) survive; the accepted false positive (quoting someone else loses framing words) is pinned in a test. Re-validate prompt changes with `scripts/probe_narration.py`.
|
|
56
|
+
- `TurnCompleted` timing events are emitted every turn but rendered only via `npc run --timings` (like `StateChanged`, they're overlay material); `/status` shows last/avg turn seconds.
|
|
57
|
+
|
|
58
|
+
License is AGPL-3.0-or-later — new dependencies must be compatible (Piper is GPL-3.0, fine).
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for your interest! This is a small project, but contributions are
|
|
4
|
+
welcome — bug reports, campaign examples, and code alike.
|
|
5
|
+
|
|
6
|
+
## Development setup
|
|
7
|
+
|
|
8
|
+
Requirements: Linux, Python 3.11+, [uv](https://docs.astral.sh/uv/), and the
|
|
9
|
+
system package `libportaudio2` (for sounddevice). A local
|
|
10
|
+
[Ollama](https://ollama.com) install is only needed for integration tests and
|
|
11
|
+
actual play.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
git clone https://github.com/seetee/npc.git
|
|
15
|
+
cd npc
|
|
16
|
+
uv sync --group dev # add `--extra cuda` for GPU whisper
|
|
17
|
+
uv run npc --version
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Tests
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
uv run pytest # unit tests — hardware-free, fakes injected
|
|
24
|
+
uv run pytest -m integration # real whisper/piper/ollama smoke tests
|
|
25
|
+
# (auto-skip when the services are missing)
|
|
26
|
+
uv run ruff check . # lint
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Unit tests must stay hardware-free: the STT/LLM/TTS/recorder seams are all
|
|
30
|
+
Protocol-typed and faked in `tests/test_app_pipeline.py` — follow that pattern
|
|
31
|
+
for new features.
|
|
32
|
+
|
|
33
|
+
## Prompt changes need probing
|
|
34
|
+
|
|
35
|
+
The system prompt is only as good as its measured behavior against a real
|
|
36
|
+
model. If you touch prompt wording, re-run the relevant probe against live
|
|
37
|
+
Ollama and include the numbers in your PR:
|
|
38
|
+
|
|
39
|
+
- `uv run python scripts/probe_narration.py` — after changes to
|
|
40
|
+
`ROLE_FRAMING` / narration handling in `src/npc/session/prompt.py`.
|
|
41
|
+
- `uv run python scripts/probe_secrets.py` — after changes to
|
|
42
|
+
`locked_block` / `deny_note` in `src/npc/session/secrets.py`.
|
|
43
|
+
|
|
44
|
+
## Architecture in one paragraph
|
|
45
|
+
|
|
46
|
+
The app never prints: `NPCApp` (`src/npc/app.py`) emits typed events
|
|
47
|
+
(`src/npc/events.py`) to a single callback; the CLI renders them, tests assert
|
|
48
|
+
on them, and the overlay broadcasts them. One worker thread serializes
|
|
49
|
+
STT → LLM → TTS turns. Read `ARCHITECTURE.md` for the full picture and
|
|
50
|
+
`CLAUDE.md` for the invariants that must not break (per-NPC memory isolation,
|
|
51
|
+
the secrets leak guarantee, the single shared `AudioPlayer`, DM-only event
|
|
52
|
+
tiers on the overlay).
|
|
53
|
+
|
|
54
|
+
## Pull requests
|
|
55
|
+
|
|
56
|
+
- Keep `uv run pytest` and `uv run ruff check .` green.
|
|
57
|
+
- New dependencies must be AGPL-3.0-or-later compatible.
|
|
58
|
+
- By contributing you agree your work is licensed under the project license,
|
|
59
|
+
AGPL-3.0-or-later (see `LICENSE`).
|