Skip to content

Repository files navigation

GraphStore

A temporally-aware knowledge graph for AI agent memory — a Scala 3 reimagining of Graphiti's core ideas (bi-temporal edges, episodic ingestion, hybrid retrieval, contradiction-driven invalidation) built on the Typelevel stack and wired into ADK4S as durable agent memory.

What it does

GraphStore turns text (conversations, documents, tool outputs) into a bi-temporal property graph that AI agents can query as long-term memory:

  • Episodic ingestion — text goes in, entities and relationships come out via structured LLM extraction. No hand-rolled prompts or JSON parsing — ADK4S structured-llm handles schema injection and lenient recovery.
  • Bi-temporal edges — every edge tracks valid time (when the fact was true in reality) and transaction time (when the system knew it). This enables point-in-time queries ("what did the graph look like on Tuesday?").
  • Contradiction-driven invalidation — when a new edge asserts an exclusive relationship (works_at, married_to, lives_in, …) that contradicts an existing one, the prior edge's validity is closed at the new fact's validFrom. No data is deleted — the history is preserved.
  • Hybrid retrieval — semantic search (embedding cosine similarity) and keyword search (Lucene BM25) are fused via Reciprocal Rank Fusion (RRF), giving both meaning-based and term-based recall in a single query.
  • Agent memory contractGraphStoreMemory implements ADK4S AgentMemory[F], so any ADK4S agent can use GraphStore as durable memory without knowing Neo4j or Lucene exists. The contract is verified by running ADK4S AgentMemoryLaws against the real implementation.
  • HTTP API — a Smithy4s/http4s REST API lets non-ADK4S consumers (Python agents, other services) ingest episodes and search the graph over HTTP.

Architecture

domain ─┬─ neo4j ─┐
        ├─ embedder(→ adk4s-core) ┤
        ├─ temporal ┤
        └─ search ─┴─ episode(→ adk4s-core, structured-llm, adk4s-memory-api) ── api
                                                                        │
                            config ───────────────────────────────────────┘
                            telemetry ───────────────────────────────────┘

Module map

Module Role Key types
domain Pure model + algebras (no infrastructure imports) Node, TemporalEdge, Episode, GraphStore[F], Embedder[F]
neo4j Neo4j-backed GraphStore[F] via neotypes + Cypher Neo4jGraphStore, Neo4jGraphStoreImpl, InMemoryGraphStore (test)
embedder OpenAI embeddings via http4s + caching OpenAIEmbedder, EmbeddingCache
search Hybrid retrieval: Lucene BM25 + cosine + RRF fusion HybridSearch, SearchResult, SearchFusionError
temporal Contradiction-driven bi-temporal invalidation TemporalInvalidation
episode Extraction pipeline + AgentMemory implementation EpisodeProcessor, Extractor, GraphStoreMemory
api Smithy4s HTTP service + Ember server GraphStoreServiceImpl, GraphStoreServer
config Ciris-based config from environment variables GraphStoreConfig, ConfigError
telemetry otel4s tracing + log4cats logging + metrics Telemetry, GraphStoreMeters
core Shared syntax helpers syntax

Key design decisions

  • No bespoke LLM client. Extraction delegates entirely to ADK4S structured-llm — we declare result types as Smithy schemas, hand them to StructuredLLM.complete[A], and receive parsed values. The Schema-Aligned Parser (SAP) handles malformed JSON, markdown fences, trailing commas, and other LLM output quirks.
  • Tagless-final storage seam. GraphStore[F[_]] is a domain algebra with two implementations: Neo4jGraphStoreImpl (production, neotypes + Cypher) and InMemoryGraphStore (test oracle, Ref-backed). The in-memory store lets unit tests run without Docker; a Testcontainers suite verifies the Neo4j implementation matches the in-memory oracle.
  • Deterministic node IDs. NodeId.from(entityType, name) produces a deterministic ID from the entity type and name hash, so re-ingesting the same entity collapses to one node — giving idempotent ingestion.
  • Smithy-first API. The HTTP API is defined in Smithy IDL; smithy4s generates the Scala service trait, input/output structs, and http4s wiring. We implement the trait and serve via Ember.

Tech stack

Concern Library Version
Language Scala 3.8.4
Build sbt 1.12.12
Effects Cats Effect 3.7.0
Streaming fs2 3.13.0
Graph DB Neo4j (via neotypes) 5.20.0 / neotypes 1.0.0
Search Apache Lucene 9.10.0
HTTP http4s (Ember) 0.23.28
IDL/Codegen smithy4s 0.18.55
Config Ciris 3.6.0
Telemetry otel4s + log4cats 0.7.0 / 2.7.0
Testing munit + Hedgehog 1.3.3 / 0.13.1
Integration Testcontainers (Neo4j) 0.41.0
Mutation testing Stryker4s 0.21.0
Static analysis WartRemover + Scalafix 3.5.8 / 0.14.7

ADK4S source dependency

GraphStore consumes ADK4S via sbt ProjectRef against a local source checkout. This gives live co-development — edits in ADK4S are visible immediately, no publishLocal needed. The ADK4S checkout must exist at the path configured as adk4sBase in build.sbt (defaults to /home/gruggiero/git/rs/adk4s).

Four ADK4S projects are referenced:

ADK4S project What GraphStore uses from it
adk4s-core Embedder (bridge type in embedder module)
structured-llm StructuredLLM, Prompt, Schema
adk4s-memory-api AgentMemory, Episode, EpisodeOutcome, MemoryHit, TemporalScope, SourceType
adk4s-memory-testkit AgentMemoryLaws (test scope)

Version alignment constraints:

  • sbt version must match ADK4S (1.12.12) — ProjectRef loads ADK4S's project/
  • Scala version must match ADK4S (3.8.4) — TASTy is backward-compatible only
  • smithy4s version must match ADK4S (0.18.55) — codegen output must match runtime jar

Getting started

Prerequisites

  • JDK 26 (or compatible — the project uses Java 26)
  • sbt 1.12.12
  • Docker (for Testcontainers-based Neo4j tests)
  • ADK4S source checkout at the path configured in build.sbt

Build

# Compile all 10 modules
sbt compile

# Compile main + test sources
sbt Test/compile

# Run all tests (282 tests across all modules, including Testcontainers Neo4j)
sbt test

# Format code
sbt scalafmtAll

# Lint (Scalafix + WartRemover)
sbt "scalafix --check"
sbt scalafmtCheckAll

Quickstart (docker compose + memory demo)

The fastest way to see GraphStore's bi-temporal memory in action is the end-to-end example at examples/ (module graphstore-examples), orchestrated by scripts/run-memory-demo.sh. It runs five separate JVMs — reset → teach → contradict → recall-work → recall-at $T1 — to demonstrate that a fact taught in one process, contradicted in a second, is recalled as the newer fact in a third while the superseded fact remains retrievable at an earlier point in time.

# 1. Start Neo4j
docker compose -f docker/docker-compose.yml up -d

# 2. Set required environment variables
export GRAPHSTORE_NEO4J_URI=bolt://localhost:7687
export GRAPHSTORE_NEO4J_USERNAME=neo4j
export GRAPHSTORE_NEO4J_PASSWORD=testpassword
export GRAPHSTORE_EMBEDDER_MODEL=text-embedding-3-small
export GRAPHSTORE_EMBEDDER_DIMENSION=1536
export GRAPHSTORE_EMBEDDER_API_KEY=sk-...
export OPENAI_API_KEY=sk-...

# 3. Run the demo
./scripts/run-memory-demo.sh

The script fails fast if any required environment variable is missing. Each mode prints observables that make the bi-temporal mechanism legible:

  • G1: recall-work's hits contain Beta, not Acme
  • G2: contradict's edgesInvalidated >= 1
  • G3: recall-at $T1's hits contain Acme (the superseded fact, still visible at the earlier instant)
  • G4: the injected context block is non-empty and contains Beta

The example obtains its AgentMemory from the same GraphStoreRuntime.resource composition root the HTTP service uses, so wiring drift is structurally impossible. See examples/src/main/scala/.../CrossRunGraphMemoryExample.scala for the mode dispatch and examples/src/test/... for the CI-safe smoke + property tests (which use stubbed extraction/embedding — no OpenAI, no secrets).

Configuration

GraphStore loads configuration from environment variables via Ciris:

Variable Required Description
GRAPHSTORE_NEO4J_URI Yes Neo4j connection URI (e.g. bolt://localhost:7687)
GRAPHSTORE_NEO4J_USERNAME Yes Neo4j username
GRAPHSTORE_NEO4J_PASSWORD Yes Neo4j password (loaded as Secret[String])
GRAPHSTORE_EMBEDDER_MODEL Yes OpenAI embedding model (e.g. text-embedding-3-small)
GRAPHSTORE_EMBEDDER_DIMENSION Yes Embedding dimension (e.g. 1536)
GRAPHSTORE_EMBEDDER_API_KEY Yes OpenAI API key (loaded as Secret[String])
GRAPHSTORE_SERVER_HOST No Server host (default: 0.0.0.0)
GRAPHSTORE_SERVER_PORT No Server port (default: 8080)
GRAPHSTORE_ENTITY_RESOLUTION No lexical or vector (default: lexical)

Running the HTTP service

The HTTP API is served by GraphStoreServer (http4s Ember). It exposes three operations defined in the Smithy IDL:

POST /v1/episodes     — Ingest text, extract entities/relationships, persist to graph
POST /v1/search       — Hybrid semantic+keyword search with RRF fusion
GET  /v1/state/{id}   — Point-in-time query: node + active edges as of timestamp

Example: Ingest an episode

curl -X POST http://localhost:8080/v1/episodes \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Alice works at Acme Corp. She previously worked at Beta Inc.",
    "sourceType": "CONVERSATION",
    "timestamp": "2026-07-17T10:00:00Z"
  }'

Response:

{
  "episodeId": "ep-...",
  "entitiesExtracted": 3,
  "relationshipsCreated": 2,
  "edgesInvalidated": 1,
  "processingTimeMs": 1250
}

Example: Search memory

curl -X POST http://localhost:8080/v1/search \
  -H "Content-Type: application/json" \
  -d '{
    "query": "Where does Alice work?",
    "k": 5
  }'

Example: Point-in-time query

curl "http://localhost:8080/v1/state/node-Person|Alice?at=2026-07-17T09:00:00Z"

Embedded usage (ADK4S agent)

GraphStore implements AgentMemory[F] from ADK4S. Wire it into an agent via MemoryAwareRunner, which wraps an AgentRunner with pre-turn recall (injects relevant memories into the prompt) and post-turn remember (persists the conversation as an episode). For a runnable end-to-end example, see the Quickstart (docker compose + memory demo) above and examples/src/main/scala/.../CrossRunGraphMemoryExample.scala.

import io.gruggiero.graphstore.memory.GraphStoreMemory
import org.adk4s.memory.AgentMemory
import org.adk4s.orchestration.agent.{ReactAgent, AgentRunner}
import org.adk4s.orchestration.memory.{MemoryAwareRunner, MemoryPolicy}
import org.adk4s.orchestration.interrupt.InMemoryCheckpointStore

// 1. Build GraphStoreMemory as the AgentMemory backend
val memory: AgentMemory[IO] = GraphStoreMemory(processor, search)

// 2. Create a ReactAgent (memory is NOT a constructor parameter)
val agent = ReactAgent.create(
  name = "assistant",
  description = "A helpful assistant with graph memory",
  model = chatModel,
  tools = Nil,
  systemPrompt = Some("You are a helpful assistant."),
  maxSteps = 10
)

// 3. Wrap the AgentRunner with MemoryAwareRunner
val runner <- AgentRunner.create(agent, InMemoryCheckpointStore())
val memoryAwareRunner = MemoryAwareRunner(
  runner,
  Some(memory),
  MemoryPolicy.default
)

// 4. Run — pre-turn recall injects context, post-turn remember persists
val result = memoryAwareRunner.run(messages)

Alternatively, adapt AgentMemory into a Retriever for use as a tool or RAG context source via MemoryRetriever:

import org.adk4s.memory.MemoryRetriever

val retriever = MemoryRetriever[IO](memory, k = 8)
// retriever.retrieve("query") returns List[Document]

HTTP API

The API is defined in Smithy IDL at smithy/src/main/smithy/. smithy4s generates the Scala service trait, JSON codecs, and http4s route builders.

Operations

Operation Method Path Description
ProcessEpisode POST /v1/episodes Ingest text, extract entities/relationships, persist to graph
SearchMemory POST /v1/search Hybrid semantic+keyword search with RRF fusion
QueryAtTime GET /v1/state/{nodeId}?at={timestamp} Point-in-time node state with active edges

Error responses

Error HTTP status When
ValidationError 400 Empty content/query, k <= 0
NotFound 404 Node not found at the given timestamp
ProcessingError 500 Backend failure (Neo4j, embedder, extraction)

Testing

The project uses a layered testing strategy:

Layer Approach Test count
domain Pure property tests (Hedgehog): TemporalEdge.isValidAt, cosine, NodeId.from determinism 8
config Ciris env-var loading, missing keys, defaults, validation, secrets 47
telemetry Logger, meters, resource, noop telemetry 41
embedder OpenAI embedder, batch, cache, ADK4S bridge 34
search Lucene keyword, semantic cosine, RRF fusion, provenance 41
neo4j Testcontainers: real Cypher, temporal predicates, CRUD, traversal, similarity 68
api Service impl, HTTP-layer error mapping, server, source-type conversion 51
episode AgentMemoryLaws conformance (4 laws + all conjunction = 5 tests) 5
Total 282+

The AgentMemoryLaws test is the crucial cross-project check: it runs the 4 ADK4S laws (kBound, scoreOrdering, recallAfterRemember, temporalIgnorability) both individually and as a conjunction against GraphStoreMemory, proving GraphStore honors the same agent memory contract that InMemoryAgentMemory is tested against.

Mutation testing

Stryker4s is configured in stryker4s.conf. Retarget the mutate list to the changed production files, then run:

JAVA_OPTS="-Xmx4g -XX:+UseG1GC" sbt "api/stryker"

In multi-module sbt builds, Stryker4s may report NoCoverage mutants because the test runner can't trace coverage through smithy4s HTTP indirection. The meaningful metric is "100% on covered code".

Code quality

  • WartRemoverWarts.unsafe minus TripleQuestionMark, Any, DefaultArguments. -Werror is on — warnings fail the build.
  • ScalafixDisableSyntax, RemoveUnused, OrganizeImports, plus custom regex rules forbidding ConfigFactory, sys.env, and infrastructure imports in domain/core.
  • Scalafmt — Scala 3 dialect, max column 120.

OpenSpec workflow

This project uses OpenSpec with the verified-scala3 schema for structured change management. The workflow produces artifacts (proposal, capability profile, concept inventory, specs, design, implementation order, tasks) before implementation, with verification rings (compile, lint, architecture, tests, compatibility, mutation, adversarial review) checked at each stage.

See openspec/ for the change archive and synced specs.

Roadmap

v0.1 (MVP) — complete

  • Domain model, Neo4j store, structured-LLM extraction
  • Bi-temporal edges with exclusive-relationship invalidation
  • Hybrid semantic+keyword search with RRF fusion
  • GraphStoreMemory implementing AgentMemory + AgentMemoryLaws green
  • Smithy4s HTTP API (ingest, search, point-in-time query)
  • Ciris config, otel4s/log4cats telemetry

v0.2 — planned

  • Graph-proximity reranking in hybrid search
  • Embedding cache for ingestion and query paths
  • Full HTTP-server telemetry wiring (server spans, request metrics)
  • Vector-index entity resolution (Neo4j 5.15+ vector index)
  • Make episodeId optional in the Smithy model (V2-3 fix)

v0.3 — planned

  • Community detection + hierarchical summaries
  • Non-exclusive contradiction adjudication via LLM (structured-llm)
  • Richer invalidation rules

Later

  • Alternative GraphStore[F] backends (FalkorDB)
  • Batch/streaming ingestion via fs2 from Kafka
  • Multi-tenant scoping

License

MIT — see LICENSE.

Author

Giovanni Ruggiero

About

A temporally-aware knowledge graph for AI agent memory

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages