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.
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-llmhandles 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'svalidFrom. 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 contract —
GraphStoreMemoryimplements ADK4SAgentMemory[F], so any ADK4S agent can use GraphStore as durable memory without knowing Neo4j or Lucene exists. The contract is verified by running ADK4SAgentMemoryLawsagainst 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.
domain ─┬─ neo4j ─┐
├─ embedder(→ adk4s-core) ┤
├─ temporal ┤
└─ search ─┴─ episode(→ adk4s-core, structured-llm, adk4s-memory-api) ── api
│
config ───────────────────────────────────────┘
telemetry ───────────────────────────────────┘
| 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 |
- No bespoke LLM client. Extraction delegates entirely to ADK4S
structured-llm— we declare result types as Smithy schemas, hand them toStructuredLLM.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) andInMemoryGraphStore(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.
| 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 |
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) —
ProjectRefloads ADK4S'sproject/ - 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
- 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
# 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 scalafmtCheckAllThe 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.shThe 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 containBeta, notAcme - G2:
contradict'sedgesInvalidated >= 1 - G3:
recall-at $T1's hits containAcme(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).
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) |
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
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
}curl -X POST http://localhost:8080/v1/search \
-H "Content-Type: application/json" \
-d '{
"query": "Where does Alice work?",
"k": 5
}'curl "http://localhost:8080/v1/state/node-Person|Alice?at=2026-07-17T09:00:00Z"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]The API is defined in Smithy IDL at smithy/src/main/smithy/. smithy4s
generates the Scala service trait, JSON codecs, and http4s route builders.
| 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 | 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) |
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.
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
NoCoveragemutants because the test runner can't trace coverage through smithy4s HTTP indirection. The meaningful metric is "100% on covered code".
- WartRemover —
Warts.unsafeminusTripleQuestionMark,Any,DefaultArguments.-Werroris on — warnings fail the build. - Scalafix —
DisableSyntax,RemoveUnused,OrganizeImports, plus custom regex rules forbiddingConfigFactory,sys.env, and infrastructure imports indomain/core. - Scalafmt — Scala 3 dialect, max column 120.
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.
- Domain model, Neo4j store, structured-LLM extraction
- Bi-temporal edges with exclusive-relationship invalidation
- Hybrid semantic+keyword search with RRF fusion
GraphStoreMemoryimplementingAgentMemory+AgentMemoryLawsgreen- Smithy4s HTTP API (ingest, search, point-in-time query)
- Ciris config, otel4s/log4cats telemetry
- 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
episodeIdoptional in the Smithy model (V2-3 fix)
- Community detection + hierarchical summaries
- Non-exclusive contradiction adjudication via LLM (
structured-llm) - Richer invalidation rules
- Alternative
GraphStore[F]backends (FalkorDB) - Batch/streaming ingestion via fs2 from Kafka
- Multi-tenant scoping
MIT — see LICENSE.
Giovanni Ruggiero