"""Structured-fact storage for "living" memory mode.
In living mode the engine distils each turn's narrative into atomic **facts**
(a who/what/when/where/why model adapted from Hindsight) and stores them here,
tagged with the ``turn_id`` that produced them. The turn tag makes rollback
trivial — rewinding to turn N simply drops every fact from a later turn — and
keeps the facts in lockstep with the event log they were derived from.
This module is the *deterministic* storage layer: no LLM, no network. The LLM
extraction that produces the facts lives in ``axiom.factextract``; the background
job that calls it lives in the app's worker layer. Facts live in the same SQLite
database as ``Event_Log`` / ``State_Cache`` (keyed by ``save_id`` + ``turn_id``).
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from axiom.schema import ensure_facts_table, get_connection
# Recognised fact categories (free text is tolerated but these are the intent):
# world — a fact about the world/NPCs ("the bridge at Voss collapsed")
# experience — something the player lived through ("the player swore an oath")
# assistant — something narrated/said by the game ("the narrator hinted at...")
FACT_TYPES: tuple[str, ...] = ("world", "experience", "assistant")
[docs]
@dataclass
class Fact:
"""One atomic fact extracted from the narrative.
``statement`` is the canonical one-sentence form used for recall/embedding;
the who/what/when/where/why fields are the structured decomposition.
"""
statement: str
fact_type: str = "world"
who: str = ""
what: str = ""
when: str = ""
where: str = ""
why: str = ""
entities: list[str] = field(default_factory=list)
turn_id: int | None = None
fact_id: int | None = None
def __post_init__(self) -> None:
if self.fact_type not in FACT_TYPES:
self.fact_type = "world"
def _row_to_fact(row) -> Fact:
try:
entities = json.loads(row["entities"]) if row["entities"] else []
if not isinstance(entities, list):
entities = []
except (ValueError, TypeError):
entities = []
return Fact(
statement=row["statement"],
fact_type=row["fact_type"],
who=row["who"],
what=row["what"],
when=row["fact_when"],
where=row["fact_where"],
why=row["why"],
entities=[str(e) for e in entities],
turn_id=row["turn_id"],
fact_id=row["fact_id"],
)
[docs]
def insert_facts(db_path: str, save_id: str, turn_id: int, facts: list[Fact]) -> list[int]:
"""Persist a turn's extracted facts. Returns the new ``fact_id`` values.
Empty statements are skipped (an extractor that found nothing is normal and
must not write blank rows). Idempotency is the caller's concern: re-extracting
a turn should first ``rollback_facts`` to that turn.
Side effect: each ``Fact`` that is actually inserted has its ``fact_id`` and
``turn_id`` set in place, so the caller can use the objects directly without
re-aligning a separate id list (skipped/blank facts keep ``fact_id=None``).
"""
if not facts:
return []
new_ids: list[int] = []
with get_connection(db_path) as conn:
ensure_facts_table(conn)
for f in facts:
statement = (f.statement or "").strip()
if not statement:
continue
cur = conn.execute(
"""
INSERT INTO Facts
(save_id, turn_id, fact_type, who, what, fact_when,
fact_where, why, entities, statement)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""",
(
save_id,
turn_id,
f.fact_type if f.fact_type in FACT_TYPES else "world",
f.who or "",
f.what or "",
f.when or "",
f.where or "",
f.why or "",
json.dumps(list(f.entities or []), ensure_ascii=False),
statement,
),
)
f.fact_id = int(cur.lastrowid)
f.turn_id = turn_id
new_ids.append(f.fact_id)
conn.commit()
return new_ids
[docs]
def get_facts(
db_path: str,
save_id: str,
*,
max_turn_id: int | None = None,
entity: str | None = None,
limit: int | None = None,
) -> list[Fact]:
"""Fetch a save's facts, most recent first.
Args:
max_turn_id: Only facts from turns ``<= max_turn_id`` (honours the
history window / rewind horizon). ``None`` = no upper bound.
entity: Keep only facts whose ``entities`` list contains this name
(case-insensitive exact match). ``None`` = no filter.
limit: Cap the number of rows returned.
"""
sql = "SELECT * FROM Facts WHERE save_id = ?"
params: list[object] = [save_id]
if max_turn_id is not None:
sql += " AND turn_id <= ?"
params.append(max_turn_id)
sql += " ORDER BY turn_id DESC, fact_id DESC"
# Push the cap into SQL when there is no post-filter, so we don't materialise
# the whole table just to slice it. With an entity filter the cap must stay in
# Python (rows are dropped *after* the JSON entities match).
if limit is not None and entity is None:
sql += " LIMIT ?"
params.append(int(limit))
with get_connection(db_path) as conn:
ensure_facts_table(conn)
rows = conn.execute(sql, params).fetchall()
facts = [_row_to_fact(r) for r in rows]
if entity is not None:
needle = entity.strip().lower()
facts = [f for f in facts if any(e.lower() == needle for e in f.entities)]
if limit is not None:
facts = facts[:limit]
return facts
[docs]
def count_facts(db_path: str, save_id: str) -> int:
"""Number of stored facts for a save (cheap COUNT)."""
with get_connection(db_path) as conn:
ensure_facts_table(conn)
row = conn.execute(
"SELECT COUNT(*) FROM Facts WHERE save_id = ?;", (save_id,)
).fetchone()
return int(row[0])
[docs]
def rollback_facts(db_path: str, save_id: str, target_turn_id: int) -> int:
"""Delete a save's facts from turns after ``target_turn_id``. Returns the count.
Standalone helper (opens its own connection). The in-session rewind path
deletes facts inside ``CheckpointManager.rewind``'s own transaction instead,
so events and facts roll back atomically.
"""
with get_connection(db_path) as conn:
ensure_facts_table(conn)
row = conn.execute(
"SELECT COUNT(*) FROM Facts WHERE save_id = ? AND turn_id > ?;",
(save_id, target_turn_id),
).fetchone()
deleted = int(row[0])
conn.execute(
"DELETE FROM Facts WHERE save_id = ? AND turn_id > ?;",
(save_id, target_turn_id),
)
conn.commit()
return deleted