Skip to content

Memory System

The Memory System (@cycgraph/memory) provides a temporal knowledge graph with xMemory-inspired hierarchical organization. It gives agents persistent, queryable memory that survives across workflow runs — not just the ephemeral WorkflowState.memory that exists within a single execution.

The memory package is standalone with zero orchestrator dependencies. It works with any application or as the memory layer inside @cycgraph/orchestrator via the memoryRetriever option.

Messages (raw conversation turns)
| EpisodeSegmenter
Episodes (topic-coherent message groups)
| SemanticExtractor
SemanticFacts (atomic knowledge units)
| ThemeClusterer
Themes (high-level clusters)

Parallel to the hierarchy, a knowledge graph stores entities (nodes) and relationships (edges) with temporal validity windows. Retrieval combines both paths: top-down hierarchical search and BFS subgraph extraction.

LevelTypeDescription
0MessagesRaw conversation turns
1EpisodesGroups of messages about one topic
2SemanticFactsAtomic facts distilled from episodes
3ThemesClusters of related facts

Queries start at the theme level and drill down only as needed, reducing token usage by up to 50% compared to flat retrieval.

Entities and relationships form a directed graph with temporal awareness:

  • Entities — people, organizations, concepts, tools, locations
  • Relationships — directed, weighted edges with valid_from / valid_until windows
  • Temporal invalidation — old facts are soft-deleted (invalidated), not removed
  • Provenance tracking — every record knows its origin (agent, tool, human, system, derived)
import { InMemoryMemoryStore } from '@cycgraph/memory';
import type { Entity, Relationship } from '@cycgraph/memory';
const store = new InMemoryMemoryStore();
const aliceId = crypto.randomUUID();
const acmeId = crypto.randomUUID();
await store.putEntity({
id: aliceId,
name: 'Alice',
entity_type: 'person',
attributes: { role: 'engineer' },
provenance: { source: 'agent', created_at: new Date() },
created_at: new Date(),
updated_at: new Date(),
});
await store.putEntity({
id: acmeId,
name: 'Acme Corp',
entity_type: 'organization',
attributes: {},
provenance: { source: 'agent', created_at: new Date() },
created_at: new Date(),
updated_at: new Date(),
});
await store.putRelationship({
id: crypto.randomUUID(),
source_id: aliceId,
target_id: acmeId,
relation_type: 'work_at',
weight: 1.0,
attributes: {},
valid_from: new Date('2024-01-01'),
provenance: { source: 'agent', created_at: new Date() },
});

Three extractors convert episodes into atomic facts:

Minimal extraction: one fact per episode (the topic). Use for bootstrapping or when extraction quality doesn’t matter.

Pattern-based extraction producing 3-10 facts per episode. Detects entities (capitalized names, @handles, camelCase, ACRONYMS) and relationships (work_at, manage, depend_on, and ~20 other base verbs with automatic inflection). Entity matching uses word boundaries to prevent false positives (e.g., “Smith” won’t match inside “Blacksmith”). No LLM required.

import { RuleBasedExtractor } from '@cycgraph/memory';
const extractor = new RuleBasedExtractor({ minSentenceLength: 20 });
const facts = await extractor.extract(episode);
// Standalone entity extraction
const entities = extractor.extractEntities('Alice Smith works at Acme Corp');
// [{ name: 'Alice Smith', type: 'person' }, { name: 'Acme Corp', type: 'organization' }]

LLM-backed extraction for maximum quality. Uses an injectable LLMProvider interface (bring your own LLM). Falls back to RuleBasedExtractor on failure.

import { LLMExtractor } from '@cycgraph/memory';
import type { LLMProvider } from '@cycgraph/memory';
const provider: LLMProvider = {
complete: async (prompt) => { /* call your LLM */ return response; },
};
const extractor = new LLMExtractor({ provider, maxFactsPerEpisode: 20 });
const facts = await extractor.extract(episode);

Greedy single-pass assignment: each fact joins the most similar existing theme (by embedding cosine similarity) or creates a new one.

Two-pass clustering that prevents theme proliferation:

  1. Assignment pass — same greedy assignment as SimpleThemeClusterer
  2. Merge pass — pairwise cosine similarity between all theme centroids; themes above mergeThreshold are merged, centroids recomputed
import { ConsolidatingThemeClusterer } from '@cycgraph/memory';
const clusterer = new ConsolidatingThemeClusterer({
assignmentThreshold: 0.7, // min similarity to join existing theme
mergeThreshold: 0.85, // merge themes above this similarity
maxThemes: 50, // soft cap
});
const themes = await clusterer.cluster(facts, existingThemes);

Top-down search: match themes by embedding similarity, expand to facts, apply temporal filters, expand to episodes, collect entities and relationships.

import { retrieveMemory } from '@cycgraph/memory';
const result = await retrieveMemory(store, index, {
embedding: queryVector,
limit: 20,
min_similarity: 0.5,
valid_at: new Date(), // only currently-valid facts
changed_since: lastQueryTime, // only recent changes
});
// result.themes, result.facts, result.episodes, result.entities, result.relationships

When you have specific entity IDs, retrieval uses BFS subgraph extraction:

const result = await retrieveMemory(store, index, {
entity_ids: [aliceId, bobId],
max_hops: 2,
limit: 20,
});
import { isValidAt, filterValid } from '@cycgraph/memory';
isValidAt(relationship, new Date()); // within [valid_from, valid_until)?
const validFacts = filterValid(allFacts, {
valid_at: new Date(),
changed_since: lastSync,
include_invalidated: false,
});

Over time, memory accumulates duplicates, outdated facts, and contradictions. The consolidation system manages the lifecycle:

Prunes and deduplicates memory records to stay within budget:

import { MemoryConsolidator } from '@cycgraph/memory';
const consolidator = new MemoryConsolidator(store, index, {
maxFacts: 1000, // prune lowest-scoring facts over this count
maxEpisodes: 100, // prune oldest episodes over this count
decayHalfLifeDays: 30, // time-based relevance decay
dedupThreshold: 0.9, // cosine similarity for near-duplicate detection
deleteMode: 'soft', // 'soft' (invalidate) or 'hard' (delete)
batchSize: 1000, // paginated fact loading (avoids OOM on large stores)
logger: { warn: console.warn }, // optional structured logging
});
const report = await consolidator.consolidate();
// report.factsDeduped — near-duplicates merged
// report.factsDecayed — low-relevance facts pruned
// report.episodesPruned — old episodes removed
// report.themesCleanedUp — themes with updated fact_ids
// report.themesRemoved — empty themes deleted

Consolidation cascades to themes: when facts are pruned, the themes that referenced them have their fact_ids updated and their embeddings recomputed. Themes with zero remaining facts are deleted.

Identifies contradictory, negating, or superseding facts:

import { ConflictDetector } from '@cycgraph/memory';
const detector = new ConflictDetector(store, index, {
autoResolveSupersession: true,
embeddingThreshold: 0.8,
policy: 'negation-invalidates-positive',
});
const conflicts = await detector.detectConflicts();
// Auto-resolve with configured policy
const resolution = await detector.autoResolveAll(conflicts);

Three conflict types:

TypeDetectionConfidence
negationOne fact contains negation words, high word overlap0.8
supersessionSame entities, similar content, >N days apart (configurable via supersessionDayThreshold, default 1)0.9
semantic_contradictionHigh embedding similarity, shared entities, low text overlap0.3-0.7 (scaled by fact length)

Three resolution policies:

PolicyBehavior
supersede-on-newerAlways keep the newer fact
negation-invalidates-positiveKeep the negation (the correction), use temporal order for supersession, skip semantic contradictions
manual-reviewReturn all conflicts unresolved
BackendPackageUse Case
InMemoryMemoryStore@cycgraph/memoryTesting and lightweight use
InMemoryMemoryIndex@cycgraph/memoryBrute-force cosine similarity
DrizzleMemoryStore@cycgraph/orchestrator-postgresProduction Postgres
DrizzleMemoryIndex@cycgraph/orchestrator-postgrespgvector HNSW indexes
// Production setup
import { DrizzleMemoryStore, DrizzleMemoryIndex } from '@cycgraph/orchestrator-postgres';
const store = new DrizzleMemoryStore();
const index = new DrizzleMemoryIndex();

Inject memory retrieval into GraphRunner via the memoryRetriever option:

import { GraphRunner } from '@cycgraph/orchestrator';
import { InMemoryMemoryStore, InMemoryMemoryIndex, retrieveMemory } from '@cycgraph/memory';
const store = new InMemoryMemoryStore();
const index = new InMemoryMemoryIndex();
const memoryRetriever = async (query, options) => {
const result = await retrieveMemory(store, index, {
entity_ids: query.entityIds,
limit: options?.maxFacts ?? 20,
});
return {
facts: result.facts.map(f => ({ content: f.content, validFrom: f.valid_from })),
entities: result.entities.map(e => ({ name: e.name, type: e.entity_type })),
themes: result.themes.map(t => ({ label: t.label })),
};
};
const runner = new GraphRunner(graph, state, { memoryRetriever });
  • Workflow State — ephemeral per-run memory vs persistent knowledge graph
  • Context Engine — compress memory payloads before prompt injection
  • Using Memory — practical guide for integrating memory into workflows
  • Persistence — how workflow state is persisted alongside memory