| """SQLite state store for Petriarium. |
| |
| The store is intentionally small: the hackathon app needs persistence, |
| receipts, and deterministic state, not a full agent memory framework. |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import random |
| import sqlite3 |
| import time |
| from dataclasses import asdict, dataclass |
| from pathlib import Path |
| from typing import Any |
|
|
|
|
| DEFAULT_DB = Path.home() / ".petriarium" / "petriarium.db" |
|
|
| TRAITS = ( |
| "curiosity", |
| "mischief", |
| "tenderness", |
| "orderliness", |
| "appetite", |
| "weirdness", |
| "social_bond", |
| ) |
|
|
| MOODS = ("bright", "shy", "buzzing", "stormy", "sleepy", "entranced") |
|
|
|
|
| @dataclass |
| class CreatureState: |
| seed: int |
| name: str |
| form: str |
| mood: str |
| traits: dict[str, float] |
| color_seed: str |
| artifact_count: int = 0 |
| entry_count: int = 0 |
|
|
|
|
| @dataclass |
| class MomentResult: |
| entry_id: int |
| artifact_id: int |
| memory_id: int |
| response_text: str |
| artifact: dict[str, Any] |
| state: CreatureState |
|
|
|
|
| def _clamp(value: float, low: float = 0.0, high: float = 1.0) -> float: |
| return max(low, min(high, value)) |
|
|
|
|
| def make_genome(seed: int | None = None) -> CreatureState: |
| seed = int(seed if seed is not None else time.time() * 1000) % 2_147_483_647 |
| rng = random.Random(seed) |
| stems = ["Moss", "Blink", "Lint", "Orb", "Glyph", "Wisp", "Nook", "Flick"] |
| suffixes = ["let", "mote", "kin", "bud", "ling", "node", "sprout", "bit"] |
| traits = {trait: round(rng.uniform(0.18, 0.82), 3) for trait in TRAITS} |
| color_seed = f"#{rng.randrange(0x38516A, 0xE8D98A):06x}" |
| return CreatureState( |
| seed=seed, |
| name=rng.choice(stems) + rng.choice(suffixes), |
| form="seedling", |
| mood=rng.choice(MOODS), |
| traits=traits, |
| color_seed=color_seed, |
| ) |
|
|
|
|
| def keyword_tags(text: str) -> list[str]: |
| words = [ |
| "".join(ch for ch in raw.lower() if ch.isalnum() or ch in "-_") |
| for raw in text.split() |
| ] |
| stop = { |
| "the", |
| "and", |
| "but", |
| "for", |
| "with", |
| "that", |
| "this", |
| "about", |
| "from", |
| "have", |
| "just", |
| "really", |
| "today", |
| "tonight", |
| "feel", |
| "feeling", |
| } |
| tags: list[str] = [] |
| for word in words: |
| if len(word) < 4 or word in stop or word in tags: |
| continue |
| tags.append(word) |
| if len(tags) == 5: |
| break |
| return tags or ["moment"] |
|
|
|
|
| def mutate_traits(state: CreatureState, text: str, tags: list[str], mood: str) -> CreatureState: |
| traits = dict(state.traits) |
| lowered = text.lower() |
| mood = mood if mood in MOODS else state.mood |
|
|
| if "?" in text or any(t in lowered for t in ("wonder", "curious", "learn", "why")): |
| traits["curiosity"] += 0.07 |
| if any(t in lowered for t in ("joke", "weird", "silly", "chaos", "prank")): |
| traits["mischief"] += 0.07 |
| if any(t in lowered for t in ("sad", "nervous", "tired", "lonely", "afraid")): |
| traits["tenderness"] += 0.08 |
| traits["social_bond"] += 0.04 |
| if any(t in lowered for t in ("deadline", "folder", "plan", "list", "schedule")): |
| traits["orderliness"] += 0.08 |
| if len(text) > 140: |
| traits["appetite"] += 0.05 |
| if any(t in lowered for t in ("impossible", "signal", "dream", "strange", "alien")): |
| traits["weirdness"] += 0.08 |
|
|
| traits["social_bond"] += 0.025 |
| traits["appetite"] += min(0.04, len(tags) * 0.006) |
| traits = {k: round(_clamp(v), 3) for k, v in traits.items()} |
|
|
| form = state.form |
| next_count = state.entry_count + 1 |
| if next_count >= 4 and traits.get("social_bond", 0) > 0.38: |
| form = "specimen" |
| elif next_count >= 2: |
| form = "sproutling" |
|
|
| return CreatureState( |
| seed=state.seed, |
| name=state.name, |
| form=form, |
| mood=mood, |
| traits=traits, |
| color_seed=state.color_seed, |
| artifact_count=state.artifact_count, |
| entry_count=state.entry_count, |
| ) |
|
|
|
|
| class CreatureStore: |
| def __init__(self, db_path: Path | str = DEFAULT_DB): |
| self.db_path = Path(db_path) |
| self.db_path.parent.mkdir(parents=True, exist_ok=True) |
| self.conn = sqlite3.connect(self.db_path, check_same_thread=False) |
| self.conn.row_factory = sqlite3.Row |
| self._init_schema() |
| self.ensure_state() |
|
|
| def _init_schema(self) -> None: |
| self.conn.executescript( |
| """ |
| CREATE TABLE IF NOT EXISTS creature_state ( |
| id INTEGER PRIMARY KEY CHECK (id = 1), |
| state_json TEXT NOT NULL |
| ); |
| CREATE TABLE IF NOT EXISTS entries ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| ts REAL NOT NULL, |
| text TEXT NOT NULL, |
| tags_json TEXT NOT NULL DEFAULT '[]' |
| ); |
| CREATE TABLE IF NOT EXISTS memories ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| entry_id INTEGER NOT NULL, |
| summary TEXT NOT NULL, |
| tags_json TEXT NOT NULL DEFAULT '[]', |
| created_at REAL NOT NULL |
| ); |
| CREATE TABLE IF NOT EXISTS artifacts ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| entry_id INTEGER NOT NULL, |
| memory_id INTEGER NOT NULL, |
| title TEXT NOT NULL, |
| kind TEXT NOT NULL, |
| content_json TEXT NOT NULL, |
| created_at REAL NOT NULL |
| ); |
| CREATE TABLE IF NOT EXISTS events ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| ts REAL NOT NULL, |
| kind TEXT NOT NULL, |
| payload_json TEXT NOT NULL |
| ); |
| """ |
| ) |
| self.conn.commit() |
|
|
| def ensure_state(self) -> CreatureState: |
| row = self.conn.execute("SELECT state_json FROM creature_state WHERE id=1").fetchone() |
| if row: |
| return self._state_from_json(row["state_json"]) |
| state = make_genome() |
| self.save_state(state) |
| return state |
|
|
| def state(self) -> CreatureState: |
| return self.ensure_state() |
|
|
| def save_state(self, state: CreatureState) -> None: |
| self.conn.execute( |
| "INSERT OR REPLACE INTO creature_state (id, state_json) VALUES (1, ?)", |
| (json.dumps(asdict(state), sort_keys=True),), |
| ) |
| self.conn.commit() |
|
|
| def recent_entries(self, n: int = 3) -> list[dict[str, Any]]: |
| rows = self.conn.execute( |
| "SELECT * FROM entries ORDER BY id DESC LIMIT ?", (n,) |
| ).fetchall() |
| return [ |
| {"id": r["id"], "text": r["text"], "tags": json.loads(r["tags_json"])} |
| for r in reversed(rows) |
| ] |
|
|
| def recent_memories(self, n: int = 6) -> list[dict[str, Any]]: |
| rows = self.conn.execute( |
| "SELECT * FROM memories ORDER BY id DESC LIMIT ?", (n,) |
| ).fetchall() |
| return [ |
| { |
| "id": r["id"], |
| "entry_id": r["entry_id"], |
| "summary": r["summary"], |
| "tags": json.loads(r["tags_json"]), |
| } |
| for r in rows |
| ] |
|
|
| def artifacts(self, n: int = 12) -> list[dict[str, Any]]: |
| rows = self.conn.execute( |
| "SELECT * FROM artifacts ORDER BY id DESC LIMIT ?", (n,) |
| ).fetchall() |
| return [self._artifact_row(r) for r in rows] |
|
|
| def recent_events(self, n: int = 12) -> list[dict[str, Any]]: |
| rows = self.conn.execute( |
| "SELECT * FROM events ORDER BY id DESC LIMIT ?", (n,) |
| ).fetchall() |
| events: list[dict[str, Any]] = [] |
| for row in rows: |
| payload = json.loads(row["payload_json"]) |
| summary = ", ".join( |
| part |
| for part in ( |
| f"entry #{payload.get('entry_id')}" if payload.get("entry_id") else "", |
| f"memory #{payload.get('memory_id')}" if payload.get("memory_id") else "", |
| f"artifact #{payload.get('artifact_id')}" if payload.get("artifact_id") else "", |
| str(payload.get("mood", "")), |
| ) |
| if part |
| ) |
| events.append( |
| { |
| "id": row["id"], |
| "kind": row["kind"], |
| "when": time.strftime("%H:%M:%S", time.localtime(row["ts"])), |
| "summary": summary or "moment recorded", |
| "payload": payload, |
| } |
| ) |
| return events |
|
|
| def add_moment( |
| self, |
| text: str, |
| tags: list[str], |
| mood: str, |
| response_text: str, |
| memory_summary: str, |
| artifact: dict[str, Any], |
| ) -> MomentResult: |
| now = time.time() |
| state = mutate_traits(self.state(), text=text, tags=tags, mood=mood) |
| entry_cur = self.conn.execute( |
| "INSERT INTO entries (ts, text, tags_json) VALUES (?, ?, ?)", |
| (now, text, json.dumps(tags)), |
| ) |
| entry_id = int(entry_cur.lastrowid) |
| memory_cur = self.conn.execute( |
| "INSERT INTO memories (entry_id, summary, tags_json, created_at) VALUES (?, ?, ?, ?)", |
| (entry_id, memory_summary, json.dumps(tags), now), |
| ) |
| memory_id = int(memory_cur.lastrowid) |
| artifact_cur = self.conn.execute( |
| """ |
| INSERT INTO artifacts (entry_id, memory_id, title, kind, content_json, created_at) |
| VALUES (?, ?, ?, ?, ?, ?) |
| """, |
| ( |
| entry_id, |
| memory_id, |
| str(artifact.get("title", "Untitled relic")), |
| str(artifact.get("kind", "glyph")), |
| json.dumps(artifact, sort_keys=True), |
| now, |
| ), |
| ) |
| artifact_id = int(artifact_cur.lastrowid) |
| state.artifact_count += 1 |
| state.entry_count += 1 |
| self.save_state(state) |
| self.conn.execute( |
| "INSERT INTO events (ts, kind, payload_json) VALUES (?, ?, ?)", |
| ( |
| now, |
| "moment", |
| json.dumps( |
| { |
| "entry_id": entry_id, |
| "memory_id": memory_id, |
| "artifact_id": artifact_id, |
| "mood": mood, |
| }, |
| sort_keys=True, |
| ), |
| ), |
| ) |
| self.conn.commit() |
| return MomentResult( |
| entry_id=entry_id, |
| artifact_id=artifact_id, |
| memory_id=memory_id, |
| response_text=response_text, |
| artifact=artifact, |
| state=state, |
| ) |
|
|
| def reset(self, seed: int | None = None) -> CreatureState: |
| self.conn.executescript( |
| """ |
| DELETE FROM events; |
| DELETE FROM artifacts; |
| DELETE FROM memories; |
| DELETE FROM entries; |
| DELETE FROM creature_state; |
| """ |
| ) |
| state = make_genome(seed) |
| self.save_state(state) |
| self.conn.commit() |
| return state |
|
|
| def close(self) -> None: |
| self.conn.close() |
|
|
| @staticmethod |
| def _state_from_json(raw: str) -> CreatureState: |
| data = json.loads(raw) |
| return CreatureState( |
| seed=int(data["seed"]), |
| name=str(data["name"]), |
| form=str(data["form"]), |
| mood=str(data["mood"]), |
| traits={k: float(v) for k, v in dict(data["traits"]).items()}, |
| color_seed=str(data["color_seed"]), |
| artifact_count=int(data.get("artifact_count", 0)), |
| entry_count=int(data.get("entry_count", 0)), |
| ) |
|
|
| @staticmethod |
| def _artifact_row(row: sqlite3.Row) -> dict[str, Any]: |
| data = json.loads(row["content_json"]) |
| data.update( |
| { |
| "id": row["id"], |
| "entry_id": row["entry_id"], |
| "memory_id": row["memory_id"], |
| "title": row["title"], |
| "kind": row["kind"], |
| } |
| ) |
| return data |
|
|