Skip to content
zhanghao142Public

About

Long-term memory middleware for Java LLM agents. Pluggable backends, SPI-based design with metadata filters, works with LangChain4j & Spring AI.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

langMem4j

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 in MemoryRuntime; 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.


Architecture

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.


Features

  • Event as anchor — MemoryEvent is never mutated. Memories are formed from events (CREATE / REINFORCE / SUPERSEDE). Same-key writes go through SimpleConflictResolver (Jaccard + preference polarity), not a KV overwrite.

  • Thin Store — put / get / delete / search only. Implementations do not know about Memory, Event, decay, or conflict. Keys are event:{id} and memory:{id}.

  • Namespace isolation — user_alice / user_bob under the same store never interfere.

  • Time-driven decay on retrieve — MemoryDecayPolicy.exponential(halfLife) re-ranks results by freshness; values below pruneThreshold() are hidden (not deleted).

  • Kind-group compaction — KindGroupCompaction.categoryGroup() groups fragments by tags.category (fallback: kind). compaction.policy=llm is rejected; LLM summarization is out of this release.

  • Zero-dependency runtime — langmem4j-spi / langmem4j-core need only slf4j-api.

  • Framework-decoupled tools — save/search string I/O lives in core (SaveMemoryService); langmem4j-tools is a 1-line LangChain4j @Tool wrapper.

  • Spring Boot starter — one dependency wires Store + MemoryRuntime from langmem4j.* properties. No MemoryManager. All beans @ConditionalOnMissingBean.

  • Runtime namespace resolution — NamespaceResolver in core; starter SpEL namespace-pattern (#header / #principal).

  • Observability — MemoryMetricsRecorder SPI; Micrometer langmem4j_* meters; Actuator health probe through MemoryRuntime ingest.


Module Structure

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

Quick Start

0. Dependencies (Maven)

<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 -->

1. Spring Boot: 1 dependency + 0 lines of Java

# 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.

2. Three lines (InMemory + ingest)

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);

3. Qdrant

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/qdrant

4. Proof: langgraph4j Store as the only backend

org.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 required

listKeys 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.LangGraphMemoryProofDemo

5. Expose to an LLM

Option A: LangChain4j @Tool

var tools = List.of(
        new SaveMemoryTool(runtime, "user_alice"),
        new SearchMemoryTool(runtime, "user_alice")
);
Assistant assistant = AiServices.builder(Assistant.class)
        .chatLanguageModel(model)
        .tools(tools)
        .build();

Option B: SaveMemoryService (Spring AI / custom bridge)

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");

Decay, conflict & compaction

Decay (retrieve-time)

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.

Conflict (same key / similar preference)

SimpleConflictResolver is the default: Jaccard ≥ 0.6 plus preference polarity. Repeated similar statements REINFORCE; opposite polarity SUPERSEDE. There is no langmem4j.merge property.

Compaction

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.


Observability

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();

vs Mem0 / mem4j

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

Build & Test

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

Roadmap

  • 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

License

MIT © langMem4j Contributors

About

Long-term memory middleware for Java LLM agents. Pluggable backends, SPI-based design with metadata filters, works with LangChain4j & Spring AI.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages