Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Game profile ships mechanisms; demo defaults move to `examples/game`

Breaking for `soul_protocol.profiles.game` callers:

- **`GrudgeKernel.birth(name, archetype, ...)`** — `name` and `archetype` are required. `persona` defaults to `"I am <name>, <archetype>."` instead of Bjorn's text; `GrudgeKernel.awaken(..., persona=None)` likewise.
- **`DialogueEngine.speak()` takes `npc_name`.** `TemplatedDialogueEngine` lines are NPC-name-aware and trade-neutral (as `speak_reputation` already was), and `LLMDialogueEngine.build_prompt()` is built from `persona` + `npc_name` rather than a hardcoded butcher. Custom engines must accept the new keyword.
- **`PRICING` is empty.** `CostMeter(generate, model, pricing=None)` reads a caller-supplied `{model: (in_rate, out_rate)}` table, or the module-level `PRICING` dict if you populate it at startup. An unconfigured meter raises `unknown model`.
- **`DEFAULT_ZONE` is `"start"`** and `GameWorld(..., start_zone=DEFAULT_ZONE)` lets you pick the label.

Bjorn the Butcher (name, archetype, persona, scripted lines as `ButcherDialogueEngine`, `birth_bjorn()`), the `"tavern"` zone and the sample vendor rate table now live in `examples/game/`; `examples/npc_soul_grudge` and `examples/butcher_remembers` import from there.

### Architecture debt — extract health, rename list (#288)

- **Refactor:** Extracted duplicated health-audit and cleanup logic from `cli/main.py` and `mcp/server.py` into a new shared module `runtime/health.py`. Both entry points now delegate to `audit_health()`, `plan_cleanup()`, and `execute_cleanup()`.
Expand Down
15 changes: 9 additions & 6 deletions examples/butcher_remembers/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
import json
import os
import re
import sys
import tempfile
import threading
import time
Expand All @@ -71,6 +72,10 @@
)

ROOT = Path(__file__).resolve().parent
# Runnable as a script from anywhere and loadable by path from the tests.
sys.path.insert(0, str(ROOT.parent.parent))
from examples.game import PRICING, TAVERN, birth_bjorn # noqa: E402

DEFAULT_PORT = 8777

# Static whitelist: path -> (filename in this folder, content type). Nothing
Expand Down Expand Up @@ -183,7 +188,7 @@ def build_engine(requested: str):
warning and fall back to templated — never crash the demo.
"""
if requested == "claude":
meter = CostMeter(claude_cli_generate, model="claude-cli")
meter = CostMeter(claude_cli_generate, model="claude-cli", pricing=PRICING)
return "claude", LLMDialogueEngine(meter), meter
if requested == "deepseek":
if not os.environ.get("DEEPSEEK_API_KEY"):
Expand All @@ -193,7 +198,7 @@ def build_engine(requested: str):
flush=True,
)
return "templated", None, None
meter = CostMeter(deepseek_generate, model="deepseek-v3.2")
meter = CostMeter(deepseek_generate, model="deepseek-v3.2", pricing=PRICING)
return "deepseek", LLMDialogueEngine(meter), meter
return "templated", None, None

Expand Down Expand Up @@ -253,17 +258,15 @@ async def replace_npc(world: GameWorld, kernel: GrudgeKernel) -> str:

async def build_world(session_path: str | None = None, dialogue_engine=None) -> GameWorld:
"""The Butcher world: two npc.souls + one player.soul, zoned for the canvas."""
bjorn = await GrudgeKernel.birth(
name="Bjorn", archetype="The Butcher", dialogue_engine=dialogue_engine
)
bjorn = await birth_bjorn(dialogue_engine)
astrid = await GrudgeKernel.birth(
name="Astrid",
archetype="The Innkeeper",
persona=ASTRID_PERSONA,
dialogue_engine=dialogue_engine,
)
ragnar = await PlayerSoul.birth(name="Ragnar")
world = GameWorld([bjorn, astrid], [ragnar], session_path=session_path)
world = GameWorld([bjorn, astrid], [ragnar], session_path=session_path, start_zone=TAVERN)
world.move("Bjorn", "stall")
world.move("Astrid", "tables")
world.move("Ragnar", "door")
Expand Down
75 changes: 75 additions & 0 deletions examples/game/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# __init__.py — Demo content for the Game Profile: Bjorn the butcher, the
# tavern, and a vendor pricing table.
#
# Created: 2026-09-11 (fix/game-profile-demo-defaults) — The profile under
# soul_protocol.profiles.game ships mechanisms only (grudge kernel, dialogue
# seam, world, cost meter). Everything that used to be a baked-in default
# there (the Bjorn name/archetype/persona, his scripted lines, the "tavern"
# start zone, the $/1M-token vendor rates) lives here so the examples can
# keep telling the Butcher story without the library carrying it.

from __future__ import annotations

from soul_protocol.profiles.game import NONE, SLIGHTED, GrudgeKernel, TemplatedDialogueEngine

BJORN_NAME = "Bjorn"
BJORN_ARCHETYPE = "The Butcher"
BJORN_PERSONA = (
"I am Bjorn, a proud, gruff medieval butcher. I keep an honest stall and a long memory."
)

# Every soul starts here until GameWorld.move() re-homes it.
TAVERN = "tavern"

# $ per 1M tokens as (input_rate, output_rate). claude-cli (local, no key) and
# gemini-nano (on-device) are $0; the meter still counts their tokens so
# CostMeter.project() can re-price the same session under a paid model.
PRICING: dict[str, tuple[float, float]] = {
"deepseek-v3.2": (0.14, 0.28),
"gemini-flash-lite": (0.10, 0.40),
"gemini-nano": (0.0, 0.0),
"claude-cli": (0.0, 0.0),
}


class ButcherDialogueEngine(TemplatedDialogueEngine):
"""Bjorn's scripted lines. Reputation lines fall through to the generic engine."""

async def speak(
self,
*,
npc_name: str,
persona: str,
ocean: dict[str, float],
grudge_level: str,
grievances: list[str],
player_line: str,
player_name: str | None = None,
) -> str:
who = player_name or "stranger"
if grudge_level == NONE:
return (
f"Bjorn wipes his hands and smiles. 'Welcome to my stall, {who}! "
"Finest cuts in town.'"
)
if grudge_level == SLIGHTED:
return (
f"Bjorn's smile thins. He keeps one eye on {who}. "
"'...You again. State your business and be quick about it.'"
)
cited = ", ".join(grievances[:3]) if grievances else "what you did"
return (
f"Bjorn's cleaver thuds into the block. 'You have the gall to show "
f"your face here, {who}? I remember {cited}. You'll get nothing from "
"me but the door.'"
)


async def birth_bjorn(dialogue_engine=None) -> GrudgeKernel:
"""Bjorn the Butcher; templated lines are his own unless an engine is passed."""
return await GrudgeKernel.birth(
name=BJORN_NAME,
archetype=BJORN_ARCHETYPE,
persona=BJORN_PERSONA,
dialogue_engine=dialogue_engine or ButcherDialogueEngine(),
)
20 changes: 14 additions & 6 deletions examples/npc_soul_grudge/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from __future__ import annotations

import asyncio
import sys
import tempfile
import time
from pathlib import Path
Expand All @@ -52,6 +53,9 @@
claude_cli_generate,
)

sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from examples.game import BJORN_PERSONA, PRICING, ButcherDialogueEngine, birth_bjorn # noqa: E402

# Stable fake player DIDs — in a real game these are the player.soul identities.
RAGNAR = "did:soul:player:ragnar" # the wrongdoer
ASTRID = "did:soul:player:astrid" # an innocent newcomer (control)
Expand All @@ -76,7 +80,7 @@ async def step(kernel: GrudgeKernel, did: str, name: str, text: str, kind: str)

async def main() -> None:
rule("SESSION 1 — Bjorn meets Ragnar, and Ragnar wrongs him")
kernel = await GrudgeKernel.birth()
kernel = await birth_bjorn()
print(f"Bjorn born: {kernel.soul.name} the {kernel.soul.archetype} (did={kernel.soul.did})")
print(f"Starting bond with Ragnar: {kernel.bond_strength(RAGNAR):.1f}")

Expand Down Expand Up @@ -116,7 +120,9 @@ async def main() -> None:
del kernel # the in-memory NPC is gone; only the file remains

rule("SESSION 2 — a fresh process AWAKENS Bjorn from the file")
reborn = await GrudgeKernel.awaken(soul_path)
reborn = await GrudgeKernel.awaken(
soul_path, dialogue_engine=ButcherDialogueEngine(), persona=BJORN_PERSONA
)
ragnar_bond_after = reborn.bond_strength(RAGNAR)
ragnar_grudge_after = await reborn.grudge_level(RAGNAR)
print(f"Awakened {reborn.soul.name} from disk (memories={reborn.soul.memory_count})")
Expand Down Expand Up @@ -204,9 +210,9 @@ async def run_live_section() -> None:
print("no API key needed here. ~10s per line. Falls back to templated on error.\n")

# The meter wraps the raw generate at the seam — every live line is priced.
meter = CostMeter(claude_cli_generate, model="claude-cli")
meter = CostMeter(claude_cli_generate, model="claude-cli", pricing=PRICING)
engine = LLMDialogueEngine(meter)
kernel = await GrudgeKernel.birth(dialogue_engine=engine)
kernel = await birth_bjorn(engine)

print(f"Bjorn born: {kernel.soul.name} the {kernel.soul.archetype}")
print(f"Starting bond with Ragnar: {kernel.bond_strength(RAGNAR):.1f}")
Expand All @@ -222,7 +228,9 @@ async def run_live_section() -> None:
del kernel

# Same meter across the reload — the summary covers the WHOLE session.
reborn = await GrudgeKernel.awaken(soul_path, dialogue_engine=LLMDialogueEngine(meter))
reborn = await GrudgeKernel.awaken(
soul_path, dialogue_engine=LLMDialogueEngine(meter), persona=BJORN_PERSONA
)
grievances = await reborn.grievances(RAGNAR)
print(
f"Awakened Bjorn from disk — grudge={await reborn.grudge_level(RAGNAR)}, "
Expand Down Expand Up @@ -282,7 +290,7 @@ async def run_reputation_section() -> None:

# Ragnar is a real player.soul now, not just a DID. He wrongs Bjorn; the deed
# is written to BOTH souls — Bjorn's grievance AND Ragnar's own reputation.
bjorn = await GrudgeKernel.birth()
bjorn = await birth_bjorn()
ragnar = await PlayerSoul.birth(name="Ragnar")
await bjorn.record(RAGNAR, "I framed you to the guards.", kind="betrayal", player_soul=ragnar)
await bjorn.record(RAGNAR, "*steals sausages*", kind="theft", player_soul=ragnar)
Expand Down
5 changes: 3 additions & 2 deletions spec/profiles/game.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,9 @@ or any error), or anything else satisfying the Protocol. `claude_cli_generate`
is the reference's zero-key backend.

Cost instrumentation composes at the same seam, not inside the engines:
`CostMeter(generate, model=...)` meters tokens, latency, and $ against
`PRICING` (with `summary()`, `cost_per_player_hour()`, and `project(model)`
`CostMeter(generate, model=..., pricing=...)` meters tokens, latency, and $
against a caller-supplied `{model: (in_rate, out_rate)}` table; the profile
ships no vendor rates (with `summary()`, `cost_per_player_hour()`, and `project(model)`
re-pricing), and `ReplayCache(generate, path=...)` makes a recorded session
replayable byte-identically at zero model cost. They stack:
`LLMDialogueEngine(CostMeter(ReplayCache(generate, path), model=...))`.
Expand Down
4 changes: 4 additions & 0 deletions src/soul_protocol/profiles/game/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# __init__.py — soul_protocol.profiles.game: the Game Profile public API.
# Updated: 2026-09-11 (fix/game-profile-demo-defaults) — The profile now
# ships mechanisms only: GrudgeKernel.birth requires name/archetype, the
# templated lines are name-aware, PRICING is empty, DEFAULT_ZONE is "start".
# Bjorn, the tavern and the vendor rates live in examples/game.
# Created: 2026-07-02 (experiment/npc-soul-grudge-kernel) — graduates the
# examples/npc_soul_grudge experiment into the first Soul Protocol profile.
# Re-exports the two roles (GrudgeKernel = npc, PlayerSoul = player), the
Expand Down
44 changes: 25 additions & 19 deletions src/soul_protocol/profiles/game/costmeter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
# costmeter.py — Cost meter + replay cache at the `generate` seam of the
#
# Updated: 2026-09-11 (fix/game-profile-demo-defaults) — PRICING ships empty;
# CostMeter takes pricing= (falls back to PRICING). The vendor rate table
# moved to examples/game.
# npc.soul grudge kernel.
#
# Created: 2026-07-02 (experiment/npc-soul-grudge-kernel) — Two SMALL composable
Expand Down Expand Up @@ -42,15 +46,12 @@
import time
from pathlib import Path

# $ per 1M tokens as (input_rate, output_rate). claude-cli (local, no key) and
# gemini-nano (on-device) are $0 — the meter still counts their tokens so
# project() can re-price the same session under a paid model.
PRICING: dict[str, tuple[float, float]] = {
"deepseek-v3.2": (0.14, 0.28),
"gemini-flash-lite": (0.10, 0.40),
"gemini-nano": (0.0, 0.0),
"claude-cli": (0.0, 0.0),
}
# $ per 1M tokens as {model: (input_rate, output_rate)}. The profile ships no
# vendor rates: pass ``pricing=`` to CostMeter, or populate this dict once at
# startup and every meter built without ``pricing=`` reads it. A $0 entry is
# still metered for tokens so project() can re-price under a paid model.
# (examples/game carries a sample table.)
PRICING: dict[str, tuple[float, float]] = {}


def _estimate_tokens(text: str) -> int:
Expand All @@ -61,14 +62,19 @@ def _estimate_tokens(text: str) -> int:
class CostMeter:
"""Awaitable passthrough that meters an async ``generate(prompt) -> str``.

``meter = CostMeter(generate, model="deepseek-v3.2")`` — then use ``meter``
anywhere the raw callable went: ``await meter(prompt)``. Per non-cached call
it records estimated tokens in/out, latency, and $ cost from PRICING.
``meter = CostMeter(generate, model="deepseek-v3.2", pricing=RATES)`` — then
use ``meter`` anywhere the raw callable went: ``await meter(prompt)``. Per
non-cached call it records estimated tokens in/out, latency, and $ cost
from ``pricing`` (``{model: (in_rate, out_rate)}`` per 1M tokens; defaults
to the module-level ``PRICING``, which is empty until you fill it).
"""

def __init__(self, generate, model: str = "claude-cli") -> None:
if model not in PRICING:
raise ValueError(f"unknown model {model!r}; expected one of {sorted(PRICING)}")
def __init__(
self, generate, model: str, pricing: dict[str, tuple[float, float]] | None = None
) -> None:
self.pricing = PRICING if pricing is None else pricing
if model not in self.pricing:
raise ValueError(f"unknown model {model!r}; expected one of {sorted(self.pricing)}")
self._generate = generate
self.model = model
self.calls = 0 # metered (real model) calls
Expand All @@ -92,7 +98,7 @@ async def __call__(self, prompt: str) -> str:

tokens_in = _estimate_tokens(prompt)
tokens_out = _estimate_tokens(reply)
in_rate, out_rate = PRICING[self.model]
in_rate, out_rate = self.pricing[self.model]
self.calls += 1
self.tokens_in += tokens_in
self.tokens_out += tokens_out
Expand Down Expand Up @@ -123,9 +129,9 @@ def cost_per_player_hour(self, lines_per_hour: int = 90) -> float:

def project(self, model: str) -> float:
"""What the SAME recorded traffic would cost under another model."""
if model not in PRICING:
raise ValueError(f"unknown model {model!r}; expected one of {sorted(PRICING)}")
in_rate, out_rate = PRICING[model]
if model not in self.pricing:
raise ValueError(f"unknown model {model!r}; expected one of {sorted(self.pricing)}")
in_rate, out_rate = self.pricing[model]
return (self.tokens_in * in_rate + self.tokens_out * out_rate) / 1_000_000


Expand Down
Loading
Loading