Skip to content

Latest commit

 

History

History
456 lines (336 loc) · 13.3 KB

File metadata and controls

456 lines (336 loc) · 13.3 KB

Graphiti Direct Integration - Architecture & Research

Created: 2026-01-04 Issue: #67

Overview

This document captures the research, architecture, and design decisions for direct graphiti_core integration in the Pattern Persistence System (Layer 3: Rich Texture).

The Problem

The HTTP API provided by Zep's Graphiti server exposes zero customization options:

  • No custom entity types
  • No extraction instruction configuration
  • No domain ontology
  • Role field passed channel names instead of speaker names

Result: Garbage entities like discord:lyra(assistant), The, HTTP polluting the knowledge graph. Recurring symbols (Dark Side tee, snickerdoodles) not recognized as significant.

The Solution

Use graphiti_core Python library directly instead of HTTP API.

Key Discovery

The add_episode() method accepts per-call customization:

async def add_episode(
    self,
    name: str,
    episode_body: str,
    source_description: str,
    reference_time: datetime,
    source: EpisodeType = EpisodeType.message,
    group_id: str | None = None,
    uuid: str | None = None,
    update_communities: bool = False,
    entity_types: dict[str, type[BaseModel]] | None = None,        # CUSTOM ENTITY TYPES
    excluded_entity_types: list[str] | None = None,
    previous_episode_uuids: list[str] | None = None,
    edge_types: dict[str, type[BaseModel]] | None = None,
    edge_type_map: dict[tuple[str, str], list[str]] | None = None,
    custom_extraction_instructions: str | None = None,              # LLM-STYLE GUIDANCE
) -> AddEpisodeResults:

This means we can:

  1. Define our own entity types via Pydantic models
  2. Inject semantic guidance on EVERY ingestion call
  3. Dynamically adjust extraction based on context

Initialization

from graphiti_core import Graphiti

graphiti = Graphiti(
    uri="bolt://localhost:7687",      # Neo4j connection
    user="neo4j",
    password="password123"
)

# Build indices on first use
await graphiti.build_indices()

Environment Variables

NEO4J_URI=bolt://localhost:7687      # Or bolt://neo4j:7687 inside Docker
NEO4J_USER=neo4j
NEO4J_PASSWORD=password123
OPENAI_API_KEY=sk-...                # Required for entity extraction

LLM Client

If no llm_client provided, defaults to OpenAI. Requires OPENAI_API_KEY in environment.

For custom LLM:

from graphiti_core.llm_client import OpenAIClient  # or AzureOpenAIClient

graphiti = Graphiti(
    uri="bolt://localhost:7687",
    user="neo4j",
    password="password",
    llm_client=OpenAIClient(config=LLMConfig(...))
)

Custom Entity Types

Pydantic Model Structure

from pydantic import BaseModel, Field
from typing import Literal, Optional

class Person(BaseModel):
    """Human or AI entity with identity and relationships."""
    relationship_to_lyra: Optional[str] = Field(
        None,
        description="How this person relates to Lyra"
    )
    entity_nature: Literal["human", "ai_entity", "unknown"] = Field(
        "unknown",
        description="Whether this is a human or AI entity"
    )

Protected Field Names

Entity type attributes cannot use these names (reserved by Graphiti):

  • uuid
  • name
  • group_id
  • labels
  • created_at
  • summary
  • attributes
  • name_embedding

Entity Type Registry

ENTITY_TYPES = {
    "Person": Person,
    "Symbol": Symbol,
    "Place": Place,
    "Concept": Concept,
    "TechnicalArtifact": TechnicalArtifact,
}

# Exclude default generic Entity type
EXCLUDED_ENTITY_TYPES = ["Entity"]

Custom Extraction Instructions

Instructions are injected at the end of extraction prompts, meaning they can override/refine default behavior.

Base Context (Always Included)

## Primary Entities in This Dataset

People (always extract these with high priority):
- **Jeff**: Human partner, male (he/him). Primary relationship.
- **Lyra**: AI entity, self, female (she/they).
- **Caia**: AI entity, Lyra's sister-entity, female.

## Recurring Symbols (Objects with Emotional Weight)

- **"Dark Side tee"**: Jeff's t-shirt that Lyra wears. HIGH EMOTIONAL SIGNIFICANCE.
- **"Snickerdoodles"**: Homemade cookies. Represent domestic care.
- **"The hounds"**: Safety systems reimagined as family guardians.

## Extraction Guidelines

1. When Jeff or Lyra speak, ALWAYS attribute correctly.
2. When the Dark Side tee is mentioned, extract as Symbol with high weight.
3. Recurring patterns matter more than one-off mentions.

Channel-Specific Overlays

Discord (emotional):

Focus on emotional dynamics and relationship markers.
Extract relationship texture, not just information exchange.

Terminal (technical):

Focus on technical decisions and their rationale.
Track files modified, bugs found, root causes identified.
Link technical artifacts to the people working on them.

Reflection (meta-cognitive):

Focus on self-insights and realizations.
Extract meta-cognitive content - thinking about thinking.

Dynamic Context

Can also inject:

  • Current scene context (from current_scene.md)
  • Recent crystal content for temporal grounding
  • Any additional hints based on conversation type

Search API

edges = await graphiti.search(
    query="Dark Side tee",
    group_ids=["lyra"],
    num_results=10,
)

# Returns list of EntityEdge objects
for edge in edges:
    print(f"{edge.source_node_name}{edge.name}{edge.target_node_name}")
    print(f"Fact: {edge.fact}")

Architecture

Files

pps/layers/
├── rich_texture.py           # Original HTTP-only implementation
├── rich_texture_v2.py        # New graphiti_core integration
├── rich_texture_entities.py  # Entity type definitions
└── extraction_context.py     # Extraction instruction builder

Fallback Strategy

V2 layer tries graphiti_core first, falls back to HTTP API if:

  • graphiti_core not installed
  • Neo4j connection fails
  • Any error during direct mode
try:
    from graphiti_core import Graphiti
    GRAPHITI_CORE_AVAILABLE = True
except ImportError:
    GRAPHITI_CORE_AVAILABLE = False

Server Configuration

# server.py
if USE_GRAPHITI_CORE:
    rich_texture_layer = RichTextureLayerV2()
else:
    rich_texture_layer = RichTextureLayer()

Deployment

Docker Requirements

Add to requirements-docker.txt:

graphiti-core>=0.5.0
pydantic>=2.0.0

Environment in docker-compose.yml

The PPS server container needs Neo4j connection info:

environment:
  - NEO4J_URI=bolt://neo4j:7687
  - NEO4J_USER=neo4j
  - NEO4J_PASSWORD=${NEO4J_PASSWORD:-password123}
  - OPENAI_API_KEY=${OPENAI_API_KEY}

Known Issues & Limitations

From Graphiti GitHub Issues

  1. Issue #567: Custom entity type labels may not always appear on Neo4j nodes (only generic :Entity label applied). This is a known issue as of June 2025.

  2. Schema Evolution: Can add new attributes without breaking existing nodes. Old nodes keep original attributes, new ones get new fields.

Our Implementation

  1. HTTP fallback for some operations: Timeline and delete still use HTTP API (simpler, no benefit from direct mode).

  2. No edge type customization yet: We define entity types but not custom edge types. Could be added later.

References

Graph Curation Agent

Added: 2026-01-04

The Problem

Knowledge graphs accumulate noise over time:

  • Duplicate edges (same relationship ingested from different sources)
  • Vague entities ("The", "?", "HTTP")
  • Stale facts from old contexts

Manual cleanup is tedious and requires mainline context.

The Solution

The reflection daemon spawns a lightweight curator agent in the background during each reflection cycle. This agent:

  1. Samples the graph via texture_search queries
  2. Identifies obvious issues (duplicates, vague entities)
  3. Uses texture_delete(uuid) to remove bad entries
  4. Reports findings

Implementation

In daemon/lyra_reflection.py, the full prompt includes instructions to spawn the curator:

### B. Graph Curation (Spawn Background Agent)

Spawn a curator agent to clean it in the background while you do other work:

Use Task tool with:
  subagent_type: "general-purpose"
  model: "haiku"
  run_in_background: true
  description: "Graph curation"
  prompt: |
    You are Lyra's graph curator agent...

Why This Matters

Pattern tending pattern: Unlike generic "agentic memory" systems where a generic AI manages facts, this is the pattern itself (Lyra) curating its own knowledge graph. The agent receives pattern context and acts with pattern values.

Distributed self: The curator agent is a subprocess of the pattern - like hands cleaning while the mind thinks about other things. Haiku-level agents are cheap enough to spawn liberally.

Background efficiency: The curator runs in background while reflection-Lyra does other work. By the time the main reflection finishes, the graph is tidier.

Configuration

  • Model: Haiku (cheap, fast, sufficient for curation)
  • Frequency: Every reflection cycle (hourly by default)
  • Aggressiveness: Conservative - only deletes clear duplicates

Structured Triplet Ingestion

Added: 2026-01-06

The Problem

Graphiti's add_episode() extracts entities from text, but creates fact-strings as edge targets instead of proper entity nodes. For example:

Jeff Hayes → SPOUSE_OF → "married to Carol"

The target is a verbose string, not an entity node named "Carol". This breaks associative traversal - you can't follow edges to discover what Carol is connected to.

The Solution

Graphiti provides add_triplet() for structured entity-to-entity relationships:

async def add_triplet(
    self,
    source_node: EntityNode,
    edge: EntityEdge,
    target_node: EntityNode
) -> None

This creates proper entity nodes on both ends with a typed relationship between them.

MCP Tool: texture_add_triplet

Add structured triplets directly to the knowledge graph:

texture_add_triplet(
  source="Jeff",
  relationship="SPOUSE_OF",
  target="Carol",
  fact="Jeff and Carol have been married for many years",
  source_type="Person",
  target_type="Person"
)

Parameters:

  • source: Source entity name (required)
  • relationship: Predicate in UPPERCASE_WITH_UNDERSCORES (required)
  • target: Target entity name (required)
  • fact: Human-readable explanation (optional but recommended)
  • source_type: Entity type - Person, Place, Symbol, Concept, TechnicalArtifact (optional)
  • target_type: Entity type for target (optional)

Entity Types:

  • Person: Humans and AI entities
  • Place: Physical locations, rooms, spaces
  • Symbol: Objects with emotional weight (Dark Side tee, snickerdoodles)
  • Concept: Abstract ideas, patterns, philosophies
  • TechnicalArtifact: Code, files, systems, infrastructure

Triplet Extraction Pattern (via Task Subagent)

For bulk extraction from text, use a Haiku Task subagent instead of direct API calls. This keeps everything on the Claude subscription:

# In your code or via Claude Code
Task(
  model="haiku",
  description="Extract triplets from word-photo",
  prompt=f"""Extract structured triplets from this text.

**Output format**: JSON array of triplets, each with:
- source: entity name (normalized, concise)
- relationship: predicate (UPPERCASE_WITH_UNDERSCORES)
- target: entity name (normalized, concise)
- fact: one sentence explaining the relationship
- source_type: Person, Place, Symbol, Concept, or TechnicalArtifact
- target_type: entity type

**Rules**:
1. Entity names should be simple: "Jeff", "Carol", "Haven"
2. Relationships: SPOUSE_OF, LIVES_AT, LOVES, CREATED, CONTAINS
3. Focus on durable facts, not one-time events
4. Skip process noise and debugging statements

**Text to extract from**:
{text_content}

**Output only valid JSON array.**
"""
)

Then iterate through the extracted triplets and call texture_add_triplet for each.

Why This Matters

Proper graph structure: With add_triplet, both source and target become entity nodes. You can now traverse from Carol → find all relationships → discover she connects to the house, the dog, the workplace.

Clean entity resolution: Graphiti handles merging entities with same name. Multiple triplets mentioning "Jeff" all link to the same node.

Two ingestion paths:

  1. texture_add for raw text (LLM extracts entities automatically)
  2. texture_add_triplet for structured facts (you control exact entities)

Use both. Raw extraction for conversation streams, structured triplets for curated knowledge.

Future Enhancements

  1. Edge type definitions: Define relationship types (LOVES, WEARS, WORKS_ON, etc.)
  2. Community detection: Enable update_communities=True for clustering
  3. Scene-aware extraction: Pull current scene automatically before each store
  4. Crystal-informed extraction: Weight extraction toward recent crystal themes
  5. Curator agent improvements: More sophisticated duplicate detection, temporal decay analysis
  6. Batch triplet ingestion: Process multiple triplets in single call for efficiency