Event-driven long-term memory runtime for Java agents.
Thesis: langgraph4j's thin Store (
put/get/delete/search) is enough to implement a complex memory system. All memory semantics live inMemoryRuntime; the Store never learns what an Event or a Memory is.
MemoryEvent is the storage anchor (immutable fact). Memory is derived.
MemoryRuntime.ingest(MemoryEvent) is the only write path.
Use standalone, or expose as tool functions to LangChain4j / Spring AI.
The automated proof is RuntimeOverLangGraph4jStoreTest: CREATE / REINFORCE / SUPERSEDE / decay / compact / explain all run on org.bsc.langgraph4j.store.InMemoryStore. Runnable: LangGraphMemoryProofDemo.
Agent / Tools
│ ingest(MemoryEvent)
▼
MemoryRuntime
│ extract Candidate → resolve CREATE / REINFORCE / SUPERSEDE
▼
thin Store (event:{id} and memory:{id})
│
▼
derived Memory ← retrieve / findByKey
One facade, one write path, three storage backends — switch by changing the Store.
-
Event as anchor —
MemoryEventis never mutated. Memories are formed from events (CREATE/REINFORCE/SUPERSEDE). Same-key writes go throughSimpleConflictResolver(Jaccard + preference polarity), not a KV overwrite. -
Thin Store —
put/get/delete/searchonly. Implementations do not know about Memory, Event, decay, or conflict. Keys areevent:{id}andmemory:{id}. -
Namespace isolation —
user_alice/user_bobunder the same store never interfere. -
Time-driven decay on retrieve —
MemoryDecayPolicy.exponential(halfLife)re-ranks results by freshness; values belowpruneThreshold()are hidden (not deleted). -
Kind-group compaction —
KindGroupCompaction.categoryGroup()groups fragments bytags.category(fallback: kind).compaction.policy=llmis rejected; LLM summarization is out of this release. -
Zero-dependency runtime —
langmem4j-spi/langmem4j-coreneed onlyslf4j-api. -
Framework-decoupled tools — save/search string I/O lives in core (
SaveMemoryService);langmem4j-toolsis a 1-line LangChain4j@Toolwrapper. -
Spring Boot starter — one dependency wires
Store+MemoryRuntimefromlangmem4j.*properties. NoMemoryManager. All beans@ConditionalOnMissingBean. -
Runtime namespace resolution —
NamespaceResolverin core; starter SpELnamespace-pattern(#header/#principal). -
Observability —
MemoryMetricsRecorderSPI; Micrometerlangmem4j_*meters; Actuator health probe throughMemoryRuntimeingest.
langmem4j/
├── langmem4j-spi ← contracts: Event, Memory, Store, MemoryRuntime
├── langmem4j-core ← DefaultMemoryRuntime, InMemoryStore, Save/Search services
├── langmem4j-store-qdrant ← QdrantStore (JSON payload, optional embeddings)
├── langmem4j-langgraph4j ← LangGraph4jStore wrapping any langgraph4j Store
├── langmem4j-tools ← LangChain4j @Tool wrappers
├── langmem4j-spring-boot-starter ← Store + MemoryRuntime auto-config + Micrometer
└── langmem4j-examples/
├── langmem4j-example-plain // PlainExample + EventMemoryDemo + LangGraphMemoryProofDemo
└── langmem4j-example-springboot // POST /api/events
<dependency>
<groupId>com.langmem4j</groupId>
<artifactId>langmem4j-core</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>
<!-- Optional: langmem4j-tools (LangChain4j @Tool) -->
<!-- Optional: langmem4j-store-qdrant / langmem4j-langgraph4j -->
<!-- Optional: langmem4j-spring-boot-starter --># application.yml
langmem4j:
default-namespace: user_alice
namespace-pattern: "user_#{#header['X-User-Id'] ?: 'anonymous'}"
store:
type: inmemory # or qdrant / langgraph
decay:
enabled: true
half-life: 7d
prune-threshold: 0.01
compaction:
enabled: true
policy: category-group # llm is rejected
observability:
metrics:
enabled: true
health:
enabled: true@Autowired MemoryRuntime runtime; // that's the whole integration
runtime.ingest(MemoryEvent.of(
UUID.randomUUID().toString(),
runtime.resolveNamespace(),
EventType.USER_STATED,
"user",
Map.of("content", "Alice loves hot pot"),
Map.of("key", "favorite_food")));
runtime.compact(runtime.resolveNamespace());A request carrying X-User-Id: alice is routed to user_alice. Patterns use Spring's SimpleEvaluationContext (no T(), no constructors). Header-dependent results are never cached. See langmem4j-examples/langmem4j-example-springboot (POST /api/events).
All starter beans are @ConditionalOnMissingBean — define your own Store / MemoryRuntime and the starter steps aside.
MemoryRuntime runtime = DefaultMemoryRuntime.builder(new InMemoryStore())
.withDefaultNamespace("user_alice")
.withDecayPolicy(MemoryDecayPolicy.exponential())
.build();
runtime.ingest(MemoryEvent.of(
UUID.randomUUID().toString(),
"user_alice",
EventType.USER_STATED,
"alice",
Map.of("content", "Alice loves hot pot"),
Map.of("key", "food", "category", "preference")));
List<Memory> hits = runtime.retrieve("user_alice", "hot pot", 10);EmbeddingGenerator embedder = text ->
openAiEmbeddingModel.embed(text).content().vector();
Store qdrant = QdrantStore.builder()
.host("localhost")
.port(6334)
.embeddingGenerator(embedder)
.vectorSize(1536)
.build();
MemoryRuntime runtime = DefaultMemoryRuntime.builder(qdrant)
.withDefaultNamespace("user_alice")
.build();Without an EmbeddingGenerator, Qdrant uses a dummy vector and substring search. Start Qdrant with:
docker run -p 6333:6333 -p 6334:6334 qdrant/qdrantorg.bsc.langgraph4j.store.Store graphStore = new org.bsc.langgraph4j.store.InMemoryStore();
Store store = new LangGraph4jStore(graphStore); // put/get/delete/search — nothing else
MemoryRuntime runtime = DefaultMemoryRuntime.builder(store)
.withDecayPolicy(MemoryDecayPolicy.exponential())
.withCompaction(KindGroupCompaction.categoryGroup())
.build();
runtime.ingest(event); // CREATE / REINFORCE / SUPERSEDE
runtime.retrieve(ns, "Java", 5);
runtime.explain(ns, memoryId); // Event is still in the same Store
runtime.compact(ns); // scans via search; listKeys is not requiredlistKeys is not on the langgraph4j Store SPI. The runtime falls back to search. That is part of the proof: the thin operations are sufficient.
mvn -pl langmem4j-examples/langmem4j-example-plain -am package -DskipTests -q
mvn -pl langmem4j-examples/langmem4j-example-plain \
org.codehaus.mojo:exec-maven-plugin:3.5.0:java \
-Dexec.mainClass=com.langmem4j.example.plain.LangGraphMemoryProofDemovar tools = List.of(
new SaveMemoryTool(runtime, "user_alice"),
new SearchMemoryTool(runtime, "user_alice")
);
Assistant assistant = AiServices.builder(Assistant.class)
.chatLanguageModel(model)
.tools(tools)
.build();SaveMemoryService save = new SaveMemoryService(runtime, "user_alice");
SearchMemoryService search = new SearchMemoryService(runtime, "user_alice");
save.saveMemoryWithMetadata("food", "Alice likes hot pot", "source=user,category=preference");
String hits = search.searchMemory("food", 5, "category=preference");MemoryRuntime runtime = DefaultMemoryRuntime.builder(new InMemoryStore())
.withDecayPolicy(MemoryDecayPolicy.exponential()) // 7-day half-life
.build();factor = 0.5 ^ ((now - lastAccessedAt) / halfLifeMs). Below pruneThreshold() (default 1%) the memory is hidden, not deleted.
SimpleConflictResolver is the default: Jaccard ≥ 0.6 plus preference polarity. Repeated similar statements REINFORCE; opposite polarity SUPERSEDE. There is no langmem4j.merge property.
MemoryRuntime runtime = DefaultMemoryRuntime.builder(new InMemoryStore())
.withCompaction(KindGroupCompaction.categoryGroup())
.build();
runtime.compact("user_alice");Groups by tags.category (fallback: kind). Compacted records carry compacted=true. LLM summarization is not shipped.
| Meter | Type | Tags | Meaning |
|---|---|---|---|
langmem4j_add_total |
Counter | namespace, status | ingest() calls |
langmem4j_search_total |
Counter | namespace, status | retrieve/search calls |
langmem4j_search_duration_seconds |
Timer | namespace | retrieve latency |
langmem4j_get_total |
Counter | namespace, hit | exact-key lookup |
langmem4j_compact_total |
Counter | namespace, policy, status | compact() |
langmem4j_compact_duration_seconds |
Timer | namespace | compact latency |
langmem4j_decay_factor |
Summary | namespace | freshness of surviving hits |
langmem4j_namespace_resolve_total |
Counter | source | fixed / resolver / fallback |
Namespace tags are cardinality-capped (first 100, then _other).
Health: /actuator/health runs ingest → findByKey → delete through the real MemoryRuntime. Probe keys use the __health_check_ prefix.
Non-Spring users can implement MemoryMetricsRecorder themselves, or
construct MicrometerMemoryMetricsRecorder from the starter if Micrometer
is already on the classpath.
MemoryRuntime runtime = DefaultMemoryRuntime.builder(store)
.withMetricsRecorder(new MicrometerMemoryMetricsRecorder(registry))
.build();| langMem4j | Mem0 / mem4j | |
|---|---|---|
| Language | Native Java 17+ | Python-first |
| Design | Event anchor + derived Memory + thin Store | Typically Memory-as-KV |
| Entry | MemoryRuntime.ingest(MemoryEvent) |
Compose Memory + embedding + store |
| Tool layer | SaveMemoryService + optional LangChain4j wrappers |
Bundled with the official stack |
mvn test
mvn -pl langmem4j-examples/langmem4j-example-plain -am package -DskipTests -q
mvn -pl langmem4j-examples/langmem4j-example-plain \
org.codehaus.mojo:exec-maven-plugin:3.5.0:java \
-Dexec.mainClass=com.langmem4j.example.plain.PlainExample
mvn -pl langmem4j-examples/langmem4j-example-plain \
org.codehaus.mojo:exec-maven-plugin:3.5.0:java \
-Dexec.mainClass=com.langmem4j.example.plain.EventMemoryDemo
# Proof: same runtime, langgraph4j InMemoryStore as the only backend
mvn -pl langmem4j-examples/langmem4j-example-plain \
org.codehaus.mojo:exec-maven-plugin:3.5.0:java \
-Dexec.mainClass=com.langmem4j.example.plain.LangGraphMemoryProofDemo- Event-anchored
MemoryRuntime(ingest / retrieve / explain / compact) - Proof that langgraph4j thin Store hosts the full runtime (
RuntimeOverLangGraph4jStoreTest) - SimpleExtractor + SimpleConflictResolver (CREATE / REINFORCE / SUPERSEDE)
- Decay on retrieve + kind-group compaction
- Spring Boot starter (Store + Runtime, namespace-pattern, observability)
- LLM extraction / consolidation
- Hierarchy and hot-cold fields
- Milvus / PGVector adapters
- Compaction auto-trigger
MIT © langMem4j Contributors