From 4a60171035ac08365691e659fe007c3569c2e9ce Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 11:23:38 -0400 Subject: [PATCH 01/45] feat: project skeleton with health endpoint --- .gitignore | 6 ++++++ .python-version | 1 + chat/__init__.py | 0 chat/app.py | 8 ++++++++ pyproject.toml | 23 +++++++++++++++++++++++ tests/__init__.py | 0 tests/test_health.py | 9 +++++++++ 7 files changed, 47 insertions(+) create mode 100644 .python-version create mode 100644 chat/__init__.py create mode 100644 chat/app.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/test_health.py diff --git a/.gitignore b/.gitignore index 92172f9..6bf8f70 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,9 @@ # v1 runtime data (DB, backups, snapshots, exports, config with secrets) data/ + +# Python +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/chat/__init__.py b/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/app.py b/chat/app.py new file mode 100644 index 0000000..66990d8 --- /dev/null +++ b/chat/app.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI(title="chat") + + +@app.get("/health") +def health(): + return {"status": "ok"} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c0593a9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "chat" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.30", + "httpx>=0.27", + "pydantic>=2.6", + "pydantic-settings>=2.2", + "openai>=1.30", + "instructor>=1.3", + "tiktoken>=0.7", + "jinja2>=3.1", + "aiosqlite>=0.20", +] + +[project.optional-dependencies] +dev = ["pytest>=8", "pytest-asyncio>=0.23", "freezegun>=1.4"] + +[tool.pytest.ini_options] +pythonpath = ["."] +asyncio_mode = "auto" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..f44ad32 --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,9 @@ +from fastapi.testclient import TestClient +from chat.app import app + + +def test_health_endpoint_returns_ok(): + client = TestClient(app) + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} -- 2.52.0 From 01e6975d20c7cae035a3b7a6a868b03a0149d9b7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 11:28:40 -0400 Subject: [PATCH 02/45] feat: config loader with toml + env override --- chat/config.py | 40 ++++++++++++++++++++++++++++++++++++++++ data/config.example.toml | 6 ++++++ tests/test_config.py | 26 ++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 chat/config.py create mode 100644 data/config.example.toml create mode 100644 tests/test_config.py diff --git a/chat/config.py b/chat/config.py new file mode 100644 index 0000000..90d6175 --- /dev/null +++ b/chat/config.py @@ -0,0 +1,40 @@ +from __future__ import annotations +import os +import tomllib +from pathlib import Path +from pydantic import BaseModel, Field + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_CONFIG = REPO_ROOT / "data" / "config.toml" +DEFAULT_DB = REPO_ROOT / "data" / "chat.db" + +class Settings(BaseModel): + featherless_api_key: str + featherless_base_url: str = "https://api.featherless.ai/v1" + narrative_model: str = "dphn/Dolphin-Mistral-24B-Venice-Edition" + classifier_model: str = "NousResearch/Hermes-3-Llama-3.1-8B" + classifier_fallbacks: list[str] = Field( + default_factory=lambda: [ + "cognitivecomputations/dolphin-2.9.4-llama3-8b", + "mlabonne/Meta-Llama-3.1-8B-Instruct-abliterated", + ] + ) + ooc_marker: str = "((" + retrieval_k: int = 4 + narrative_budget_hard: int = 8000 + narrative_budget_soft: int = 6000 + classifier_budget_hard: int = 4000 + classifier_timeout_s: float = 10.0 + db_path: Path = DEFAULT_DB + data_dir: Path = REPO_ROOT / "data" + bind_host: str = "127.0.0.1" + bind_port: int = 8000 + +def load_settings() -> Settings: + config_path = Path(os.environ.get("CHAT_CONFIG_PATH", DEFAULT_CONFIG)) + raw: dict = {} + if config_path.exists(): + raw = tomllib.loads(config_path.read_text()) + if "CHAT_DB_PATH" in os.environ: + raw["db_path"] = Path(os.environ["CHAT_DB_PATH"]) + return Settings(**raw) diff --git a/data/config.example.toml b/data/config.example.toml new file mode 100644 index 0000000..1da91f2 --- /dev/null +++ b/data/config.example.toml @@ -0,0 +1,6 @@ +# Copy this file to data/config.toml and fill in your API key. +featherless_api_key = "REPLACE_ME" +narrative_model = "dphn/Dolphin-Mistral-24B-Venice-Edition" +classifier_model = "NousResearch/Hermes-3-Llama-3.1-8B" +ooc_marker = "((" +retrieval_k = 4 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..abffd57 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,26 @@ +import os +from pathlib import Path +import pytest +from chat.config import load_settings + +def test_load_settings_reads_toml(tmp_path, monkeypatch): + cfg = tmp_path / "config.toml" + cfg.write_text(""" + featherless_api_key = "sk-test" + narrative_model = "dphn/Dolphin-Mistral-24B-Venice-Edition" + classifier_model = "NousResearch/Hermes-3-Llama-3.1-8B" + ooc_marker = "((" + retrieval_k = 4 + """) + monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg)) + s = load_settings() + assert s.featherless_api_key == "sk-test" + assert s.narrative_model.startswith("dphn/") + assert s.retrieval_k == 4 + +def test_chat_db_path_env_overrides_default(tmp_path, monkeypatch): + monkeypatch.setenv("CHAT_DB_PATH", str(tmp_path / "alt.db")) + monkeypatch.setenv("CHAT_CONFIG_PATH", str(tmp_path / "config.toml")) + (tmp_path / "config.toml").write_text('featherless_api_key = "x"\n') + s = load_settings() + assert s.db_path == tmp_path / "alt.db" -- 2.52.0 From 67517926aa65c5986aebdffdf2c4466e4d5a5f8f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 11:32:32 -0400 Subject: [PATCH 03/45] feat: sqlite migration runner with meta version table --- chat/db/__init__.py | 0 chat/db/connection.py | 17 +++++++++++++++++ chat/db/migrate.py | 26 ++++++++++++++++++++++++++ chat/db/migrations/0001_init_meta.sql | 2 ++ tests/test_migrate.py | 22 ++++++++++++++++++++++ 5 files changed, 67 insertions(+) create mode 100644 chat/db/__init__.py create mode 100644 chat/db/connection.py create mode 100644 chat/db/migrate.py create mode 100644 chat/db/migrations/0001_init_meta.sql create mode 100644 tests/test_migrate.py diff --git a/chat/db/__init__.py b/chat/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/db/connection.py b/chat/db/connection.py new file mode 100644 index 0000000..ad21a01 --- /dev/null +++ b/chat/db/connection.py @@ -0,0 +1,17 @@ +from __future__ import annotations +import sqlite3 +from contextlib import contextmanager +from pathlib import Path + + +@contextmanager +def open_db(path: Path): + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + try: + yield conn + conn.commit() + finally: + conn.close() diff --git a/chat/db/migrate.py b/chat/db/migrate.py new file mode 100644 index 0000000..95d9ba3 --- /dev/null +++ b/chat/db/migrate.py @@ -0,0 +1,26 @@ +from __future__ import annotations +from pathlib import Path + +from chat.db.connection import open_db + +MIGRATIONS_DIR = Path(__file__).parent / "migrations" + + +def apply_migrations(db_path: Path) -> None: + with open_db(db_path) as conn: + conn.execute( + "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)" + ) + cur = conn.execute("SELECT value FROM meta WHERE key = 'schema_version'") + row = cur.fetchone() + current = int(row[0]) if row else 0 + for path in sorted(MIGRATIONS_DIR.glob("*.sql")): + version = int(path.stem.split("_", 1)[0]) + if version <= current: + continue + sql = path.read_text() + conn.executescript(sql) + conn.execute( + "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)", + (str(version),), + ) diff --git a/chat/db/migrations/0001_init_meta.sql b/chat/db/migrations/0001_init_meta.sql new file mode 100644 index 0000000..c0020e4 --- /dev/null +++ b/chat/db/migrations/0001_init_meta.sql @@ -0,0 +1,2 @@ +-- meta table is created by the migrate runner; this migration is a marker. +SELECT 1; diff --git a/tests/test_migrate.py b/tests/test_migrate.py new file mode 100644 index 0000000..eaafcd3 --- /dev/null +++ b/tests/test_migrate.py @@ -0,0 +1,22 @@ +from chat.db.connection import open_db +from chat.db.migrate import apply_migrations + + +def test_apply_migrations_creates_meta_table(tmp_path): + db = tmp_path / "test.db" + apply_migrations(db) + with open_db(db) as conn: + row = conn.execute( + "SELECT value FROM meta WHERE key = 'schema_version'" + ).fetchone() + assert row is not None + assert int(row[0]) >= 1 + + +def test_apply_migrations_idempotent(tmp_path): + db = tmp_path / "test.db" + apply_migrations(db) + apply_migrations(db) # second call must be a no-op + with open_db(db) as conn: + count = conn.execute("SELECT COUNT(*) FROM meta").fetchone()[0] + assert count == 1 -- 2.52.0 From e627356168e1cf3119fc71d6d68832b8de78d5bc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 11:35:57 -0400 Subject: [PATCH 04/45] feat: LLMClient protocol with Featherless and mock implementations --- chat/llm/__init__.py | 0 chat/llm/client.py | 14 ++++++++++++++ chat/llm/featherless.py | 29 +++++++++++++++++++++++++++++ chat/llm/mock.py | 16 ++++++++++++++++ tests/test_llm_mock.py | 21 +++++++++++++++++++++ 5 files changed, 80 insertions(+) create mode 100644 chat/llm/__init__.py create mode 100644 chat/llm/client.py create mode 100644 chat/llm/featherless.py create mode 100644 chat/llm/mock.py create mode 100644 tests/test_llm_mock.py diff --git a/chat/llm/__init__.py b/chat/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/llm/client.py b/chat/llm/client.py new file mode 100644 index 0000000..ca34a2d --- /dev/null +++ b/chat/llm/client.py @@ -0,0 +1,14 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Protocol, AsyncIterator, Sequence + + +@dataclass +class Message: + role: str # "system" | "user" | "assistant" + content: str + + +class LLMClient(Protocol): + async def generate(self, messages: Sequence[Message], *, model: str, **params) -> str: ... + def stream(self, messages: Sequence[Message], *, model: str, **params) -> AsyncIterator[str]: ... diff --git a/chat/llm/featherless.py b/chat/llm/featherless.py new file mode 100644 index 0000000..3e1fbcf --- /dev/null +++ b/chat/llm/featherless.py @@ -0,0 +1,29 @@ +from __future__ import annotations +from typing import AsyncIterator, Sequence +from openai import AsyncOpenAI +from .client import Message + + +class FeatherlessClient: + def __init__(self, api_key: str, base_url: str = "https://api.featherless.ai/v1"): + self._client = AsyncOpenAI(api_key=api_key, base_url=base_url) + + async def generate(self, messages: Sequence[Message], *, model: str, **params) -> str: + resp = await self._client.chat.completions.create( + model=model, + messages=[{"role": m.role, "content": m.content} for m in messages], + **params, + ) + return resp.choices[0].message.content or "" + + async def stream(self, messages: Sequence[Message], *, model: str, **params) -> AsyncIterator[str]: + stream = await self._client.chat.completions.create( + model=model, + messages=[{"role": m.role, "content": m.content} for m in messages], + stream=True, + **params, + ) + async for chunk in stream: + delta = chunk.choices[0].delta.content or "" + if delta: + yield delta diff --git a/chat/llm/mock.py b/chat/llm/mock.py new file mode 100644 index 0000000..75ab786 --- /dev/null +++ b/chat/llm/mock.py @@ -0,0 +1,16 @@ +from __future__ import annotations +from typing import AsyncIterator, Sequence +from .client import Message + + +class MockLLMClient: + def __init__(self, canned: list[str]): + self._canned = list(canned) + + async def generate(self, messages: Sequence[Message], *, model: str, **params) -> str: + return self._canned.pop(0) + + async def stream(self, messages: Sequence[Message], *, model: str, **params) -> AsyncIterator[str]: + text = self._canned.pop(0) + for ch in text: + yield ch diff --git a/tests/test_llm_mock.py b/tests/test_llm_mock.py new file mode 100644 index 0000000..d56a783 --- /dev/null +++ b/tests/test_llm_mock.py @@ -0,0 +1,21 @@ +import pytest +from chat.llm.mock import MockLLMClient +from chat.llm.client import Message + + +@pytest.mark.asyncio +async def test_mock_returns_canned_response(): + client = MockLLMClient(canned=["Hello, world."]) + msgs = [Message(role="user", content="hi")] + out = await client.generate(msgs, model="any") + assert out == "Hello, world." + + +@pytest.mark.asyncio +async def test_mock_streams_tokens(): + client = MockLLMClient(canned=["abcd"]) + msgs = [Message(role="user", content="hi")] + chunks = [] + async for chunk in client.stream(msgs, model="any"): + chunks.append(chunk) + assert "".join(chunks) == "abcd" -- 2.52.0 From c2aceffda1daa198707857100ea12054872f72b3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 11:38:48 -0400 Subject: [PATCH 05/45] feat: classifier wrapper with retry, timeout, schema-default fallback --- .../migrations/0002_classifier_failures.sql | 8 ++++ chat/llm/classify.py | 41 +++++++++++++++++++ tests/test_classify.py | 24 +++++++++++ 3 files changed, 73 insertions(+) create mode 100644 chat/db/migrations/0002_classifier_failures.sql create mode 100644 chat/llm/classify.py create mode 100644 tests/test_classify.py diff --git a/chat/db/migrations/0002_classifier_failures.sql b/chat/db/migrations/0002_classifier_failures.sql new file mode 100644 index 0000000..7bfa23a --- /dev/null +++ b/chat/db/migrations/0002_classifier_failures.sql @@ -0,0 +1,8 @@ +CREATE TABLE classifier_failures ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL, + model TEXT NOT NULL, + raw_text TEXT, + attempt_count INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/chat/llm/classify.py b/chat/llm/classify.py new file mode 100644 index 0000000..66517c6 --- /dev/null +++ b/chat/llm/classify.py @@ -0,0 +1,41 @@ +from __future__ import annotations +import json +import asyncio +from typing import TypeVar +from pydantic import BaseModel, ValidationError +from .client import LLMClient, Message + +T = TypeVar("T", bound=BaseModel) + +REFUSAL_PATTERNS = ("i can't", "i cannot", "i'm sorry, but", "as an ai") + + +async def classify( + client: LLMClient, + *, + model: str, + system: str, + user: str, + schema: type[T], + default: T | None = None, + timeout_s: float = 10.0, +) -> T: + msgs = [ + Message(role="system", content=system + "\n\nRespond with JSON only matching the schema."), + Message(role="user", content=user), + ] + for attempt in range(2): + try: + text = await asyncio.wait_for( + client.generate(msgs, model=model, response_format={"type": "json_object"}), + timeout=timeout_s, + ) + if any(p in text.lower()[:80] for p in REFUSAL_PATTERNS) and not text.strip().startswith("{"): + raise ValueError("refusal-shaped response") + return schema.model_validate_json(text) + except (ValidationError, ValueError, json.JSONDecodeError, asyncio.TimeoutError): + msgs[0] = Message(role="system", content=system + "\n\nRespond with valid JSON ONLY. No prose.") + continue + if default is None: + raise RuntimeError(f"classify failed for schema {schema.__name__} with no default") + return default diff --git a/tests/test_classify.py b/tests/test_classify.py new file mode 100644 index 0000000..d059eaa --- /dev/null +++ b/tests/test_classify.py @@ -0,0 +1,24 @@ +import pytest +from pydantic import BaseModel +from chat.llm.mock import MockLLMClient +from chat.llm.classify import classify + + +class Verdict(BaseModel): + score: int + reason: str + + +@pytest.mark.asyncio +async def test_classify_parses_valid_json(): + mock = MockLLMClient(canned=['{"score": 2, "reason": "notable"}']) + result = await classify(mock, model="m", system="x", user="y", schema=Verdict) + assert result.score == 2 + + +@pytest.mark.asyncio +async def test_classify_falls_back_on_unparseable_after_retry(): + mock = MockLLMClient(canned=["nope", "still nope"]) + default = Verdict(score=1, reason="fallback") + result = await classify(mock, model="m", system="x", user="y", schema=Verdict, default=default) + assert result.reason == "fallback" -- 2.52.0 From 517fe49aefb26e2f823a40a11a04c6dadb466a0f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 11:42:49 -0400 Subject: [PATCH 06/45] feat: append-only event log with projector skeleton --- chat/db/migrations/0003_event_log.sql | 10 +++++++ chat/eventlog/__init__.py | 0 chat/eventlog/log.py | 38 +++++++++++++++++++++++++++ chat/eventlog/projector.py | 27 +++++++++++++++++++ tests/test_eventlog.py | 15 +++++++++++ 5 files changed, 90 insertions(+) create mode 100644 chat/db/migrations/0003_event_log.sql create mode 100644 chat/eventlog/__init__.py create mode 100644 chat/eventlog/log.py create mode 100644 chat/eventlog/projector.py create mode 100644 tests/test_eventlog.py diff --git a/chat/db/migrations/0003_event_log.sql b/chat/db/migrations/0003_event_log.sql new file mode 100644 index 0000000..2b4c85e --- /dev/null +++ b/chat/db/migrations/0003_event_log.sql @@ -0,0 +1,10 @@ +CREATE TABLE event_log ( + id INTEGER PRIMARY KEY, + branch_id INTEGER NOT NULL DEFAULT 1, + ts TEXT NOT NULL DEFAULT (datetime('now')), + kind TEXT NOT NULL, + payload_json TEXT NOT NULL, + superseded_by INTEGER REFERENCES event_log(id), + hidden INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX idx_event_log_branch_kind ON event_log(branch_id, kind); diff --git a/chat/eventlog/__init__.py b/chat/eventlog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/eventlog/log.py b/chat/eventlog/log.py new file mode 100644 index 0000000..74919fb --- /dev/null +++ b/chat/eventlog/log.py @@ -0,0 +1,38 @@ +from __future__ import annotations +import json +from dataclasses import dataclass +from typing import Any, Iterator +from sqlite3 import Connection + + +@dataclass +class Event: + id: int + branch_id: int + ts: str + kind: str + payload: dict[str, Any] + superseded_by: int | None + hidden: bool + + +def append_event(conn: Connection, *, kind: str, payload: dict[str, Any], branch_id: int = 1) -> int: + cur = conn.execute( + "INSERT INTO event_log (branch_id, kind, payload_json) VALUES (?, ?, ?)", + (branch_id, kind, json.dumps(payload)), + ) + return cur.lastrowid + + +def read_events(conn: Connection, branch_id: int = 1, after_id: int = 0) -> Iterator[Event]: + cur = conn.execute( + "SELECT id, branch_id, ts, kind, payload_json, superseded_by, hidden " + "FROM event_log WHERE branch_id = ? AND id > ? AND hidden = 0 " + "AND superseded_by IS NULL ORDER BY id", + (branch_id, after_id), + ) + for row in cur: + yield Event( + id=row[0], branch_id=row[1], ts=row[2], kind=row[3], + payload=json.loads(row[4]), superseded_by=row[5], hidden=bool(row[6]), + ) diff --git a/chat/eventlog/projector.py b/chat/eventlog/projector.py new file mode 100644 index 0000000..fba406c --- /dev/null +++ b/chat/eventlog/projector.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from collections.abc import Callable +from sqlite3 import Connection +from .log import Event, read_events + +Handler = Callable[[Connection, Event], None] +_REGISTRY: dict[str, Handler] = {} + + +def on(kind: str): + def deco(fn: Handler) -> Handler: + _REGISTRY[kind] = fn + return fn + return deco + + +def project(conn: Connection, branch_id: int = 1) -> None: + for event in read_events(conn, branch_id=branch_id): + h = _REGISTRY.get(event.kind) + if h: + h(conn, event) + + +def apply_event(conn: Connection, event: Event) -> None: + h = _REGISTRY.get(event.kind) + if h: + h(conn, event) diff --git a/tests/test_eventlog.py b/tests/test_eventlog.py new file mode 100644 index 0000000..c7c129f --- /dev/null +++ b/tests/test_eventlog.py @@ -0,0 +1,15 @@ +from chat.db.migrate import apply_migrations +from chat.db.connection import open_db +from chat.eventlog.log import append_event, read_events + + +def test_append_and_read(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + eid = append_event(conn, kind="test_kind", payload={"a": 1}) + assert eid > 0 + rows = list(read_events(conn)) + assert len(rows) == 1 + assert rows[0].kind == "test_kind" + assert rows[0].payload["a"] == 1 -- 2.52.0 From 5e6bbb586c80e1d3d94c0bedaf29c3ea2fe95d28 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 11:46:19 -0400 Subject: [PATCH 07/45] feat: bot and you entity schemas with projector handlers --- chat/db/migrations/0004_entities.sql | 18 ++++++++++ chat/state/__init__.py | 0 chat/state/entities.py | 54 ++++++++++++++++++++++++++++ tests/test_entities.py | 38 ++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 chat/db/migrations/0004_entities.sql create mode 100644 chat/state/__init__.py create mode 100644 chat/state/entities.py create mode 100644 tests/test_entities.py diff --git a/chat/db/migrations/0004_entities.sql b/chat/db/migrations/0004_entities.sql new file mode 100644 index 0000000..ef7b9f3 --- /dev/null +++ b/chat/db/migrations/0004_entities.sql @@ -0,0 +1,18 @@ +CREATE TABLE bots ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + persona TEXT NOT NULL, + voice_samples_json TEXT NOT NULL DEFAULT '[]', + traits_json TEXT NOT NULL DEFAULT '[]', + backstory TEXT NOT NULL DEFAULT '', + initial_relationship_to_you TEXT NOT NULL DEFAULT '', + kickoff_prose TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE you_entity ( + id INTEGER PRIMARY KEY CHECK (id = 1), + name TEXT NOT NULL, + pronouns TEXT NOT NULL DEFAULT '', + persona TEXT NOT NULL DEFAULT '' +); diff --git a/chat/state/__init__.py b/chat/state/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/state/entities.py b/chat/state/entities.py new file mode 100644 index 0000000..57d0cc3 --- /dev/null +++ b/chat/state/entities.py @@ -0,0 +1,54 @@ +from __future__ import annotations +import json +from sqlite3 import Connection +from chat.eventlog.projector import on +from chat.eventlog.log import Event + + +@on("bot_authored") +def _apply_bot_authored(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "INSERT OR REPLACE INTO bots " + "(id, name, persona, voice_samples_json, traits_json, backstory, " + " initial_relationship_to_you, kickoff_prose) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (p["id"], p["name"], p["persona"], + json.dumps(p.get("voice_samples", [])), + json.dumps(p.get("traits", [])), + p.get("backstory", ""), + p.get("initial_relationship_to_you", ""), + p.get("kickoff_prose", "")), + ) + + +@on("you_authored") +def _apply_you_authored(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "INSERT OR REPLACE INTO you_entity (id, name, pronouns, persona) VALUES (1, ?, ?, ?)", + (p["name"], p.get("pronouns", ""), p.get("persona", "")), + ) + + +def get_bot(conn: Connection, bot_id: str) -> dict | None: + row = conn.execute("SELECT * FROM bots WHERE id = ?", (bot_id,)).fetchone() + if not row: + return None + cols = [c[1] for c in conn.execute("PRAGMA table_info(bots)").fetchall()] + d = dict(zip(cols, row)) + d["voice_samples"] = json.loads(d.pop("voice_samples_json")) + d["traits"] = json.loads(d.pop("traits_json")) + return d + + +def list_bots(conn: Connection) -> list[dict]: + cur = conn.execute("SELECT id, name FROM bots ORDER BY name") + return [{"id": r[0], "name": r[1]} for r in cur] + + +def get_you(conn: Connection) -> dict | None: + row = conn.execute("SELECT name, pronouns, persona FROM you_entity WHERE id = 1").fetchone() + if not row: + return None + return {"name": row[0], "pronouns": row[1], "persona": row[2]} diff --git a/tests/test_entities.py b/tests/test_entities.py new file mode 100644 index 0000000..5ef6f17 --- /dev/null +++ b/tests/test_entities.py @@ -0,0 +1,38 @@ +from chat.db.migrate import apply_migrations +from chat.db.connection import open_db +from chat.eventlog.log import append_event +from chat.eventlog.projector import project +from chat.state.entities import get_bot, list_bots, get_you +import chat.state.entities # registers handlers + + +def test_bot_authored_creates_bot_row(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="bot_authored", payload={ + "id": "bot_a", "name": "BotA", + "persona": "...", "voice_samples": ["sample"], "traits": ["shy"], + "backstory": "...", + "initial_relationship_to_you": "coworker", + "kickoff_prose": "you stay late", + }) + project(conn) + bot = get_bot(conn, "bot_a") + assert bot is not None + assert bot["name"] == "BotA" + assert bot["traits"] == ["shy"] + assert "bot_a" in [b["id"] for b in list_bots(conn)] + + +def test_you_authored_creates_you_singleton(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="you_authored", payload={ + "name": "Me", "pronouns": "they/them", "persona": "engineer", + }) + project(conn) + you = get_you(conn) + assert you is not None + assert you["name"] == "Me" -- 2.52.0 From 7e6c2985dd817fd7c6393d8df9fd02826546cb3e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 11:48:30 -0400 Subject: [PATCH 08/45] docs: fix Task 6 plan snippet: PRAGMA table_info name index is c[1] not c[0] --- docs/plans/2026-04-26-v1-phase1-implementation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-04-26-v1-phase1-implementation.md b/docs/plans/2026-04-26-v1-phase1-implementation.md index 1a1ce16..0ad593d 100644 --- a/docs/plans/2026-04-26-v1-phase1-implementation.md +++ b/docs/plans/2026-04-26-v1-phase1-implementation.md @@ -884,7 +884,7 @@ def get_bot(conn: Connection, bot_id: str) -> dict | None: row = conn.execute("SELECT * FROM bots WHERE id = ?", (bot_id,)).fetchone() if not row: return None - cols = [c[0] for c in conn.execute("PRAGMA table_info(bots)").fetchall()] + cols = [c[1] for c in conn.execute("PRAGMA table_info(bots)").fetchall()] d = dict(zip(cols, row)) d["voice_samples"] = json.loads(d.pop("voice_samples_json")) d["traits"] = json.loads(d.pop("traits_json")) -- 2.52.0 From bc97d425ef267311162788c67a34f18006cc39b7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 11:51:15 -0400 Subject: [PATCH 09/45] feat: directed edges with per-turn delta projector --- chat/db/migrations/0005_edges.sql | 13 +++ chat/state/edges.py | 84 ++++++++++++++++ tests/test_edges.py | 162 ++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 chat/db/migrations/0005_edges.sql create mode 100644 chat/state/edges.py create mode 100644 tests/test_edges.py diff --git a/chat/db/migrations/0005_edges.sql b/chat/db/migrations/0005_edges.sql new file mode 100644 index 0000000..4a0969e --- /dev/null +++ b/chat/db/migrations/0005_edges.sql @@ -0,0 +1,13 @@ +CREATE TABLE edges ( + id INTEGER PRIMARY KEY, + chat_id TEXT, + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + affinity INTEGER NOT NULL DEFAULT 50, + trust INTEGER NOT NULL DEFAULT 50, + summary TEXT NOT NULL DEFAULT '', + knowledge_json TEXT NOT NULL DEFAULT '[]', + last_interaction_chat_id TEXT, + last_interaction_at TEXT, + UNIQUE (source_id, target_id) +); diff --git a/chat/state/edges.py b/chat/state/edges.py new file mode 100644 index 0000000..03be66b --- /dev/null +++ b/chat/state/edges.py @@ -0,0 +1,84 @@ +from __future__ import annotations +import json +from sqlite3 import Connection +from chat.eventlog.projector import on +from chat.eventlog.log import Event + + +def _clamp(value: int, lo: int = 0, hi: int = 100) -> int: + return max(lo, min(hi, value)) + + +@on("edge_update") +def _apply_edge_update(conn: Connection, e: Event) -> None: + p = e.payload + source_id = p["source_id"] + target_id = p["target_id"] + chat_id = p.get("chat_id") + + # Upsert: ensure a row exists with defaults, then apply deltas. + conn.execute( + "INSERT OR IGNORE INTO edges (chat_id, source_id, target_id) VALUES (?, ?, ?)", + (chat_id, source_id, target_id), + ) + + row = conn.execute( + "SELECT affinity, trust, knowledge_json, last_interaction_chat_id, last_interaction_at " + "FROM edges WHERE source_id = ? AND target_id = ?", + (source_id, target_id), + ).fetchone() + affinity, trust, knowledge_json, last_chat_id, last_at = row + + affinity_delta = int(p.get("affinity_delta", 0)) + trust_delta = int(p.get("trust_delta", 0)) + new_affinity = _clamp(affinity + affinity_delta) + new_trust = _clamp(trust + trust_delta) + + new_facts = p.get("knowledge_facts") or [] + if new_facts: + knowledge = json.loads(knowledge_json) + knowledge.extend(new_facts) + knowledge_json = json.dumps(knowledge) + + payload_at = p.get("last_interaction_at") + payload_chat_id = p.get("last_interaction_chat_id") + if payload_at is not None: + last_at = payload_at + if payload_chat_id is not None: + last_chat_id = payload_chat_id + + conn.execute( + "UPDATE edges SET affinity = ?, trust = ?, knowledge_json = ?, " + "last_interaction_chat_id = ?, last_interaction_at = ? " + "WHERE source_id = ? AND target_id = ?", + (new_affinity, new_trust, knowledge_json, last_chat_id, last_at, + source_id, target_id), + ) + + +def get_edge(conn: Connection, source_id: str, target_id: str) -> dict | None: + row = conn.execute( + "SELECT * FROM edges WHERE source_id = ? AND target_id = ?", + (source_id, target_id), + ).fetchone() + if not row: + return None + cols = [c[1] for c in conn.execute("PRAGMA table_info(edges)").fetchall()] + d = dict(zip(cols, row)) + d["knowledge"] = json.loads(d.pop("knowledge_json")) + return d + + +def list_edges_for(conn: Connection, source_id: str) -> list[dict]: + cur = conn.execute( + "SELECT * FROM edges WHERE source_id = ? ORDER BY target_id", + (source_id,), + ) + rows = cur.fetchall() + cols = [c[1] for c in conn.execute("PRAGMA table_info(edges)").fetchall()] + out: list[dict] = [] + for row in rows: + d = dict(zip(cols, row)) + d["knowledge"] = json.loads(d.pop("knowledge_json")) + out.append(d) + return out diff --git a/tests/test_edges.py b/tests/test_edges.py new file mode 100644 index 0000000..22746fc --- /dev/null +++ b/tests/test_edges.py @@ -0,0 +1,162 @@ +from chat.db.migrate import apply_migrations +from chat.db.connection import open_db +from chat.eventlog.log import append_event +from chat.eventlog.projector import project +from chat.state.edges import get_edge, list_edges_for +import chat.state.entities # registers bot/you handlers +import chat.state.edges # registers edge_update handler + + +def _seed_entities(conn) -> None: + append_event(conn, kind="bot_authored", payload={ + "id": "bot_a", "name": "BotA", "persona": "p", + "voice_samples": [], "traits": [], + "backstory": "", "initial_relationship_to_you": "", + "kickoff_prose": "", + }) + append_event(conn, kind="you_authored", payload={ + "name": "Me", "pronouns": "", "persona": "", + }) + + +def test_edge_update_upsert_applies_first_delta(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_entities(conn) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "affinity_delta": 5, + }) + project(conn) + edge = get_edge(conn, "bot_a", "you") + assert edge is not None + assert edge["affinity"] == 55 + assert edge["trust"] == 50 + assert edge["knowledge"] == [] + assert edge["summary"] == "" + + +def test_edge_update_multiple_deltas_accumulate(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_entities(conn) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "affinity_delta": 5, + }) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "affinity_delta": -3, + "trust_delta": 2, + "knowledge_facts": ["she has a sister"], + }) + project(conn) + edge = get_edge(conn, "bot_a", "you") + assert edge is not None + assert edge["affinity"] == 52 + assert edge["trust"] == 52 + assert edge["knowledge"] == ["she has a sister"] + + +def test_edge_update_clamps_affinity_at_max(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_entities(conn) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "affinity_delta": 5, + }) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "affinity_delta": 100, + }) + project(conn) + edge = get_edge(conn, "bot_a", "you") + assert edge is not None + assert edge["affinity"] == 100 + + +def test_edge_update_clamps_trust_at_min(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_entities(conn) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "trust_delta": -200, + }) + project(conn) + edge = get_edge(conn, "bot_a", "you") + assert edge is not None + assert edge["trust"] == 0 + + +def test_edges_are_directed_and_asymmetric(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_entities(conn) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "affinity_delta": 5, + }) + append_event(conn, kind="edge_update", payload={ + "source_id": "you", "target_id": "bot_a", + "affinity_delta": 10, + }) + project(conn) + forward = get_edge(conn, "bot_a", "you") + reverse = get_edge(conn, "you", "bot_a") + assert forward is not None and reverse is not None + assert forward["affinity"] == 55 + assert reverse["affinity"] == 60 + # Independent rows + assert forward["affinity"] != reverse["affinity"] + + +def test_edge_update_bumps_last_interaction(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_entities(conn) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "affinity_delta": 1, + "last_interaction_at": "2026-04-26T10:00:00", + "last_interaction_chat_id": "chat_bot_a", + }) + project(conn) + edge = get_edge(conn, "bot_a", "you") + assert edge is not None + assert edge["last_interaction_at"] == "2026-04-26T10:00:00" + assert edge["last_interaction_chat_id"] == "chat_bot_a" + + +def test_list_edges_for_returns_outgoing_only(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_entities(conn) + append_event(conn, kind="bot_authored", payload={ + "id": "bot_b", "name": "BotB", "persona": "p", + "voice_samples": [], "traits": [], + "backstory": "", "initial_relationship_to_you": "", + "kickoff_prose": "", + }) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", "affinity_delta": 1, + }) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "bot_b", "affinity_delta": 2, + }) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_b", "target_id": "bot_a", "affinity_delta": 3, + }) + project(conn) + outgoing = list_edges_for(conn, "bot_a") + targets = [e["target_id"] for e in outgoing] + assert targets == sorted(targets) + assert set(targets) == {"you", "bot_b"} -- 2.52.0 From 30e664812216c688e8a6fd80e8299abd161fd653 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 11:56:32 -0400 Subject: [PATCH 10/45] feat: memory schema with witness flags and FTS5 index --- chat/db/migrations/0006_memories.sql | 35 ++++ chat/state/memory.py | 96 +++++++++++ tests/test_memory.py | 229 +++++++++++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 chat/db/migrations/0006_memories.sql create mode 100644 chat/state/memory.py create mode 100644 tests/test_memory.py diff --git a/chat/db/migrations/0006_memories.sql b/chat/db/migrations/0006_memories.sql new file mode 100644 index 0000000..a9c9fa4 --- /dev/null +++ b/chat/db/migrations/0006_memories.sql @@ -0,0 +1,35 @@ +CREATE TABLE memories ( + id INTEGER PRIMARY KEY, + owner_id TEXT NOT NULL, + chat_id TEXT NOT NULL, + scene_id INTEGER, + pov_summary TEXT NOT NULL, + witness_you INTEGER NOT NULL, + witness_host INTEGER NOT NULL, + witness_guest INTEGER NOT NULL, + chat_clock_at TEXT, + source TEXT, + reliability REAL NOT NULL DEFAULT 1.0, + significance INTEGER NOT NULL DEFAULT 1, + pinned INTEGER NOT NULL DEFAULT 0, + auto_pinned INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX idx_memories_owner ON memories(owner_id); + +CREATE VIRTUAL TABLE memories_fts USING fts5( + pov_summary, content='memories', content_rowid='id' +); + +CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN + INSERT INTO memories_fts(rowid, pov_summary) VALUES (new.id, new.pov_summary); +END; +CREATE TRIGGER memories_au AFTER UPDATE ON memories BEGIN + INSERT INTO memories_fts(memories_fts, rowid, pov_summary) + VALUES('delete', old.id, old.pov_summary); + INSERT INTO memories_fts(rowid, pov_summary) VALUES (new.id, new.pov_summary); +END; +CREATE TRIGGER memories_ad AFTER DELETE ON memories BEGIN + INSERT INTO memories_fts(memories_fts, rowid, pov_summary) + VALUES('delete', old.id, old.pov_summary); +END; diff --git a/chat/state/memory.py b/chat/state/memory.py new file mode 100644 index 0000000..b2655b0 --- /dev/null +++ b/chat/state/memory.py @@ -0,0 +1,96 @@ +from __future__ import annotations +from sqlite3 import Connection +from chat.eventlog.projector import on +from chat.eventlog.log import Event + +_VALID_WITNESS_ROLES = {"you", "host", "guest"} + + +def _row_to_dict(conn: Connection, row: tuple) -> dict: + cols = [c[1] for c in conn.execute("PRAGMA table_info(memories)").fetchall()] + return dict(zip(cols, row)) + + +@on("memory_written") +def _apply_memory_written(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "INSERT INTO memories (" + "owner_id, chat_id, scene_id, pov_summary, " + "witness_you, witness_host, witness_guest, " + "chat_clock_at, source, reliability, significance, pinned, auto_pinned" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + p["owner_id"], + p["chat_id"], + p.get("scene_id"), + p["pov_summary"], + int(p["witness_you"]), + int(p["witness_host"]), + int(p["witness_guest"]), + p.get("chat_clock_at"), + p.get("source", "direct"), + float(p.get("reliability", 1.0)), + int(p.get("significance", 1)), + int(p.get("pinned", 0)), + int(p.get("auto_pinned", 0)), + ), + ) + + +def get_memory(conn: Connection, memory_id: int) -> dict | None: + row = conn.execute( + "SELECT * FROM memories WHERE id = ?", (memory_id,) + ).fetchone() + if not row: + return None + return _row_to_dict(conn, row) + + +def get_pinned(conn: Connection, owner_id: str) -> list[dict]: + cur = conn.execute( + "SELECT * FROM memories WHERE owner_id = ? AND pinned = 1 " + "ORDER BY created_at DESC, id DESC", + (owner_id,), + ) + rows = cur.fetchall() + cols = [c[1] for c in conn.execute("PRAGMA table_info(memories)").fetchall()] + return [dict(zip(cols, row)) for row in rows] + + +def search_memories( + conn: Connection, + owner_id: str, + witness_role: str, + query: str, + k: int = 4, +) -> list[dict]: + """FTS5 search over pov_summary, scoped by owner and witness role. + + witness_role must be one of {"you", "host", "guest"} per the witness flags + on each memory row. Returns up to k rows ordered by FTS5 bm25 rank. + """ + if witness_role not in _VALID_WITNESS_ROLES: + raise ValueError( + f"witness_role must be one of {sorted(_VALID_WITNESS_ROLES)}, " + f"got {witness_role!r}" + ) + witness_col = f"witness_{witness_role}" + cols = [c[1] for c in conn.execute("PRAGMA table_info(memories)").fetchall()] + select_list = ", ".join(f"m.{c}" for c in cols) + sql = ( + f"SELECT {select_list}, memories_fts.rank AS fts_rank " + "FROM memories_fts " + "JOIN memories m ON m.id = memories_fts.rowid " + f"WHERE m.owner_id = ? AND m.{witness_col} = 1 " + "AND memories_fts MATCH ? " + "ORDER BY memories_fts.rank " + "LIMIT ?" + ) + cur = conn.execute(sql, (owner_id, query, k)) + rows = cur.fetchall() + out: list[dict] = [] + result_cols = cols + ["fts_rank"] + for row in rows: + out.append(dict(zip(result_cols, row))) + return out diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..3d174b0 --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,229 @@ +from __future__ import annotations +import pytest + +from chat.db.connection import open_db +from chat.db.migrate import apply_migrations +from chat.eventlog.log import append_event +from chat.eventlog.projector import project +import chat.state.memory # registers memory_written handler +from chat.state.memory import get_memory, get_pinned, search_memories + + +def _base_memory(**overrides): + payload = { + "owner_id": "bot_a", + "chat_id": "chat_bot_a", + "scene_id": 1, + "pov_summary": "She laughed at his joke about owls.", + "witness_you": 1, + "witness_host": 1, + "witness_guest": 0, + "chat_clock_at": "2026-04-26T10:00:00", + "source": "direct", + "reliability": 1.0, + "significance": 1, + "pinned": 0, + "auto_pinned": 0, + } + payload.update(overrides) + return payload + + +def test_memory_written_is_projected_and_readable(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="memory_written", payload=_base_memory()) + project(conn) + row = conn.execute("SELECT id FROM memories").fetchone() + assert row is not None + mem = get_memory(conn, row[0]) + assert mem is not None + assert mem["owner_id"] == "bot_a" + assert mem["chat_id"] == "chat_bot_a" + assert mem["scene_id"] == 1 + assert mem["pov_summary"] == "She laughed at his joke about owls." + assert mem["witness_you"] == 1 + assert mem["witness_host"] == 1 + assert mem["witness_guest"] == 0 + assert mem["chat_clock_at"] == "2026-04-26T10:00:00" + assert mem["source"] == "direct" + assert mem["reliability"] == 1.0 + assert mem["significance"] == 1 + assert mem["pinned"] == 0 + assert mem["auto_pinned"] == 0 + + +def test_get_memory_returns_none_for_missing_id(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + assert get_memory(conn, 9999) is None + + +def test_search_memories_filters_out_non_witness(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="memory_written", payload=_base_memory( + pov_summary="The cat sat on the mat.", + witness_you=1, witness_host=1, witness_guest=0, + )) + project(conn) + # guest did not witness => excluded + results = search_memories(conn, "bot_a", "guest", "cat") + assert results == [] + + +def test_search_memories_includes_witnesses(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="memory_written", payload=_base_memory( + pov_summary="The cat sat on the mat.", + witness_you=1, witness_host=1, witness_guest=0, + )) + project(conn) + results = search_memories(conn, "bot_a", "host", "cat") + assert len(results) == 1 + assert results[0]["pov_summary"] == "The cat sat on the mat." + assert "fts_rank" in results[0] + + +def test_search_memories_fts_matches_only_relevant_text(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="memory_written", payload=_base_memory( + pov_summary="She loves owls and stars.", + )) + append_event(conn, kind="memory_written", payload=_base_memory( + pov_summary="He fixed the broken kettle.", + )) + project(conn) + results = search_memories(conn, "bot_a", "you", "owls") + assert len(results) == 1 + assert results[0]["pov_summary"] == "She loves owls and stars." + + +def test_search_memories_filters_by_owner(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="memory_written", payload=_base_memory( + owner_id="bot_a", + pov_summary="Owls hooted at midnight.", + )) + append_event(conn, kind="memory_written", payload=_base_memory( + owner_id="bot_b", + pov_summary="Owls hooted at midnight.", + )) + project(conn) + results_a = search_memories(conn, "bot_a", "you", "owls") + results_b = search_memories(conn, "bot_b", "you", "owls") + assert len(results_a) == 1 + assert results_a[0]["owner_id"] == "bot_a" + assert len(results_b) == 1 + assert results_b[0]["owner_id"] == "bot_b" + + +def test_search_memories_returns_empty_on_no_match(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="memory_written", payload=_base_memory( + pov_summary="The cat sat on the mat.", + )) + project(conn) + results = search_memories(conn, "bot_a", "you", "spaceship") + assert results == [] + + +def test_search_memories_invalid_witness_role_raises(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + with pytest.raises(ValueError): + search_memories(conn, "bot_a", "everyone", "cat") + + +def test_search_memories_respects_k_limit(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + for i in range(6): + append_event(conn, kind="memory_written", payload=_base_memory( + pov_summary=f"Owls hooted at midnight number {i}.", + )) + project(conn) + results = search_memories(conn, "bot_a", "you", "owls", k=4) + assert len(results) == 4 + + +def test_get_pinned_returns_only_pinned(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="memory_written", payload=_base_memory( + pov_summary="Pinned moment.", + pinned=1, + )) + append_event(conn, kind="memory_written", payload=_base_memory( + pov_summary="Unpinned moment.", + pinned=0, + )) + project(conn) + pinned = get_pinned(conn, "bot_a") + assert len(pinned) == 1 + assert pinned[0]["pov_summary"] == "Pinned moment." + assert pinned[0]["pinned"] == 1 + + +def test_get_pinned_filters_by_owner(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="memory_written", payload=_base_memory( + owner_id="bot_a", pov_summary="A's pin.", pinned=1, + )) + append_event(conn, kind="memory_written", payload=_base_memory( + owner_id="bot_b", pov_summary="B's pin.", pinned=1, + )) + project(conn) + pinned_a = get_pinned(conn, "bot_a") + assert len(pinned_a) == 1 + assert pinned_a[0]["owner_id"] == "bot_a" + + +def test_memory_payload_defaults_when_optional_missing(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="memory_written", payload={ + "owner_id": "bot_a", + "chat_id": "chat_bot_a", + "pov_summary": "Bare minimum memory.", + "witness_you": 1, + "witness_host": 1, + "witness_guest": 1, + }) + project(conn) + row = conn.execute("SELECT id FROM memories").fetchone() + mem = get_memory(conn, row[0]) + assert mem["scene_id"] is None + assert mem["chat_clock_at"] is None + assert mem["source"] == "direct" + assert mem["reliability"] == 1.0 + assert mem["significance"] == 1 + assert mem["pinned"] == 0 + assert mem["auto_pinned"] == 0 + + +def test_schema_version_after_migration_is_6(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + row = conn.execute( + "SELECT value FROM meta WHERE key = 'schema_version'" + ).fetchone() + assert int(row[0]) == 6 -- 2.52.0 From ec344064f15c680bd177408a73fc7ea0b90708d3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 12:03:26 -0400 Subject: [PATCH 11/45] feat: chats, chat_state, containers, scenes, activity tables --- chat/db/migrations/0007_world.sql | 45 ++++ chat/state/world.py | 202 ++++++++++++++++++ tests/test_memory.py | 4 +- tests/test_world.py | 334 ++++++++++++++++++++++++++++++ 4 files changed, 583 insertions(+), 2 deletions(-) create mode 100644 chat/db/migrations/0007_world.sql create mode 100644 chat/state/world.py create mode 100644 tests/test_world.py diff --git a/chat/db/migrations/0007_world.sql b/chat/db/migrations/0007_world.sql new file mode 100644 index 0000000..74af990 --- /dev/null +++ b/chat/db/migrations/0007_world.sql @@ -0,0 +1,45 @@ +CREATE TABLE chats ( + id TEXT PRIMARY KEY, + host_bot_id TEXT NOT NULL, + guest_bot_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE chat_state ( + chat_id TEXT PRIMARY KEY, + time TEXT NOT NULL, + weather TEXT NOT NULL DEFAULT '', + active_scene_id INTEGER, + narrative_anchor TEXT +); + +CREATE TABLE containers ( + id INTEGER PRIMARY KEY, + chat_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + properties_json TEXT NOT NULL DEFAULT '{}', + parent_id INTEGER REFERENCES containers(id) +); + +CREATE TABLE scenes ( + id INTEGER PRIMARY KEY, + chat_id TEXT NOT NULL, + container_id INTEGER REFERENCES containers(id), + started_at TEXT NOT NULL, + ended_at TEXT, + significance INTEGER NOT NULL DEFAULT 0, + participants_json TEXT NOT NULL DEFAULT '[]' +); + +CREATE TABLE activity ( + entity_id TEXT PRIMARY KEY, + container_id INTEGER REFERENCES containers(id), + slot TEXT, + posture TEXT NOT NULL DEFAULT '', + action_json TEXT NOT NULL DEFAULT '{}', + attention TEXT NOT NULL DEFAULT '', + holding_json TEXT NOT NULL DEFAULT '[]', + status_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/chat/state/world.py b/chat/state/world.py new file mode 100644 index 0000000..5966924 --- /dev/null +++ b/chat/state/world.py @@ -0,0 +1,202 @@ +from __future__ import annotations +import json +from sqlite3 import Connection +from chat.eventlog.projector import on +from chat.eventlog.log import Event + + +def _row_to_dict(conn: Connection, table: str, row: tuple) -> dict: + cols = [c[1] for c in conn.execute(f"PRAGMA table_info({table})").fetchall()] + return dict(zip(cols, row)) + + +@on("chat_created") +def _apply_chat_created(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "INSERT INTO chats (id, host_bot_id, guest_bot_id) VALUES (?, ?, ?)", + (p["id"], p["host_bot_id"], p.get("guest_bot_id")), + ) + conn.execute( + "INSERT INTO chat_state (chat_id, time, weather, active_scene_id, narrative_anchor) " + "VALUES (?, ?, ?, NULL, ?)", + ( + p["id"], + p["initial_time"], + p.get("weather", ""), + p.get("narrative_anchor", ""), + ), + ) + + +@on("container_created") +def _apply_container_created(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "INSERT INTO containers (chat_id, name, type, properties_json, parent_id) " + "VALUES (?, ?, ?, ?, ?)", + ( + p["chat_id"], + p["name"], + p["type"], + json.dumps(p.get("properties", {})), + p.get("parent_id"), + ), + ) + + +@on("activity_change") +def _apply_activity_change(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "INSERT OR REPLACE INTO activity (" + "entity_id, container_id, slot, posture, action_json, " + "attention, holding_json, status_json, updated_at" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))", + ( + p["entity_id"], + p.get("container_id"), + p.get("slot"), + p.get("posture", ""), + json.dumps(p.get("action", {})), + p.get("attention", ""), + json.dumps(p.get("holding", [])), + json.dumps(p.get("status", {})), + ), + ) + + +@on("scene_opened") +def _apply_scene_opened(conn: Connection, e: Event) -> None: + p = e.payload + cur = conn.execute( + "INSERT INTO scenes (chat_id, container_id, started_at, ended_at, " + "significance, participants_json) VALUES (?, ?, ?, NULL, 0, ?)", + ( + p["chat_id"], + p.get("container_id"), + p["started_at"], + json.dumps(p.get("participants", [])), + ), + ) + new_id = cur.lastrowid + conn.execute( + "UPDATE chat_state SET active_scene_id = ? WHERE chat_id = ?", + (new_id, p["chat_id"]), + ) + + +@on("scene_closed") +def _apply_scene_closed(conn: Connection, e: Event) -> None: + p = e.payload + scene_id = p["scene_id"] + significance = int(p.get("significance", 0)) + conn.execute( + "UPDATE scenes SET ended_at = ?, significance = ? WHERE id = ?", + (p["ended_at"], significance, scene_id), + ) + row = conn.execute( + "SELECT chat_id FROM scenes WHERE id = ?", (scene_id,) + ).fetchone() + if row is not None: + chat_id = row[0] + conn.execute( + "UPDATE chat_state SET active_scene_id = NULL WHERE chat_id = ?", + (chat_id,), + ) + + +def _chat_select_columns() -> str: + return ( + "c.id, c.host_bot_id, c.guest_bot_id, c.created_at, " + "s.time, s.weather, s.active_scene_id, s.narrative_anchor" + ) + + +def _chat_row_to_dict(row: tuple) -> dict: + return { + "id": row[0], + "host_bot_id": row[1], + "guest_bot_id": row[2], + "created_at": row[3], + "time": row[4], + "weather": row[5], + "active_scene_id": row[6], + "narrative_anchor": row[7], + } + + +def get_chat(conn: Connection, chat_id: str) -> dict | None: + row = conn.execute( + f"SELECT {_chat_select_columns()} FROM chats c " + "JOIN chat_state s ON s.chat_id = c.id WHERE c.id = ?", + (chat_id,), + ).fetchone() + if not row: + return None + return _chat_row_to_dict(row) + + +def list_chats(conn: Connection) -> list[dict]: + cur = conn.execute( + f"SELECT {_chat_select_columns()} FROM chats c " + "JOIN chat_state s ON s.chat_id = c.id ORDER BY c.id" + ) + return [_chat_row_to_dict(row) for row in cur.fetchall()] + + +def get_container(conn: Connection, container_id: int) -> dict | None: + row = conn.execute( + "SELECT * FROM containers WHERE id = ?", (container_id,) + ).fetchone() + if not row: + return None + d = _row_to_dict(conn, "containers", row) + d["properties"] = json.loads(d.pop("properties_json")) + return d + + +def find_container(conn: Connection, chat_id: str, name: str) -> dict | None: + row = conn.execute( + "SELECT * FROM containers WHERE chat_id = ? AND name = ?", + (chat_id, name), + ).fetchone() + if not row: + return None + d = _row_to_dict(conn, "containers", row) + d["properties"] = json.loads(d.pop("properties_json")) + return d + + +def get_activity(conn: Connection, entity_id: str) -> dict | None: + row = conn.execute( + "SELECT * FROM activity WHERE entity_id = ?", (entity_id,) + ).fetchone() + if not row: + return None + d = _row_to_dict(conn, "activity", row) + d["action"] = json.loads(d.pop("action_json")) + d["holding"] = json.loads(d.pop("holding_json")) + d["status"] = json.loads(d.pop("status_json")) + return d + + +def get_scene(conn: Connection, scene_id: int) -> dict | None: + row = conn.execute( + "SELECT * FROM scenes WHERE id = ?", (scene_id,) + ).fetchone() + if not row: + return None + d = _row_to_dict(conn, "scenes", row) + d["participants"] = json.loads(d.pop("participants_json")) + return d + + +def active_scene(conn: Connection, chat_id: str) -> dict | None: + row = conn.execute( + "SELECT active_scene_id FROM chat_state WHERE chat_id = ?", + (chat_id,), + ).fetchone() + if not row or row[0] is None: + return None + return get_scene(conn, row[0]) diff --git a/tests/test_memory.py b/tests/test_memory.py index 3d174b0..3675ce6 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -219,11 +219,11 @@ def test_memory_payload_defaults_when_optional_missing(tmp_path): assert mem["auto_pinned"] == 0 -def test_schema_version_after_migration_is_6(tmp_path): +def test_schema_version_after_migration_is_at_least_6(tmp_path): db = tmp_path / "t.db" apply_migrations(db) with open_db(db) as conn: row = conn.execute( "SELECT value FROM meta WHERE key = 'schema_version'" ).fetchone() - assert int(row[0]) == 6 + assert int(row[0]) >= 6 diff --git a/tests/test_world.py b/tests/test_world.py new file mode 100644 index 0000000..29a0bb0 --- /dev/null +++ b/tests/test_world.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +from chat.db.connection import open_db +from chat.db.migrate import apply_migrations +from chat.eventlog.log import append_event +from chat.eventlog.projector import project +import chat.state.world # registers handlers +from chat.state.world import ( + active_scene, + find_container, + get_activity, + get_chat, + get_container, + get_scene, + list_chats, +) + + +def _chat_payload(**overrides): + payload = { + "id": "chat_bot_a", + "host_bot_id": "bot_a", + "guest_bot_id": None, + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1 evening", + "weather": "clear", + } + payload.update(overrides) + return payload + + +def _container_payload(**overrides): + payload = { + "chat_id": "chat_bot_a", + "name": "office", + "type": "workplace", + "properties": { + "public": True, + "moving": False, + "audible_range": "normal", + "slots": [], + }, + "parent_id": None, + } + payload.update(overrides) + return payload + + +def test_chat_created_initializes_chats_and_chat_state(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="chat_created", payload=_chat_payload()) + project(conn) + chat = get_chat(conn, "chat_bot_a") + assert chat is not None + assert chat["id"] == "chat_bot_a" + assert chat["host_bot_id"] == "bot_a" + assert chat["guest_bot_id"] is None + assert chat["time"] == "2026-04-26T20:00:00+00:00" + assert chat["weather"] == "clear" + assert chat["narrative_anchor"] == "Day 1 evening" + assert chat["active_scene_id"] is None + + +def test_get_chat_returns_none_for_missing_id(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + assert get_chat(conn, "chat_missing") is None + + +def test_list_chats_returns_all_joined_rows(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="chat_created", payload=_chat_payload()) + append_event(conn, kind="chat_created", payload=_chat_payload( + id="chat_bot_b", host_bot_id="bot_b", + )) + project(conn) + chats = list_chats(conn) + assert len(chats) == 2 + ids = [c["id"] for c in chats] + assert ids == sorted(ids) + assert all("time" in c for c in chats) + + +def test_chat_created_with_optional_fields_defaulted(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="chat_created", payload={ + "id": "chat_bot_a", + "host_bot_id": "bot_a", + "initial_time": "2026-04-26T20:00:00+00:00", + }) + project(conn) + chat = get_chat(conn, "chat_bot_a") + assert chat is not None + assert chat["guest_bot_id"] is None + assert chat["weather"] == "" + assert chat["narrative_anchor"] == "" + assert chat["active_scene_id"] is None + + +def test_container_created_inserts_and_findable_by_name(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="chat_created", payload=_chat_payload()) + append_event(conn, kind="container_created", payload=_container_payload()) + project(conn) + c = find_container(conn, "chat_bot_a", "office") + assert c is not None + assert c["name"] == "office" + assert c["type"] == "workplace" + assert c["chat_id"] == "chat_bot_a" + assert c["parent_id"] is None + assert c["properties"] == { + "public": True, + "moving": False, + "audible_range": "normal", + "slots": [], + } + # get_container by id also works + c2 = get_container(conn, c["id"]) + assert c2 is not None + assert c2["name"] == "office" + assert c2["properties"]["public"] is True + + +def test_find_container_returns_none_when_missing(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + assert find_container(conn, "chat_bot_a", "nope") is None + assert get_container(conn, 999) is None + + +def test_activity_change_inserts_then_replaces(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="chat_created", payload=_chat_payload()) + append_event(conn, kind="container_created", payload=_container_payload()) + # First activity event then a second for the same entity that supersedes it. + append_event(conn, kind="activity_change", payload={ + "entity_id": "bot_a", + "container_id": 1, + "slot": "desk_chair", + "posture": "sitting", + "action": { + "verb": "writing email", + "interruptible": True, + "required_attention": "medium", + "expected_duration": "a few minutes", + "started_at": "2026-04-26T20:00:00+00:00", + }, + "attention": "the screen", + "holding": [], + "status": {}, + }) + append_event(conn, kind="activity_change", payload={ + "entity_id": "bot_a", + "container_id": 1, + "posture": "standing", + "action": {"verb": "pacing"}, + "attention": "the window", + }) + project(conn) + a = get_activity(conn, "bot_a") + assert a is not None + assert a["entity_id"] == "bot_a" + assert a["container_id"] == 1 + # second event replaced first + assert a["posture"] == "standing" + assert a["action"]["verb"] == "pacing" + assert a["attention"] == "the window" + # only one row exists for that entity + count = conn.execute( + "SELECT COUNT(*) FROM activity WHERE entity_id = ?", ("bot_a",) + ).fetchone()[0] + assert count == 1 + + +def test_activity_change_initial_values_persist_when_only_one_event(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="chat_created", payload=_chat_payload()) + append_event(conn, kind="container_created", payload=_container_payload()) + append_event(conn, kind="activity_change", payload={ + "entity_id": "bot_a", + "container_id": 1, + "slot": "desk_chair", + "posture": "sitting", + "action": {"verb": "writing email"}, + "attention": "the screen", + "holding": ["pen"], + "status": {"hungry": False}, + }) + project(conn) + a = get_activity(conn, "bot_a") + assert a is not None + assert a["slot"] == "desk_chair" + assert a["posture"] == "sitting" + assert a["action"]["verb"] == "writing email" + assert a["attention"] == "the screen" + assert a["holding"] == ["pen"] + assert a["status"] == {"hungry": False} + + +def test_activity_change_defaults_for_minimal_payload(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="activity_change", payload={ + "entity_id": "you", + }) + project(conn) + a = get_activity(conn, "you") + assert a is not None + assert a["container_id"] is None + assert a["slot"] is None + assert a["posture"] == "" + assert a["action"] == {} + assert a["attention"] == "" + assert a["holding"] == [] + assert a["status"] == {} + + +def test_get_activity_returns_none_for_missing(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + assert get_activity(conn, "ghost") is None + + +def test_scene_opened_marks_active_scene_id(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="chat_created", payload=_chat_payload()) + append_event(conn, kind="container_created", payload=_container_payload()) + append_event(conn, kind="scene_opened", payload={ + "chat_id": "chat_bot_a", + "container_id": 1, + "started_at": "2026-04-26T20:00:00+00:00", + "participants": ["you", "bot_a"], + }) + project(conn) + + s = active_scene(conn, "chat_bot_a") + assert s is not None + assert s["chat_id"] == "chat_bot_a" + assert s["container_id"] == 1 + assert s["started_at"] == "2026-04-26T20:00:00+00:00" + assert s["ended_at"] is None + assert s["significance"] == 0 + assert s["participants"] == ["you", "bot_a"] + + chat = get_chat(conn, "chat_bot_a") + assert chat["active_scene_id"] == s["id"] + + s2 = get_scene(conn, s["id"]) + assert s2 is not None + assert s2["participants"] == ["you", "bot_a"] + + +def test_scene_closed_clears_active_scene_id_and_records_end(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="chat_created", payload=_chat_payload()) + append_event(conn, kind="container_created", payload=_container_payload()) + append_event(conn, kind="scene_opened", payload={ + "chat_id": "chat_bot_a", + "container_id": 1, + "started_at": "2026-04-26T20:00:00+00:00", + "participants": ["you", "bot_a"], + }) + # The first scene insert will be id=1 (first row in scenes). + append_event(conn, kind="scene_closed", payload={ + "scene_id": 1, + "ended_at": "2026-04-26T21:00:00+00:00", + "significance": 2, + }) + project(conn) + + assert active_scene(conn, "chat_bot_a") is None + chat = get_chat(conn, "chat_bot_a") + assert chat["active_scene_id"] is None + s = get_scene(conn, 1) + assert s["ended_at"] == "2026-04-26T21:00:00+00:00" + assert s["significance"] == 2 + + +def test_scene_closed_significance_defaults_to_zero(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="chat_created", payload=_chat_payload()) + append_event(conn, kind="scene_opened", payload={ + "chat_id": "chat_bot_a", + "started_at": "2026-04-26T20:00:00+00:00", + "participants": ["you", "bot_a"], + }) + append_event(conn, kind="scene_closed", payload={ + "scene_id": 1, + "ended_at": "2026-04-26T21:00:00+00:00", + }) + project(conn) + s = get_scene(conn, 1) + assert s["significance"] == 0 + assert s["ended_at"] == "2026-04-26T21:00:00+00:00" + + +def test_get_scene_returns_none_for_missing(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + assert get_scene(conn, 999) is None + assert active_scene(conn, "chat_missing") is None + + +def test_schema_version_after_migration_is_7(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + row = conn.execute( + "SELECT value FROM meta WHERE key = 'schema_version'" + ).fetchone() + assert int(row[0]) == 7 -- 2.52.0 From a5339fc1d2ce6aac391ea18d10213836e8293055 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 12:09:17 -0400 Subject: [PATCH 12/45] feat: kickoff prose parser via classifier --- chat/services/__init__.py | 0 chat/services/kickoff.py | 121 +++++++++++++++++++++++++++++++++++ tests/test_kickoff.py | 131 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 chat/services/__init__.py create mode 100644 chat/services/kickoff.py create mode 100644 tests/test_kickoff.py diff --git a/chat/services/__init__.py b/chat/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/services/kickoff.py b/chat/services/kickoff.py new file mode 100644 index 0000000..591b09c --- /dev/null +++ b/chat/services/kickoff.py @@ -0,0 +1,121 @@ +"""Kickoff prose parser. + +Service-layer function that converts a bot's authored kickoff prose into a +structured ``KickoffParse`` for the kickoff confirm-and-edit step (T13 will +wire this into the UI flow). + +The classifier prompt includes only the bot context that's load-bearing for +parsing the opening scene: name, persona, the authored +``initial_relationship_to_you`` blurb, the ``you`` entity name, and the +kickoff prose itself. Other identity fields (traits, backstory, ...) are +intentionally left out — they would be noise for this extraction. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from chat.llm.classify import classify +from chat.llm.client import LLMClient + + +class ActivityShape(BaseModel): + """Per-entity activity at scene start. + + Maps onto Requirements §6.5: ``current_action.{verb,interruptible, + required_attention,expected_duration}`` plus posture, attention, holding. + ``action_required_attention`` is left as a free-form string ("low" / + "medium" / "high" expected) rather than a Literal so the classifier has + room to vary phrasing in v1. + """ + + posture: str + action_verb: str + action_interruptible: bool + action_required_attention: str # low | medium | high + action_expected_duration: str + attention: str = "" + holding: list[str] = Field(default_factory=list) + + +class KickoffParse(BaseModel): + """Structured opening-scene state extracted from kickoff prose. + + ``container_properties`` is loose ``dict``: the classifier may emit + ``moving`` / ``public`` / ``audible_range`` keys, but downstream + consumers (T13's confirm form) handle missing keys gracefully. + ``initial_time_iso`` is stored as text — not validated as a datetime + here; ``chat_state.time`` stores it as a plain string. + """ + + container_name: str + container_type: str + container_properties: dict + you_activity: ActivityShape + bot_activity: ActivityShape + initial_time_iso: str + edge_seed_summary: str + edge_seed_knowledge_facts: list[str] + + +_SYSTEM_PROMPT = ( + "You are extracting structured scene state from a roleplay kickoff " + "scene description. The user provides bot context and a prose " + "description of the opening scene; you output JSON conforming to the " + "schema. Be concrete: pick a single container, single activity per " + "entity, and a sensible initial in-fiction time. Anything not stated " + "explicitly should be inferred reasonably from the prose." +) + + +def _build_user_prompt( + *, + bot_name: str, + bot_persona: str, + initial_relationship_to_you: str, + kickoff_prose: str, + you_name: str, +) -> str: + return ( + f"BOT NAME: {bot_name}\n" + f"BOT PERSONA: {bot_persona}\n" + f"INITIAL RELATIONSHIP TO {you_name}: {initial_relationship_to_you}\n" + f"YOU NAME: {you_name}\n" + f"KICKOFF PROSE:\n{kickoff_prose}" + ) + + +async def parse_kickoff( + client: LLMClient, + *, + model: str, + bot_name: str, + bot_persona: str, + initial_relationship_to_you: str, + kickoff_prose: str, + you_name: str, + timeout_s: float = 10.0, +) -> KickoffParse: + """Parse authored kickoff prose into a structured ``KickoffParse``. + + Internally calls :func:`chat.llm.classify.classify` with a labeled + user prompt. Raises ``RuntimeError`` if the classifier fails twice in + a row — no default is supplied at this layer, since the caller (T13's + confirm form) is responsible for showing an error and letting the + user edit. + """ + user_prompt = _build_user_prompt( + bot_name=bot_name, + bot_persona=bot_persona, + initial_relationship_to_you=initial_relationship_to_you, + kickoff_prose=kickoff_prose, + you_name=you_name, + ) + return await classify( + client, + model=model, + system=_SYSTEM_PROMPT, + user=user_prompt, + schema=KickoffParse, + timeout_s=timeout_s, + ) diff --git a/tests/test_kickoff.py b/tests/test_kickoff.py new file mode 100644 index 0000000..c3dfc14 --- /dev/null +++ b/tests/test_kickoff.py @@ -0,0 +1,131 @@ +import json +import pytest + +from chat.llm.mock import MockLLMClient +from chat.services.kickoff import ( + ActivityShape, + KickoffParse, + parse_kickoff, +) + + +def _full_kickoff_json() -> str: + return json.dumps( + { + "container_name": "office bullpen, late evening", + "container_type": "office", + "container_properties": { + "moving": False, + "public": False, + "audible_range": "room", + }, + "you_activity": { + "posture": "sitting at your desk", + "action_verb": "finishing emails", + "action_interruptible": True, + "action_required_attention": "low", + "action_expected_duration": "15m", + "attention": "the screen", + "holding": ["coffee mug"], + }, + "bot_activity": { + "posture": "sitting at her desk", + "action_verb": "pretending to work", + "action_interruptible": True, + "action_required_attention": "low", + "action_expected_duration": "indefinite", + "attention": "you, in glances", + "holding": [], + }, + "initial_time_iso": "2026-04-26T19:42:00", + "edge_seed_summary": "coworkers; aware of each other; no shared history beyond the office", + "edge_seed_knowledge_facts": [ + "they work on the same floor", + "it is unusual to be the only two left", + ], + } + ) + + +@pytest.mark.asyncio +async def test_parse_kickoff_happy_path_populates_fields(): + mock = MockLLMClient(canned=[_full_kickoff_json()]) + result = await parse_kickoff( + mock, + model="m", + bot_name="BotA", + bot_persona="reserved colleague who quietly notices things", + initial_relationship_to_you="coworker, slight crush, never voiced", + kickoff_prose=( + "you stay late at the office; only you and BotA are there; " + "she's at her desk pretending to work" + ), + you_name="You", + ) + assert isinstance(result, KickoffParse) + assert result.container_name == "office bullpen, late evening" + assert result.container_type == "office" + assert isinstance(result.you_activity, ActivityShape) + assert result.you_activity.posture == "sitting at your desk" + assert result.bot_activity.action_verb == "pretending to work" + assert result.edge_seed_summary.startswith("coworkers") + assert "they work on the same floor" in result.edge_seed_knowledge_facts + assert result.initial_time_iso == "2026-04-26T19:42:00" + + +@pytest.mark.asyncio +async def test_parse_kickoff_applies_activity_defaults_for_missing_fields(): + minimal_payload = { + "container_name": "kitchen", + "container_type": "kitchen", + "container_properties": {}, + "you_activity": { + "posture": "standing", + "action_verb": "boiling water", + "action_interruptible": True, + "action_required_attention": "low", + "action_expected_duration": "5m", + }, + "bot_activity": { + "posture": "leaning on the counter", + "action_verb": "scrolling phone", + "action_interruptible": True, + "action_required_attention": "low", + "action_expected_duration": "10m", + }, + "initial_time_iso": "2026-04-26T08:00:00", + "edge_seed_summary": "roommates", + "edge_seed_knowledge_facts": [], + } + mock = MockLLMClient(canned=[json.dumps(minimal_payload)]) + result = await parse_kickoff( + mock, + model="m", + bot_name="BotA", + bot_persona="laid-back roommate", + initial_relationship_to_you="roommates of two years", + kickoff_prose="morning in the kitchen; you're making tea while BotA scrolls her phone", + you_name="You", + ) + assert result.you_activity.attention == "" + assert result.you_activity.holding == [] + assert result.bot_activity.attention == "" + assert result.bot_activity.holding == [] + # mutating one default must not leak into the other (default_factory check) + result.you_activity.holding.append("kettle") + assert result.bot_activity.holding == [] + + +@pytest.mark.asyncio +async def test_parse_kickoff_raises_when_classifier_fails_twice(): + mock = MockLLMClient(canned=["nope", "still nope"]) + with pytest.raises(RuntimeError): + await parse_kickoff( + mock, + model="m", + bot_name="BotA", + bot_persona="x", + initial_relationship_to_you="y", + kickoff_prose="z", + you_name="You", + ) -- 2.52.0 From 44ea627a8a1bbe45582ab2df598b9d736f6dc4e9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 12:17:06 -0400 Subject: [PATCH 13/45] feat: bot authoring form with bot_authored event --- chat/app.py | 35 +++++++++- chat/static/app.css | 37 ++++++++++ chat/templates/base.html | 18 +++++ chat/templates/bot_form.html | 57 +++++++++++++++ chat/templates/bot_list.html | 17 +++++ chat/web/__init__.py | 0 chat/web/bots.py | 116 ++++++++++++++++++++++++++++++ pyproject.toml | 1 + tests/test_bot_authoring.py | 132 +++++++++++++++++++++++++++++++++++ 9 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 chat/static/app.css create mode 100644 chat/templates/base.html create mode 100644 chat/templates/bot_form.html create mode 100644 chat/templates/bot_list.html create mode 100644 chat/web/__init__.py create mode 100644 chat/web/bots.py create mode 100644 tests/test_bot_authoring.py diff --git a/chat/app.py b/chat/app.py index 66990d8..0ac28ef 100644 --- a/chat/app.py +++ b/chat/app.py @@ -1,6 +1,37 @@ -from fastapi import FastAPI +from __future__ import annotations +from contextlib import asynccontextmanager +from pathlib import Path -app = FastAPI(title="chat") +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from chat.config import load_settings +from chat.db.migrate import apply_migrations + +# Trigger handler registration: +import chat.state.entities # noqa: F401 +import chat.state.edges # noqa: F401 +import chat.state.memory # noqa: F401 +import chat.state.world # noqa: F401 + +from chat.web.bots import router as bots_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings = load_settings() + settings.db_path.parent.mkdir(parents=True, exist_ok=True) + apply_migrations(settings.db_path) + app.state.settings = settings + yield + + +app = FastAPI(title="chat", lifespan=lifespan) + +STATIC_DIR = Path(__file__).resolve().parent / "static" +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + +app.include_router(bots_router) @app.get("/health") diff --git a/chat/static/app.css b/chat/static/app.css new file mode 100644 index 0000000..96a0296 --- /dev/null +++ b/chat/static/app.css @@ -0,0 +1,37 @@ +* { box-sizing: border-box; } +body { + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; + margin: 0; + color: #1c1c1c; + background: #fafafa; +} +.topbar { + padding: 12px 24px; + border-bottom: 1px solid #e5e5e5; + background: #fff; +} +.brand { font-weight: 600; text-decoration: none; color: inherit; } +.container { max-width: 720px; margin: 24px auto; padding: 0 16px; } +h1 { margin-top: 0; } +.page-header { display: flex; align-items: center; justify-content: space-between; } +.btn, button { + display: inline-block; padding: 8px 14px; + border: 1px solid #444; background: #1c1c1c; color: #fff; + border-radius: 4px; text-decoration: none; cursor: pointer; + font: inherit; +} +.bot-form label { display: block; margin-bottom: 14px; } +.bot-form label span { display: block; font-weight: 600; margin-bottom: 4px; } +.bot-form input[type=text], .bot-form textarea { + width: 100%; padding: 6px 8px; font: inherit; + border: 1px solid #ccc; border-radius: 3px; background: #fff; +} +.bot-form small { display: block; color: #666; margin-top: 2px; } +.bot-list { list-style: none; padding: 0; } +.bot-list li { padding: 8px 0; border-bottom: 1px solid #eee; } +.muted { color: #666; } +.error { + padding: 8px 12px; border: 1px solid #c33; background: #fdecea; + color: #a00; border-radius: 3px; +} +code { font-family: ui-monospace, "SF Mono", Menlo, monospace; } diff --git a/chat/templates/base.html b/chat/templates/base.html new file mode 100644 index 0000000..6c86104 --- /dev/null +++ b/chat/templates/base.html @@ -0,0 +1,18 @@ + + + + + + {% block title %}chat{% endblock %} + + + + +
+ chat +
+
+ {% block content %}{% endblock %} +
+ + diff --git a/chat/templates/bot_form.html b/chat/templates/bot_form.html new file mode 100644 index 0000000..e0d3331 --- /dev/null +++ b/chat/templates/bot_form.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}New bot - chat{% endblock %} +{% block content %} +

New bot

+{% if error %} +

{{ error }}

+{% endif %} +
+ + + + + + + + + + + + + + + + + +
+{% endblock %} diff --git a/chat/templates/bot_list.html b/chat/templates/bot_list.html new file mode 100644 index 0000000..f3b4562 --- /dev/null +++ b/chat/templates/bot_list.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Bots - chat{% endblock %} +{% block content %} + +{% if bots %} + +{% else %} +

No bots yet. Create your first bot.

+{% endif %} +{% endblock %} diff --git a/chat/web/__init__.py b/chat/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/web/bots.py b/chat/web/bots.py new file mode 100644 index 0000000..bbf077d --- /dev/null +++ b/chat/web/bots.py @@ -0,0 +1,116 @@ +from __future__ import annotations +import sqlite3 +from pathlib import Path +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import RedirectResponse, HTMLResponse +from fastapi.templating import Jinja2Templates + +from chat.eventlog.log import append_event +from chat.eventlog.projector import project +from chat.state.entities import list_bots + +TEMPLATES = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) + +router = APIRouter() + +REQUIRED_FIELDS = ("id", "name", "persona", "initial_relationship_to_you", "kickoff_prose") + + +def get_conn(request: Request): + settings = request.app.state.settings + db_path: Path = settings.db_path + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path, check_same_thread=False) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + try: + yield conn + conn.commit() + finally: + conn.close() + + +def _split_voice_samples(text: str) -> list[str]: + if not text or not text.strip(): + return [] + # Split on a line containing only "---" (with optional surrounding whitespace). + parts: list[str] = [] + buf: list[str] = [] + for line in text.splitlines(): + if line.strip() == "---": + if buf: + parts.append("\n".join(buf).strip()) + buf = [] + continue + buf.append(line) + if buf: + parts.append("\n".join(buf).strip()) + return [p for p in parts if p] + + +def _split_traits(text: str) -> list[str]: + if not text or not text.strip(): + return [] + items: list[str] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + if "," in line: + items.extend(p.strip() for p in line.split(",")) + else: + items.append(line) + return [t for t in items if t] + + +@router.get("/bots", response_class=HTMLResponse) +async def bots_list(request: Request, conn=Depends(get_conn)): + bots = list_bots(conn) + return TEMPLATES.TemplateResponse(request, "bot_list.html", {"bots": bots}) + + +@router.get("/bots/new", response_class=HTMLResponse) +async def bot_form(request: Request): + return TEMPLATES.TemplateResponse(request, "bot_form.html", {"values": {}, "error": None}) + + +@router.post("/bots/new") +async def bot_create( + request: Request, + id: str = Form(""), + name: str = Form(""), + persona: str = Form(""), + voice_samples: str = Form(""), + traits: str = Form(""), + backstory: str = Form(""), + initial_relationship_to_you: str = Form(""), + kickoff_prose: str = Form(""), + conn=Depends(get_conn), +): + values = { + "id": id, + "name": name, + "persona": persona, + "voice_samples": voice_samples, + "traits": traits, + "backstory": backstory, + "initial_relationship_to_you": initial_relationship_to_you, + "kickoff_prose": kickoff_prose, + } + missing = [f for f in REQUIRED_FIELDS if not values[f].strip()] + if missing: + raise HTTPException(status_code=400, detail=f"missing required: {', '.join(missing)}") + + payload = { + "id": id.strip(), + "name": name.strip(), + "persona": persona.strip(), + "voice_samples": _split_voice_samples(voice_samples), + "traits": _split_traits(traits), + "backstory": backstory.strip(), + "initial_relationship_to_you": initial_relationship_to_you.strip(), + "kickoff_prose": kickoff_prose.strip(), + } + append_event(conn, kind="bot_authored", payload=payload) + project(conn) + return RedirectResponse(url=f"/bots/{payload['id']}/kickoff", status_code=303) diff --git a/pyproject.toml b/pyproject.toml index c0593a9..b72bc90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "tiktoken>=0.7", "jinja2>=3.1", "aiosqlite>=0.20", + "python-multipart>=0.0.9", ] [project.optional-dependencies] diff --git a/tests/test_bot_authoring.py b/tests/test_bot_authoring.py new file mode 100644 index 0000000..5b8a82d --- /dev/null +++ b/tests/test_bot_authoring.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from chat.app import app + + +@pytest.fixture +def client(tmp_path, monkeypatch): + config_path = tmp_path / "config.toml" + config_path.write_text('featherless_api_key = "test"\n') + monkeypatch.setenv("CHAT_CONFIG_PATH", str(config_path)) + monkeypatch.setenv("CHAT_DB_PATH", str(tmp_path / "test.db")) + with TestClient(app) as c: + yield c + + +def test_get_new_bot_form_renders(client): + response = client.get("/bots/new") + assert response.status_code == 200 + body = response.text.lower() + assert " Date: Sun, 26 Apr 2026 12:22:00 -0400 Subject: [PATCH 14/45] feat: settings page with you-entity authoring --- chat/app.py | 2 + chat/static/app.css | 4 ++ chat/templates/settings.html | 29 +++++++++ chat/web/settings.py | 46 ++++++++++++++ tests/test_settings.py | 112 +++++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+) create mode 100644 chat/templates/settings.html create mode 100644 chat/web/settings.py create mode 100644 tests/test_settings.py diff --git a/chat/app.py b/chat/app.py index 0ac28ef..82e49ad 100644 --- a/chat/app.py +++ b/chat/app.py @@ -15,6 +15,7 @@ import chat.state.memory # noqa: F401 import chat.state.world # noqa: F401 from chat.web.bots import router as bots_router +from chat.web.settings import router as settings_router @asynccontextmanager @@ -32,6 +33,7 @@ STATIC_DIR = Path(__file__).resolve().parent / "static" app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.include_router(bots_router) +app.include_router(settings_router) @app.get("/health") diff --git a/chat/static/app.css b/chat/static/app.css index 96a0296..9c261a8 100644 --- a/chat/static/app.css +++ b/chat/static/app.css @@ -34,4 +34,8 @@ h1 { margin-top: 0; } padding: 8px 12px; border: 1px solid #c33; background: #fdecea; color: #a00; border-radius: 3px; } +.success { + padding: 8px 12px; border: 1px solid #2d7a3a; background: #eafaf0; + color: #1f5c2a; border-radius: 3px; +} code { font-family: ui-monospace, "SF Mono", Menlo, monospace; } diff --git a/chat/templates/settings.html b/chat/templates/settings.html new file mode 100644 index 0000000..e26fcf6 --- /dev/null +++ b/chat/templates/settings.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Settings - chat{% endblock %} +{% block content %} +

Settings

+{% if saved %} +

Settings saved.

+{% endif %} +
+ + + + + + + +
+{% endblock %} diff --git a/chat/web/settings.py b/chat/web/settings.py new file mode 100644 index 0000000..cb04d58 --- /dev/null +++ b/chat/web/settings.py @@ -0,0 +1,46 @@ +from __future__ import annotations +from pathlib import Path +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from chat.eventlog.log import append_event +from chat.eventlog.projector import project +from chat.state.entities import get_you +from chat.web.bots import get_conn + +TEMPLATES = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) + +router = APIRouter() + + +@router.get("/settings", response_class=HTMLResponse) +async def settings_get(request: Request, conn=Depends(get_conn)): + you = get_you(conn) or {"name": "", "pronouns": "", "persona": ""} + return TEMPLATES.TemplateResponse( + request, "settings.html", {"values": you, "saved": False} + ) + + +@router.post("/settings", response_class=HTMLResponse) +async def settings_post( + request: Request, + name: str = Form(""), + pronouns: str = Form(""), + persona: str = Form(""), + conn=Depends(get_conn), +): + if not name.strip(): + raise HTTPException(status_code=400, detail="name is required") + + payload = { + "name": name.strip(), + "pronouns": pronouns.strip(), + "persona": persona.strip(), + } + append_event(conn, kind="you_authored", payload=payload) + project(conn) + + return TEMPLATES.TemplateResponse( + request, "settings.html", {"values": payload, "saved": True} + ) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..18d2c4b --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from chat.app import app + + +@pytest.fixture +def client(tmp_path, monkeypatch): + config_path = tmp_path / "config.toml" + config_path.write_text('featherless_api_key = "test"\n') + monkeypatch.setenv("CHAT_CONFIG_PATH", str(config_path)) + monkeypatch.setenv("CHAT_DB_PATH", str(tmp_path / "test.db")) + with TestClient(app) as c: + yield c + + +def test_get_settings_renders_empty(client): + response = client.get("/settings") + assert response.status_code == 200 + body = response.text.lower() + assert " Date: Sun, 26 Apr 2026 12:28:05 -0400 Subject: [PATCH 15/45] feat: kickoff parse-and-confirm flow with chat creation --- chat/app.py | 2 + chat/templates/kickoff_confirm.html | 118 ++++++++++++ chat/web/kickoff.py | 284 ++++++++++++++++++++++++++++ tests/test_kickoff_confirm.py | 212 +++++++++++++++++++++ 4 files changed, 616 insertions(+) create mode 100644 chat/templates/kickoff_confirm.html create mode 100644 chat/web/kickoff.py create mode 100644 tests/test_kickoff_confirm.py diff --git a/chat/app.py b/chat/app.py index 82e49ad..4251290 100644 --- a/chat/app.py +++ b/chat/app.py @@ -15,6 +15,7 @@ import chat.state.memory # noqa: F401 import chat.state.world # noqa: F401 from chat.web.bots import router as bots_router +from chat.web.kickoff import router as kickoff_router from chat.web.settings import router as settings_router @@ -33,6 +34,7 @@ STATIC_DIR = Path(__file__).resolve().parent / "static" app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.include_router(bots_router) +app.include_router(kickoff_router) app.include_router(settings_router) diff --git a/chat/templates/kickoff_confirm.html b/chat/templates/kickoff_confirm.html new file mode 100644 index 0000000..d830f39 --- /dev/null +++ b/chat/templates/kickoff_confirm.html @@ -0,0 +1,118 @@ +{% extends "base.html" %} +{% block title %}Confirm kickoff - chat{% endblock %} +{% block content %} +

Confirm kickoff

+

Review and edit the parsed opening scene for {{ values.bot_name }}, then confirm to start the chat.

+ +
+ +
+ Container + + + +
+ +
+ Initial in-fiction time + +
+ +
+ Your activity + + + + + + + +
+ +
+ {{ values.bot_name }}'s activity + + + + + + + +
+ +
+ Edge seed + + +
+ +
+ + Cancel +
+
+{% endblock %} diff --git a/chat/web/kickoff.py b/chat/web/kickoff.py new file mode 100644 index 0000000..4edfba9 --- /dev/null +++ b/chat/web/kickoff.py @@ -0,0 +1,284 @@ +"""Kickoff parse-and-confirm flow. + +After a bot is authored, the user lands on ``/bots//kickoff``. We call the +LLM-backed ``parse_kickoff`` to extract a structured opening scene from the +authored prose and render it as an editable form. On submit, the (possibly +edited) values are turned into a sequence of events that initialize the chat, +its container, the participants' activities, an open scene, and a seed edge. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path + +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +from chat.eventlog.log import append_event +from chat.eventlog.projector import project +from chat.llm.client import LLMClient +from chat.services.kickoff import parse_kickoff +from chat.state.entities import get_bot, get_you +from chat.web.bots import get_conn + +TEMPLATES = Jinja2Templates( + directory=str(Path(__file__).resolve().parent.parent / "templates") +) + +router = APIRouter() + + +def get_llm_client(request: Request) -> LLMClient: + """Production LLM client. Tests override this via ``app.dependency_overrides``.""" + settings = request.app.state.settings + from chat.llm.featherless import FeatherlessClient + + return FeatherlessClient( + api_key=settings.featherless_api_key, + base_url=settings.featherless_base_url, + ) + + +def _parse_holding(text: str) -> list[str]: + if not text or not text.strip(): + return [] + return [p.strip() for p in text.split(",") if p.strip()] + + +def _parse_facts(text: str) -> list[str]: + if not text or not text.strip(): + return [] + return [line.strip() for line in text.splitlines() if line.strip()] + + +def _parse_properties(text: str) -> dict: + """Parse the container_properties textarea as JSON. + + Returns ``{}`` on invalid JSON rather than raising — the form is editable + and a bad value should not block the user from confirming the rest. + """ + if not text or not text.strip(): + return {} + try: + loaded = json.loads(text) + return loaded if isinstance(loaded, dict) else {} + except (json.JSONDecodeError, ValueError): + return {} + + +@router.get("/bots/{bot_id}/kickoff", response_class=HTMLResponse) +async def kickoff_get( + bot_id: str, + request: Request, + conn=Depends(get_conn), + llm=Depends(get_llm_client), +): + bot = get_bot(conn, bot_id) + if bot is None: + raise HTTPException(status_code=404, detail=f"bot not found: {bot_id}") + + you = get_you(conn) + you_name = you["name"] if you else "You" + + settings = request.app.state.settings + parsed = await parse_kickoff( + llm, + model=settings.classifier_model, + bot_name=bot["name"], + bot_persona=bot["persona"], + initial_relationship_to_you=bot.get("initial_relationship_to_you", ""), + kickoff_prose=bot.get("kickoff_prose", ""), + you_name=you_name, + timeout_s=settings.classifier_timeout_s, + ) + + # Render values onto the form. ``container_properties`` is shown as JSON; + # ``holding`` lists are rendered as comma-separated text; the seed + # knowledge facts are rendered one-per-line. + values = { + "bot_id": bot_id, + "bot_name": bot["name"], + "container_name": parsed.container_name, + "container_type": parsed.container_type, + "container_properties": json.dumps(parsed.container_properties, indent=2), + "initial_time_iso": parsed.initial_time_iso, + "you_activity_posture": parsed.you_activity.posture, + "you_activity_action_verb": parsed.you_activity.action_verb, + "you_activity_action_interruptible": parsed.you_activity.action_interruptible, + "you_activity_action_required_attention": parsed.you_activity.action_required_attention, + "you_activity_action_expected_duration": parsed.you_activity.action_expected_duration, + "you_activity_attention": parsed.you_activity.attention, + "you_activity_holding": ", ".join(parsed.you_activity.holding), + "bot_activity_posture": parsed.bot_activity.posture, + "bot_activity_action_verb": parsed.bot_activity.action_verb, + "bot_activity_action_interruptible": parsed.bot_activity.action_interruptible, + "bot_activity_action_required_attention": parsed.bot_activity.action_required_attention, + "bot_activity_action_expected_duration": parsed.bot_activity.action_expected_duration, + "bot_activity_attention": parsed.bot_activity.attention, + "bot_activity_holding": ", ".join(parsed.bot_activity.holding), + "edge_seed_summary": parsed.edge_seed_summary, + "edge_seed_knowledge_facts": "\n".join(parsed.edge_seed_knowledge_facts), + } + return TEMPLATES.TemplateResponse(request, "kickoff_confirm.html", {"values": values}) + + +@router.post("/bots/{bot_id}/kickoff") +async def kickoff_post( + bot_id: str, + request: Request, + container_name: str = Form(""), + container_type: str = Form(""), + container_properties: str = Form(""), + initial_time_iso: str = Form(""), + you_activity_posture: str = Form(""), + you_activity_action_verb: str = Form(""), + you_activity_action_interruptible: str = Form(""), + you_activity_action_required_attention: str = Form("low"), + you_activity_action_expected_duration: str = Form(""), + you_activity_attention: str = Form(""), + you_activity_holding: str = Form(""), + bot_activity_posture: str = Form(""), + bot_activity_action_verb: str = Form(""), + bot_activity_action_interruptible: str = Form(""), + bot_activity_action_required_attention: str = Form("low"), + bot_activity_action_expected_duration: str = Form(""), + bot_activity_attention: str = Form(""), + bot_activity_holding: str = Form(""), + edge_seed_summary: str = Form(""), + edge_seed_knowledge_facts: str = Form(""), + conn=Depends(get_conn), +): + bot = get_bot(conn, bot_id) + if bot is None: + raise HTTPException(status_code=404, detail=f"bot not found: {bot_id}") + + # Loose ISO 8601 validation. ``datetime.fromisoformat`` accepts the offset + # form ``2026-04-26T20:00:00+00:00`` we use; reject anything it can't parse. + if initial_time_iso.strip(): + try: + datetime.fromisoformat(initial_time_iso.strip()) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"invalid initial_time_iso: {initial_time_iso!r}", + ) + + chat_id = f"chat_{bot_id}" + + # Predict the next container id so we can reference it from later events + # without needing a mid-flow projection. Containers use AUTOINCREMENT-style + # rowid, so MAX(id)+1 is safe within this single-writer transaction. + next_container_row = conn.execute( + "SELECT COALESCE(MAX(id), 0) + 1 FROM containers" + ).fetchone() + container_id = next_container_row[0] + + # 1. chat_created + append_event( + conn, + kind="chat_created", + payload={ + "id": chat_id, + "host_bot_id": bot_id, + "initial_time": initial_time_iso, + "narrative_anchor": "Day 1", + "weather": "", + }, + ) + + # 2. container_created + append_event( + conn, + kind="container_created", + payload={ + "chat_id": chat_id, + "name": container_name, + "type": container_type, + "properties": _parse_properties(container_properties), + "parent_id": None, + }, + ) + + you_interruptible = bool(you_activity_action_interruptible) + bot_interruptible = bool(bot_activity_action_interruptible) + + # 3. activity_change for "you" + append_event( + conn, + kind="activity_change", + payload={ + "entity_id": "you", + "container_id": container_id, + "posture": you_activity_posture, + "action": { + "verb": you_activity_action_verb, + "interruptible": you_interruptible, + "required_attention": you_activity_action_required_attention, + "expected_duration": you_activity_action_expected_duration, + "started_at": initial_time_iso, + }, + "attention": you_activity_attention, + "holding": _parse_holding(you_activity_holding), + "status": {}, + }, + ) + + # 4. activity_change for bot + append_event( + conn, + kind="activity_change", + payload={ + "entity_id": bot_id, + "container_id": container_id, + "posture": bot_activity_posture, + "action": { + "verb": bot_activity_action_verb, + "interruptible": bot_interruptible, + "required_attention": bot_activity_action_required_attention, + "expected_duration": bot_activity_action_expected_duration, + "started_at": initial_time_iso, + }, + "attention": bot_activity_attention, + "holding": _parse_holding(bot_activity_holding), + "status": {}, + }, + ) + + # 5. scene_opened + append_event( + conn, + kind="scene_opened", + payload={ + "chat_id": chat_id, + "container_id": container_id, + "started_at": initial_time_iso, + "participants": ["you", bot_id], + }, + ) + + # 6. edge_update (seed). The seed summary is preserved as the first + # knowledge fact prefixed with ``[summary] `` — proper summary writes happen + # at scene-close (T27). + facts = _parse_facts(edge_seed_knowledge_facts) + if edge_seed_summary.strip(): + facts.insert(0, f"[summary] {edge_seed_summary.strip()}") + append_event( + conn, + kind="edge_update", + payload={ + "source_id": bot_id, + "target_id": "you", + "chat_id": chat_id, + "knowledge_facts": facts, + }, + ) + + # Project all events at once. ``bot_authored`` (already in log from prior + # POST) is idempotent (INSERT OR REPLACE); the new events project cleanly + # because they're being applied for the first time. + project(conn) + + return RedirectResponse(url=f"/chats/{chat_id}", status_code=303) diff --git a/tests/test_kickoff_confirm.py b/tests/test_kickoff_confirm.py new file mode 100644 index 0000000..ef308cb --- /dev/null +++ b/tests/test_kickoff_confirm.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from chat.app import app +from chat.eventlog.log import append_event +from chat.eventlog.projector import project +from chat.llm.mock import MockLLMClient + + +CANNED_PARSE = { + "container_name": "office", + "container_type": "workplace", + "container_properties": { + "public": True, + "moving": False, + "audible_range": "normal", + }, + "you_activity": { + "posture": "sitting", + "action_verb": "working late", + "action_interruptible": True, + "action_required_attention": "medium", + "action_expected_duration": "an hour", + "attention": "the screen", + "holding": [], + }, + "bot_activity": { + "posture": "sitting", + "action_verb": "writing email", + "action_interruptible": True, + "action_required_attention": "medium", + "action_expected_duration": "a few minutes", + "attention": "her keyboard", + "holding": [], + }, + "initial_time_iso": "2026-04-26T20:00:00+00:00", + "edge_seed_summary": "BotA is your coworker.", + "edge_seed_knowledge_facts": [ + "coworker", + "they sometimes stay late together", + ], +} + + +@pytest.fixture +def client(tmp_path, monkeypatch): + config_path = tmp_path / "config.toml" + config_path.write_text('featherless_api_key = "test"\n') + monkeypatch.setenv("CHAT_CONFIG_PATH", str(config_path)) + monkeypatch.setenv("CHAT_DB_PATH", str(tmp_path / "test.db")) + + # Import after env is set so dependency lookup uses MockLLMClient. + from chat.web.kickoff import get_llm_client + + mock = MockLLMClient(canned=[json.dumps(CANNED_PARSE)]) + app.dependency_overrides[get_llm_client] = lambda: mock + + with TestClient(app) as c: + c.mock_llm = mock # type: ignore[attr-defined] + yield c + + app.dependency_overrides.clear() + + +def _author_bot(db_path: Path, bot_id: str = "bot_a") -> None: + from chat.db.connection import open_db + + with open_db(db_path) as conn: + append_event( + conn, + kind="bot_authored", + payload={ + "id": bot_id, + "name": "BotA", + "persona": "thoughtful, observant", + "voice_samples": [], + "traits": ["shy"], + "backstory": "", + "initial_relationship_to_you": "coworker", + "kickoff_prose": "you stay late at the office; she's there too", + }, + ) + project(conn) + + +def test_get_kickoff_404_when_bot_missing(client): + response = client.get("/bots/no_such_bot/kickoff") + assert response.status_code == 404 + + +def test_get_kickoff_renders_parsed_form(client, tmp_path): + _author_bot(tmp_path / "test.db", "bot_a") + response = client.get("/bots/bot_a/kickoff") + assert response.status_code == 200 + body = response.text + assert "office" in body + assert "sitting" in body + assert "working late" in body + # Mock was consumed once. + assert len(client.mock_llm._canned) == 0 + + +def test_post_kickoff_creates_chat_and_redirects(client, tmp_path): + _author_bot(tmp_path / "test.db", "bot_a") + + form_data = { + "container_name": "office", + "container_type": "workplace", + "container_properties": json.dumps(CANNED_PARSE["container_properties"]), + "initial_time_iso": "2026-04-26T20:00:00+00:00", + "you_activity_posture": "sitting", + "you_activity_action_verb": "working late", + "you_activity_action_interruptible": "on", + "you_activity_action_required_attention": "medium", + "you_activity_action_expected_duration": "an hour", + "you_activity_attention": "the screen", + "you_activity_holding": "", + "bot_activity_posture": "sitting", + "bot_activity_action_verb": "writing email", + "bot_activity_action_interruptible": "on", + "bot_activity_action_required_attention": "medium", + "bot_activity_action_expected_duration": "a few minutes", + "bot_activity_attention": "her keyboard", + "bot_activity_holding": "", + "edge_seed_summary": "BotA is your coworker.", + "edge_seed_knowledge_facts": "coworker\nthey sometimes stay late together", + } + response = client.post( + "/bots/bot_a/kickoff", + data=form_data, + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers["location"] == "/chats/chat_bot_a" + + from chat.db.connection import open_db + from chat.state.world import ( + active_scene, + find_container, + get_activity, + get_chat, + ) + from chat.state.edges import get_edge + + with open_db(tmp_path / "test.db") as conn: + chat = get_chat(conn, "chat_bot_a") + assert chat is not None + assert chat["host_bot_id"] == "bot_a" + assert chat["time"] == "2026-04-26T20:00:00+00:00" + + container = find_container(conn, "chat_bot_a", "office") + assert container is not None + assert container["type"] == "workplace" + + you_act = get_activity(conn, "you") + assert you_act is not None + assert you_act["posture"] == "sitting" + assert you_act["action"]["verb"] == "working late" + + bot_act = get_activity(conn, "bot_a") + assert bot_act is not None + assert bot_act["posture"] == "sitting" + assert bot_act["action"]["verb"] == "writing email" + + scene = active_scene(conn, "chat_bot_a") + assert scene is not None + assert scene["ended_at"] is None + assert "you" in scene["participants"] + assert "bot_a" in scene["participants"] + + edge = get_edge(conn, "bot_a", "you") + assert edge is not None + knowledge = edge["knowledge"] + assert "coworker" in knowledge + assert "they sometimes stay late together" in knowledge + # The seed summary should appear somewhere in knowledge as a v1 compromise. + assert any("BotA is your coworker" in k for k in knowledge) + + +def test_post_kickoff_404_when_bot_missing(client): + response = client.post( + "/bots/no_such/kickoff", + data={ + "container_name": "office", + "container_type": "workplace", + "container_properties": "{}", + "initial_time_iso": "2026-04-26T20:00:00+00:00", + "you_activity_posture": "", + "you_activity_action_verb": "", + "you_activity_action_interruptible": "on", + "you_activity_action_required_attention": "low", + "you_activity_action_expected_duration": "", + "you_activity_attention": "", + "you_activity_holding": "", + "bot_activity_posture": "", + "bot_activity_action_verb": "", + "bot_activity_action_interruptible": "on", + "bot_activity_action_required_attention": "low", + "bot_activity_action_expected_duration": "", + "bot_activity_attention": "", + "bot_activity_holding": "", + "edge_seed_summary": "", + "edge_seed_knowledge_facts": "", + }, + follow_redirects=False, + ) + assert response.status_code == 404 -- 2.52.0 From 0c08745194776c46b90082923c13f81754e4c2a7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 12:36:20 -0400 Subject: [PATCH 16/45] feat: top-level nav and chat list view --- chat/app.py | 2 + chat/static/app.css | 37 ++++++++++-- chat/templates/base.html | 7 +-- chat/templates/bot_form.html | 2 +- chat/templates/bot_list.html | 2 +- chat/templates/chat_list.html | 26 +++++++++ chat/templates/kickoff_confirm.html | 2 +- chat/templates/layout.html | 14 +++++ chat/templates/settings.html | 2 +- chat/web/bots.py | 8 ++- chat/web/kickoff.py | 4 +- chat/web/nav.py | 34 +++++++++++ chat/web/settings.py | 8 ++- tests/test_chat_list.py | 89 +++++++++++++++++++++++++++++ 14 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 chat/templates/chat_list.html create mode 100644 chat/templates/layout.html create mode 100644 chat/web/nav.py create mode 100644 tests/test_chat_list.py diff --git a/chat/app.py b/chat/app.py index 4251290..a0924d4 100644 --- a/chat/app.py +++ b/chat/app.py @@ -16,6 +16,7 @@ import chat.state.world # noqa: F401 from chat.web.bots import router as bots_router from chat.web.kickoff import router as kickoff_router +from chat.web.nav import router as nav_router from chat.web.settings import router as settings_router @@ -36,6 +37,7 @@ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.include_router(bots_router) app.include_router(kickoff_router) app.include_router(settings_router) +app.include_router(nav_router) @app.get("/health") diff --git a/chat/static/app.css b/chat/static/app.css index 9c261a8..4645d19 100644 --- a/chat/static/app.css +++ b/chat/static/app.css @@ -4,11 +4,33 @@ body { margin: 0; color: #1c1c1c; background: #fafafa; + display: flex; + min-height: 100vh; } -.topbar { - padding: 12px 24px; - border-bottom: 1px solid #e5e5e5; - background: #fff; +.rail { + width: 200px; + background: #1c1c1c; + color: #fff; + padding: 16px; + flex-shrink: 0; +} +.rail a { color: #fff; text-decoration: none; } +.rail-brand { + font-weight: 600; + display: block; + padding-bottom: 16px; + border-bottom: 1px solid #333; + margin-bottom: 16px; +} +.rail ul { list-style: none; padding: 0; margin: 0; } +.rail li { margin: 4px 0; } +.rail li a { display: block; padding: 6px 8px; border-radius: 3px; } +.rail li a.active { background: #333; } +.content { + flex: 1; + padding: 24px; + background: #fafafa; + overflow: auto; } .brand { font-weight: 600; text-decoration: none; color: inherit; } .container { max-width: 720px; margin: 24px auto; padding: 0 16px; } @@ -29,6 +51,13 @@ h1 { margin-top: 0; } .bot-form small { display: block; color: #666; margin-top: 2px; } .bot-list { list-style: none; padding: 0; } .bot-list li { padding: 8px 0; border-bottom: 1px solid #eee; } +.chat-list { list-style: none; padding: 0; margin: 0; } +.chat-row { border-bottom: 1px solid #eee; } +.chat-row a { display: block; padding: 12px 0; text-decoration: none; color: inherit; } +.chat-row a:hover { background: #f0f0f0; } +.chat-row-name { font-weight: 600; } +.chat-row-snippet { font-size: 14px; } +.chat-row-meta { font-size: 12px; } .muted { color: #666; } .error { padding: 8px 12px; border: 1px solid #c33; background: #fdecea; diff --git a/chat/templates/base.html b/chat/templates/base.html index 6c86104..01d0abd 100644 --- a/chat/templates/base.html +++ b/chat/templates/base.html @@ -8,11 +8,6 @@ -
- chat -
-
- {% block content %}{% endblock %} -
+ {% block body %}{% endblock %} diff --git a/chat/templates/bot_form.html b/chat/templates/bot_form.html index e0d3331..6d44080 100644 --- a/chat/templates/bot_form.html +++ b/chat/templates/bot_form.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "layout.html" %} {% block title %}New bot - chat{% endblock %} {% block content %}

New bot

diff --git a/chat/templates/bot_list.html b/chat/templates/bot_list.html index f3b4562..7a2a65b 100644 --- a/chat/templates/bot_list.html +++ b/chat/templates/bot_list.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "layout.html" %} {% block title %}Bots - chat{% endblock %} {% block content %}