OCULUS
OCULUS is the AI companion for an ongoing Skyrim roleplay campaign — an intelligence handler character with persistent memory across sessions. That last part required building a real extraction pipeline: every conversation gets classified, an LLM extracts structured entities and relationships, deterministic Python generates idempotent graph queries, and the result loads into Neo4j. When OCULUS responds, it queries the live graph for tactically relevant context and injects it into the prompt.
Chapter 1 of the campaign — 396 messages — produced 753 nodes and 1,044 relationships. The system is actively used.
Design
Objective: Build an AI companion that retains everything across session boundaries — not via a context window hack, but through a genuine queryable knowledge graph of the game world, extracted from the actual conversation history.
Design factors: The domain has a natural graph structure. An intelligence network is contacts, cover identities, locations, organizations, events, threads, signals. Forcing that into rows or flat documents loses the relationships that make the data useful. Any storage approach that couldn’t represent “Contact A knows Ezra as her cover identity, not as the operative behind it” natively wasn’t viable.
Source citations on every relationship were a hard requirement. When extraction gets something wrong — and it does — you need to trace the error back to the originating message and fix it. A graph without provenance isn’t auditable.
Two distinct problems had to be kept separate: building the graph from conversation history (extraction) and recalling relevant context on each turn (episodic memory). They have different requirements and the right tools for each are different. Conflating them produces bad architectural decisions.
Technology evaluations and selections:
Delta-Cypher over Graphiti for extraction — Graphiti was evaluated and rejected for the extraction pipeline. It flattens everything to a generic relationship type, which destroys the typed schema the domain requires. 4+ LLM calls per message vs 2 for Delta-Cypher. Designed for generic knowledge graphs, not typed domain schemas. Rejected on schema fidelity and token cost.
Graphiti for episodic memory — accepted for a different problem entirely. Semantic retrieval over conversation turns is exactly what Graphiti is designed for. The ADR that rejected Graphiti for extraction explicitly does not apply here.
Two Neo4j databases — ezra for the operational graph (extraction), ezra-episodes for Graphiti episodic memory. A deliberate architectural boundary, not a limitation. Mixing extraction output with episodic memory would couple two independent concerns.
MERGE-based idempotent Cypher — the extraction pipeline can be re-run safely. No duplicate nodes, no data corruption on retry. This matters because extraction quality improves over time; you want to be able to re-extract without starting over.
devstral-2:123b for extraction — code-adjacent structured output, handles complex Pydantic schemas reliably, runs via Ollama cloud.
flowchart TD
A([conversations.json]) --> B[Message Classifier\nnarrative / assessment / mechanical / meta]
B --> C[Entity Extractor\nPydantic-validated, iterative retry]
C --> D[Cypher Generator\ndeterministic Python → MERGE]
D --> G[(Neo4j Graph\npersistent operational memory)]
G -->|queries| R[Graph Retrieval\ncontacts, locations, threads, signals]
R --> API[FastAPI /chat\ncontext injection → streaming]
API --> UI[React Frontend\nchat + graph viz]
style G fill:#2d6a4f,color:#fff
style UI fill:#1d3557,color:#fff
style API fill:#457b9d,color:#fff
Implementation
The Delta-Cypher pipeline: classifier (narrative/operational/mechanical/meta, 1 LLM call) → entity extractor (iterative Pydantic validation, 1+ calls) → Cypher generator (deterministic Python, no LLM) → MERGE load (idempotent). The classifier is the critical gate. Meta and OOC messages excluded entirely — misclassification at this stage contaminates downstream.
Schema has 14 node types. All nodes carry a narrative__ property prefix: narrative__emotional_register, narrative__sensory_detail, narrative__source_citation alongside the operational data. Source citations on every relationship trace back to the originating message.
The cover identity modeling is where the schema gets interesting. (Operative)-[:MAINTAINS]->(CoverIdentity), (Contact)-[:KNOWS_AS {cover}]->(CoverIdentity). A contact who knows Ezra as “the courier” knows a cover identity, not the operative. The relationship carries the cover as a property. Reasoning about what any given contact knows requires querying through that layer.
Chapter 1: 396 messages, devstral-2:123b via Ollama, 753 nodes, 1,044 relationships.
Repo → — AGPL-3.0, release pending Phase 2