Back to .md Directory

LokulMem — Product Requirements Document

Defines the full specification for a browser-native, zero-server memory library that gives LLMs persistent, inspectable user knowledge with decay and contradiction handling.

May 2, 2026
0 downloads
1 views
ai llm rag eval
View source

What this file does

Defines the full specification for a browser-native, zero-server memory library that gives LLMs persistent, inspectable user knowledge with decay and contradiction handling.

When to use it

  • Building a client-side memory system for an LLM-powered app
  • Evaluating whether to build or buy persistent user memory for a web AI product
  • Designing an extraction, storage, and retrieval pipeline with Ebbinghaus decay
  • Comparing LokulMem against existing memory libraries like Mem0 or LangMem

Assumes this stack

TypeScriptIndexedDBDexie.jsTransformers.jsMiniLM-L6-v2HNSW

LokulMem — Product Requirements Document

Version: 0.4 (Final — Approved for Build) Status: Green Light — Start with v0.1 Author: Lokul Core Team Last Updated: February 2026

Changelog from v0.3:

  • augment() now returns optional debug object: injected memories, R scores, reasoning — the single highest-leverage addition for developer trust and memory panel transparency
  • manage() API substantially expanded: event subscriptions, bulk operations, timeline, lineage, semantic search, related facts, pinning, conversation-source queries
  • pinned field added to memory schema with retrieval priority and decay immunity
  • sourceConversationIds added to memory schema — tracks all conversations that contributed to a memory
  • archived surfaced explicitly as a status in manage() bulk operations
  • exportToMarkdown() added alongside existing JSON export
  • Rejection rationale documented for: reextractAll(), compact/merge, setRetentionPolicy, suggestCategory/Merge, validateFact, format helpers, importFromMarkdown, application-level useMemoryPanel hook
  • Math tuning note added to implementation guidance — Ebbinghaus λ constants and extraction thresholds must be treated as empirically tunable, not fixed

Table of Contents

  1. What Is LokulMem
  2. The Problem It Solves
  3. Non-Goals — Explicit Scope Boundaries
  4. Design Philosophy
  5. Architecture Overview
  6. The Three Algorithmic Layers
  7. Memory Types
  8. The Public API
  9. Storage Schema
  10. Enhanced Features
  11. Libraries — What to Use, What to Build, What to Skip
  12. Implementation Roadmap
  13. Success Metrics
  14. Appendix — Mathematical Reference

1. What Is LokulMem

LokulMem is a browser-native, zero-server, LLM-agnostic memory management library for AI applications. It gives any LLM — running locally via WebGPU/WebLLM, or remotely via API — the ability to remember users across conversations, extract structured knowledge from dialogue, and retrieve contextually relevant memories at inference time.

LokulMem has no backend. It requires no API key. Everything — extraction scoring, embedding, storage, retrieval, decay — happens on the user's device, persisted to IndexedDB via Dexie.js, with embeddings computed by a quantised MiniLM model running in a SharedWorker (with automatic fallback) via Transformers.js.

One sentence: LokulMem is what memory would look like if it were built as an architectural property of the client rather than a feature of the server.


2. The Problem It Solves

The Core Problem

Every LLM has a context window — a finite working memory it attends to at inference time. When a conversation ends, that window is gone. The next conversation starts from zero. This is not a model limitation. It is an infrastructure limitation. The model could use prior knowledge about the user if that knowledge existed in its context.

Standard RAG makes this worse, not better. It dumps text into a vector database and blindly retrieves based on similarity — no lifecycle, no decay, no contradiction handling, no understanding of time. Facts from two years ago score identically to facts from yesterday. Contradictions are silently overwritten. There is no way to inspect or trust what the system believes.

LokulMem solves the infrastructure problem correctly: persistent, queryable user knowledge with a full lifecycle — extracted, stored with decay, retrieved with composite relevance scoring, correctable by the user, and fully inspectable.

Competitive Landscape

LibraryServer RequiredCloud LLM RequiredBrowser NativeFull LifecycleStatus
Mem0✓ (OpenAI default)PartialActive
LangMem✓ (LangGraph)PartialActive
A-MEM✓ (ChromaDB)Research
MemOS✓ (Redis)Active
railroad-memoryPartialActive
EntityDBActive
LokulMemBuilding

railroad-memory is the closest conceptual neighbour — a client-side context memory library with hierarchical tiers, pruning, and custom storage adapters. It does not implement browser-native embeddings, has no decay model, no contradiction resolution, no episodic or proactive memory. It manages context window selection; LokulMem manages the full lifecycle of persistent user knowledge.

EntityDB is browser-native vector storage — good plumbing, not a memory management system. No extraction, no decay, no contradiction handling.

The full browser-native LLM memory lifecycle layer — typed extraction, mathematical decay, contradiction audit trail, episodic memory, proactive injection, user-inspectable and correctable — is unoccupied.


3. Non-Goals — Explicit Scope Boundaries

The following are explicitly out of scope at all versions unless a separate PRD is written. Contributors and developers should treat this list as immovable without a new PRD.

Cross-device sync — Intentionally device-local. If a user wants memories on two devices, they export from one and import to the other.

Multi-user or shared memory — One user per store instance. No collaborative or team memory.

Server replication or backup — No cloud backup, no remote copy. The user owns their data.

Real-time collaboration — No merge protocol for two users sharing a memory store.

Mobile SDK — Targets browsers. React Native, Swift, Kotlin are out of scope.

Non-browser JavaScript runtimes — Node.js, Deno, Bun, Cloudflare Workers are out of scope. The library depends on browser APIs (IndexedDB, SharedWorker, Web Crypto, navigator.storage) that do not exist in server runtimes.

LLM fine-tuning or training — LokulMem injects memories into context. It does not use memories to train or fine-tune models.

General-purpose semantic search API — Vector search is internal infrastructure for memory retrieval, not a public search product.

Application-level UI state management — The library exposes data and events. Applications build their own UI state (selected items, search query string, active filters) on top. The library does not own UI state.

Re-extraction from conversation history — The library does not store raw conversations. It stores extracted facts. Applications that want to re-extract must call learn() on their own stored messages. reextractAll() is out of scope.

Bulk fact merging / deduplication enginecompact() and mergeFacts() are out of scope for v0.1. The decay system handles gradual compaction automatically. Pairwise similarity across all memories is O(N²) without careful indexing. Deferred until real user data shows it is needed.

AI-powered fact suggestionssuggestCategory() and suggestMerge() require the library to make AI calls or run inference internally. Out of scope. The extraction classifier handles categorisation at write time.

Markdown import — Parsing arbitrary Markdown back into structured memories is a non-trivial NLP problem with no reliable round-trip guarantee. exportToMarkdown() is one-way. Import is JSON only.

Fact formatting helpersformatFactForDisplay(), formatFactForEdit() are application-level concerns. Out of scope.

Retention policy enginesetRetentionPolicy() (max age, max facts, min confidence pruning) is deferred to v0.2. The Ebbinghaus decay model already handles the minConfidence case automatically.


4. Design Philosophy

Privacy by Architecture

LokulMem cannot leak user data because it has nowhere to send it. The constraint is architectural, not policy-based. No API key. No telemetry. The memory store is as private as the user's local file system.

Inspectability and Trust

A memory system nobody can inspect is a memory system nobody will trust. Every decision LokulMem makes — what it stored, why it retrieved it, what score it gave — must be queryable. The debug return from augment(), the lineage API, the injection preview — these are not nice-to-haves. They are what turns a clever library into infrastructure people bet their products on.

LLM Agnosticism

LokulMem communicates through a standard messages interface. If your LLM takes {role, content}[], LokulMem works with it.

Minimal Surface Area on Core API

Three primary methods: augment(), learn(), manage(). Integration in under 10 minutes. Complexity lives inside the library.

Math Is Empirical, Not Axiomatic

The Ebbinghaus λ constants, the 0.45 extraction threshold, the 0.80 contradiction similarity threshold — these are informed starting points, not laws. Real-world LLM embeddings are fuzzy. User dialogue is messier than any synthetic test. Every numerical constant in this PRD must be treated as a configurable default that will require tuning during alpha testing. Build the weights as easily adjustable variables from day one. Expect to change most of them.

Don't Reinvent Solved Problems

Dexie.js for IndexedDB. Transformers.js for embeddings. Purpose-built minimal HNSW for vector search at scale. The novel contribution is the memory lifecycle layer. That is where we build. The rest we compose.


5. Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                        APPLICATION                           │
│            (Lokul, any WebLLM app, any LLM app)             │
└─────────────────────────┬───────────────────────────────────┘
                           │ main thread
                    ┌──────▼──────┐
                    │  LokulMem   │
                    │   API       │
                    │ augment()   │  ← returns messages + optional debug
                    │ learn()     │  ← returns extracted + events
                    │ manage()    │  ← full panel API surface
                    └──────┬──────┘
                           │ postMessage / MessageChannel
          ┌────────────────┼────────────────┐
          │                │                │
   ┌──────▼──────┐  ┌──────▼──────┐  ┌─────▼──────┐
   │  EXTRACTION │  │  RETRIEVAL  │  │ MANAGEMENT  │
   │   LAYER     │  │   LAYER     │  │   LAYER     │
   └──────┬──────┘  └──────┬──────┘  └─────┬──────┘
          │                │                │
          └────────────────┼────────────────┘
                           │
         ┌─────────────────▼─────────────────┐
         │         WORKER LAYER               │
         │  ┌─────────────────────────────┐   │
         │  │  EMBEDDING ENGINE           │   │
         │  │  MiniLM-L6-v2 (quantised)  │   │
         │  │  Transformers.js / WASM     │   │
         │  │  LRU cache (1,000 entries)  │   │
         │  └─────────────────────────────┘   │
         │  ┌─────────────────────────────┐   │
         │  │  VECTOR SEARCH              │   │
         │  │  Brute force (N ≤ 3,000)   │   │
         │  │  Minimal HNSW (N > 3,000)  │   │
         │  └─────────────────────────────┘   │
         │                                     │
         │  Fallback detection (main thread):  │
         │  1. SharedWorker  (preferred)       │
         │  2. Worker        (Chrome Android)  │
         │  3. Main thread   (last resort)     │
         └─────────────────┬───────────────────┘
                           │
                    ┌──────▼──────┐
                    │   STORAGE   │
                    │  IndexedDB  │
                    │  (Dexie.js) │
                    └─────────────┘

Worker Mode Detection and Fallback

Runs on the main thread during init() — before the worker is spawned and before any IndexedDB operations. navigator.storage.persist() is also called here. Both are impossible inside a Worker context.

async function detectWorkerMode(): Promise<
  "shared" | "dedicated" | "main-thread"
> {
  if (typeof SharedWorker !== "undefined") return "shared";
  if (typeof Worker !== "undefined") return "dedicated";
  return "main-thread";
}
ModeMulti-tab safeBlocks UIAvailability
SharedWorkerDesktop Chrome, Firefox, Safari, Edge
Dedicated Worker✗ (per-tab)Chrome Android, broad
Main threadUniversal fallback

Console warnings in non-SharedWorker modes:

[LokulMem] SharedWorker not available. Running in dedicated Worker mode.
Multi-tab synchronisation disabled. Memory writes from multiple tabs
may conflict. Consider limiting to single-tab usage.

[LokulMem] Worker not available. Running on main thread.
Embedding operations will block the UI. Not recommended for production.

Non-SharedWorker risk is duplicate maintenance sweeps, not data corruption. Dexie.js transaction locking prevents corruption in all modes.


6. The Three Algorithmic Layers

Layer 1 — Extraction: What Is Worth Remembering?

After each exchange, every sentence in the user's message is scored using three signals.

Signal 1: Novelty Score

novelty(s) = 1 - top1_similarity(s)

Routed through the existing retrieval backend (top-1 query). Not a separate O(N) loop. O(log N) when HNSW is active. If store is empty: novelty(s) = 1.0.

Signal 2: Specificity Score

specificity(s) = min(1.0, Σ weights[feature] for feature in detected_features(s))

Lightweight NER — regex + static lookup table (~300 lines). No LLM call.

FeatureWeight
Proper noun — person name0.30
Proper noun — place or organisation0.25
Numerical value0.20
Explicit preference ("I prefer", "I like", "I hate")0.25
Date or time reference0.20
Negation of preference ("I don't", "I never")0.20
First-person possession ("my", "mine")0.10

Signal 3: Recurrence Weight

recurrence(s) = min(1.0, 0.2 + (0.25 × occurrences(s)))

Where occurrences(s) = past sentences in session with cosine similarity > 0.85 to s. Max at 4 occurrences.

Combined Extraction Score

E(s) = α × novelty(s) + β × specificity(s) + γ × recurrence(s)

Default weights: α = 0.35, β = 0.45, γ = 0.20 Default threshold: E(s) ≥ 0.45

Important: These are starting points. Expect to tune aggressively during alpha. Real dialogue is noisier than synthetic tests. The threshold and weights must be easily adjustable in init options and should be the first things changed when extraction quality is wrong.


Contradiction Detection — Three-Stage Pipeline

Stage 1 — Candidate Match

top1_similarity(new_fact) > 0.80

Uses same retrieval backend. No extra scan.

Stage 2 — Temporal Marker Detection

const temporalMarkers = [
  "used to",
  "previously",
  "before",
  "anymore",
  "no longer",
  "not anymore",
  "now i",
  "these days",
  "switched to",
  "moved to",
  "changed to",
  "lately",
  "recently started",
  "stopped",
  "quit",
  "gave up",
];

If detected: existing fact gets validTo = now. New fact stored with validFrom = now and temporalSupersedes reference. Both preserved — history, not snapshot.

Stage 3 — Typed Attribute Conflict

If no temporal marker and similarity > 0.80:

conflict = same_entity AND same_attribute_type AND different_value AND no_temporal_marker

On confirmed conflict: existing → status: superseded. New → status: active. Event emitted. Nothing silently deleted.

ScenarioTemporal marker?Result
"I live in London" (was: Berlin)NoConflict — Berlin superseded
"I used to live in Berlin, now London"YesCoexistence — Berlin validTo set
"I prefer dark mode" (was: light mode)NoConflict — light mode superseded
"I'm no longer at Acme Corp"YesCoexistence — Acme validTo set

Important: The 0.80 similarity threshold for contradiction candidates will likely need tuning. Too high: misses real contradictions. Too low: false positives on unrelated but embedding-similar statements. Tune during alpha.


Layer 2 — Storage: Decay and Persistence

Ebbinghaus Forgetting Curve

strength(t) = base_strength × e^(-λ × Δt_hours)
CategoryλHalf-life
Identity (name, age, pronouns)0.0001~289 days
Location0.0005~58 days
Profession / Role0.0003~96 days
Preferences0.001~29 days
Current project0.005~6 days
Temporal context0.02~35 hours
Relational (people)0.0004~72 days
Emotional state0.01~3 days

Important: These λ values are informed estimates. A user who mentions their project every day will have different effective decay behaviour than a user who chats weekly. Monitor during alpha and adjust.

Pinned memories: pinned: true memories have λ = 0 — they never decay. They get retrieval priority. They are excluded from automatic pruning. The user must explicitly unpin or delete them.

Reinforcement:

base_strength = min(3.0, base_strength + 0.3)  // on each retrieval

Emotional valence boost: Memories with |valence| > 0.6 start at base_strength = 1.2.

Pruning: strength(t) < 0.1status: faded. Retained 30 days. Then permanently deleted.

Semantic Clustering

K-means in worker. Dynamic K:

K = max(3, floor(√(total_active_memories / 2)))

Uses stored embeddings. Runs at session start if > 10 new memories since last run. Cluster members get +0.05 retrieval bonus.


Layer 3 — Retrieval: What Gets Injected?

Vector Search Routing

N ≤ hnswThreshold (default: 3000) → brute_force_cosine_search()
N > hnswThreshold                 → minimal_hnsw_search()

HNSW loads lazily. Zero cost until threshold crossed. Both paths return the same interface.

Composite Relevance Score

R(m, q) = w1 × semantic_sim(m, q)
         + w2 × temporal_recency(m)
         + w3 × memory_strength(m)
         + w4 × conversation_continuity(m, recent_turns)

Default weights: w1 = 0.40, w2 = 0.20, w3 = 0.25, w4 = 0.15. All configurable.

Pinned memories: w3 component set to 1.0 regardless of actual strength.

semantic_sim(m, q)   = cosine_similarity(embed(m.content), embed(q))
temporal_recency(m)  = e^(-0.005 × hours_since_created(m))
memory_strength(m)   = strength(t) / 3.0
continuity(m, turns) = max(cosine_similarity(embed(m.content), embed(t)) for t in last_3_turns)

Token-Aware Dynamic K

tokenCounter?: (text: string) => number  // provide your LLM's tokenizer
// fallback: Math.ceil(text.length / 4)  // adequate for Latin scripts, underestimates code/CJK

available_tokens = context_window_size - system_prompt_tokens - history_tokens - response_buffer
K = floor(available_tokens / avg_tokens_per_candidate)

Do not use the MiniLM tokenizer from the SharedWorker. MiniLM uses WordPiece; most LLMs use BPE or SentencePiece. Token counts differ meaningfully. Always provide your LLM's tokenizer via tokenCounter for accurate budgeting.

Injection Format — v0.1

prepend-system only in v0.1. v0.2 adds injectionMode option.

[Memory Context — what you know about this user]
• 📌 Alex is a product manager at a Series B startup in Berlin. (pinned)
• Alex prefers concise answers and bullet points. (confidence: high)
• Alex is currently preparing a board presentation. (confidence: medium)
• Alex mentioned their co-founder is Maya. (confidence: medium)
• Alex seems to be under time pressure this week. (confidence: low — inferred)
[End Memory Context]

Confidence mapping: High = base_strength ≥ 1.5 + explicitly stated. Medium = base_strength ≥ 0.8 or stated once. Low = inferred.


7. Memory Types

Seven defaults. Extensible. Multiple types per memory allowed.

TypeDescriptionExamplesλ
identityWho the user isName, age, pronouns0.0001
locationWhere they areCity, country, timezone0.0005
professionWhat they doJob title, employer0.0003
preferenceHow they like thingsTone, format, style0.001
projectWhat they're working onTasks, goals, deadlines0.005
temporalTime-sensitive context"This week", deadlines0.02
relationalPeople in their lifeNames, relationships0.0004
emotionalHow they're feelingStress, excitement0.01

Custom types:

customTypes: [
  { name: "medical", decayLambda: 0.00005 },
  { name: "financial", decayLambda: 0.002 },
];

8. The Public API

Installation

npm install @lokul/lokulmem

Initialisation

import { LokulMem } from '@lokul/lokulmem'

const memory = new LokulMem({
  // Storage
  storage: 'indexeddb' | 'memory',
  storeName: 'lokulmem_default',

  // Embedding
  embedder: 'minilm',             // or custom EmbedderInterface

  // Extraction — tune these during alpha
  extractionThreshold: 0.45,      // lower = more extracted, higher = stricter
  extractionWeights: { novelty: 0.35, specificity: 0.45, recurrence: 0.20 },

  // Retrieval — tune these during alpha
  contextWindowSize: 4096,
  responseBuffer: 512,
  retrievalWeights: { semantic: 0.40, recency: 0.20, strength: 0.25, continuity: 0.15 },
  hnswThreshold: 3000,

  // Token budgeting — always provide for accuracy
  tokenCounter: (text: string) => number,

  // Decay
  decayModel: 'ebbinghaus' | 'linear' | 'none',

  // Custom types
  customTypes: CustomTypeDefinition[],

  // Encryption (v0.3)
  encryptionPassphrase?: string,
  keyStorage?: 'session' | 'local' | 'none',  // default: 'none'

  // Callbacks
  onMemoryAdded: (memory: Memory) => void,
  onMemoryUpdated: (memory: Memory) => void,
  onMemoryDeleted: (id: string) => void,
  onMemoryFaded: (memory: Memory) => void,
  onContradictionDetected: (existing: Memory, incoming: Memory, resolution: 'conflict' | 'temporal-coexistence') => void,
  onStatsChanged: (stats: MemoryStats) => void,
  onStoragePersistDenied: () => void,
  onProgress: (event: { stage: 'worker' | 'model' | 'storage' | 'maintenance' | 'ready', progress: number }) => void,
})

await memory.init()
// Main thread sequence:
// 1. Detect worker mode
// 2. navigator.storage.persist() — main thread, before worker spawn
// 3. Spawn/connect worker
// 4. Load MiniLM model
// 5. Connect Dexie.js, run migrations
// 6. Maintenance sweep (decay pruning, clustering)
// 7. Proactive scan (v0.2+)
// 8. Resolve ready

memory.augment(message, history, options?)

const result = await memory.augment(
  userMessage: string,
  history: Message[],
  options?: {
    maxMemories?: number,
    minRelevance?: number,
    types?: string[],
    includeEpisodes?: boolean,       // v0.2+
    includeProactive?: boolean,      // v0.2+
    debug?: boolean,                 // return LokulMemDebug alongside messages
  }
): Promise<AugmentResult>

interface AugmentResult {
  messages: Message[]           // augmented messages array — pass directly to LLM
  debug?: LokulMemDebug         // only populated when options.debug = true
}

interface LokulMemDebug {
  injected: InjectedMemory[]        // what was injected and why
  candidates: ScoredMemory[]        // all memories considered (top 20)
  episodeInjected?: Episode         // episode injected if any (v0.2+)
  proactiveInjected?: Memory[]      // proactive memories injected (v0.2+)
  tokensUsed: number                // tokens consumed by memory context
  tokensAvailable: number           // tokens available before injection
  retrievalStrategy: 'brute-force' | 'hnsw'
  embeddingLatencyMs: number
  retrievalLatencyMs: number
}

interface InjectedMemory {
  memory: Memory
  score: number                     // composite R score
  scoreBreakdown: {
    semantic: number                // w1 component
    recency: number                 // w2 component
    strength: number                // w3 component
    continuity: number              // w4 component
  }
  reason: string                    // human-readable: "high semantic match + recently reinforced"
  injectionRank: number             // 1 = most relevant
}

interface ScoredMemory {
  memory: Memory
  score: number
  injected: boolean                 // false if below threshold or budget
  excludedReason?: string           // "below relevance threshold" | "token budget exhausted" | "type filtered"
}

This is the most important addition in v0.4. When debug: true:

  • Developers can see exactly what LokulMem injected and why
  • The memory panel can show "what Lokul knew when you sent this message"
  • Tuning the extraction threshold and retrieval weights becomes empirical rather than guesswork
  • Trust is built through transparency, not marketing

memory.learn(userMessage, assistantResponse, options?)

const result = await memory.learn(
  userMessage: string,
  assistantResponse: string,
  options?: {
    sessionId?: string,
    forceExtract?: boolean,
    skipTypes?: string[],
  }
): Promise<LearnResult>

interface LearnResult {
  extracted: Memory[]
  contradictions: ContradictionEvent[]
  faded: Memory[]
  totalMemories: number
}

interface ContradictionEvent {
  existing: Memory
  incoming: Memory
  resolution: 'conflict' | 'temporal-coexistence'
  temporalMarkerDetected?: string   // the marker that triggered coexistence path
}

memory.manage() — Full Panel API

const mgmt = memory.manage();

Querying

// List with filters
await mgmt.list(filter?: {
  type?: string | string[],
  status?: 'active' | 'faded' | 'superseded' | 'archived',
  minStrength?: number,
  pinned?: boolean,
  since?: number,              // Unix ms — memories created after this timestamp
  until?: number,
  limit?: number,
  offset?: number,
  orderBy?: 'createdAt' | 'lastAccessedAt' | 'strength' | 'mentionCount',
  direction?: 'asc' | 'desc',
}): Promise<Memory[]>

// Single memory
await mgmt.get(id: string): Promise<Memory>

// Memories from a specific conversation
await mgmt.getByConversation(conversationId: string): Promise<Memory[]>

// Most recently accessed
await mgmt.getRecent(limit?: number): Promise<Memory[]>

// Highest strength / most reinforced
await mgmt.getTop(limit?: number): Promise<Memory[]>

// Pinned memories
await mgmt.getPinned(): Promise<Memory[]>

// Full-text search (searches content strings)
await mgmt.search(query: string): Promise<Memory[]>

// Semantic search (embedding-based — uses vector search infrastructure)
await mgmt.semanticSearch(query: string, limit?: number): Promise<Memory[]>

// Related memories (first-degree graph neighbours — v0.2+)
await mgmt.getRelated(memoryId: string): Promise<Memory[]>

// Timeline — memories grouped by date of creation
await mgmt.getTimeline(): Promise<Array<{
  date: string,                // ISO date string 'YYYY-MM-DD'
  memories: Memory[],
}>>

// Grouped for display — pre-organised for UI rendering
await mgmt.getGrouped(): Promise<{
  pinned: Memory[],
  identity: Memory[],
  location: Memory[],
  profession: Memory[],
  preferences: Memory[],
  projects: Memory[],
  temporal: Memory[],
  relational: Memory[],
  emotional: Memory[],
  custom: Record<string, Memory[]>,   // developer-defined types
  recent: Memory[],                   // last 10 regardless of type
}>

// Full lineage of a memory — history, sources, supersession chain
await mgmt.getLineage(memoryId: string): Promise<{
  memory: Memory,
  sourceConversations: string[],  // all conversation IDs that contributed
  supersedes: Memory | null,      // memory this one replaced (if conflict)
  supersededBy: Memory | null,    // memory that replaced this one
  temporalHistory: Memory[],      // all temporal coexistence versions
  reinforcementCount: number,     // how many times this was retrieved and reinforced
}>

// Preview what augment() would inject for a given message
// Without running inference — useful for memory panel "preview" feature
await mgmt.getInjectionPreview(
  userMessage: string,
  options?: { types?: string[], maxMemories?: number }
): Promise<{
  wouldInject: InjectedMemory[],
  estimatedTokens: number,
}>

Mutations

// Update content or metadata (user correction)
await mgmt.update(id: string, patch: {
  content?: string,
  types?: string[],
  confidence?: 'high' | 'medium' | 'low',
}): Promise<Memory>

// Pin / unpin
await mgmt.pin(id: string): Promise<Memory>
await mgmt.unpin(id: string): Promise<Memory>

// Archive (soft delete — excluded from retrieval, retained in history)
await mgmt.archive(id: string): Promise<Memory>
await mgmt.unarchive(id: string): Promise<Memory>

// Permanent delete
await mgmt.delete(id: string): Promise<void>

// Full reset
await mgmt.clear(): Promise<void>

// Bulk operations (for manage-mode multi-select UI)
await mgmt.deleteMany(ids: string[]): Promise<void>
await mgmt.archiveMany(ids: string[]): Promise<void>
await mgmt.pinMany(ids: string[]): Promise<void>
await mgmt.unpinMany(ids: string[]): Promise<void>

Analytics

await mgmt.stats(): Promise<MemoryStats>

interface MemoryStats {
  total: number,
  active: number,
  faded: number,
  archived: number,
  superseded: number,
  pinned: number,
  byType: Record<string, number>,
  averageStrength: number,
  averageConfidence: number,
  confidenceDistribution: { high: number, medium: number, low: number },
  strengthDistribution: { strong: number, medium: number, weak: number },
  mostMentioned: Memory[],          // top 5 by mentionCount
  recentlyUpdated: Memory[],        // last 5 updated
  recentlyFaded: Memory[],          // last 5 that faded
  oldestMemory: Memory | null,
  newestMemory: Memory | null,
  storageUsedBytes: number,
  conversationCount: number,        // distinct conversation IDs across all memories
}

Export and Import

// Export — portable JSON, re-importable
await mgmt.export(): Promise<{
  version: string,
  exportedAt: number,
  storeName: string,
  memories: Memory[],
  episodes: Episode[],
  clusters: Cluster[],
  edges: Edge[],
}>

// Export as Markdown — one-way, human-readable, not re-importable
await mgmt.exportToMarkdown(): Promise<string>
// Format:
// # LokulMem Export — [date]
// ## Identity
// - **Alex** — Product manager at Series B startup in Berlin *(high confidence)*
// ## Preferences
// - Prefers concise answers and bullet points *(high confidence)*
// ...

// Import from JSON
await mgmt.import(data: ExportData, options?: {
  merge?: boolean,                      // default: false (replaces)
  conflictStrategy?: 'keep-existing' | 'use-imported' | 'keep-newer',
}): Promise<{ imported: number, skipped: number, conflicts: number }>

Event Subscriptions (for reactive UI)

// Subscribe to memory lifecycle events
// All return an unsubscribe function

const unsub1 = mgmt.onMemoryAdded((memory: Memory) => {
  // Update UI — new memory card, increment stats
});

const unsub2 = mgmt.onMemoryUpdated((memory: Memory) => {
  // Update specific memory card
});

const unsub3 = mgmt.onMemoryDeleted((id: string) => {
  // Remove memory card from UI
});

const unsub4 = mgmt.onMemoryFaded((memory: Memory) => {
  // Show "faded" indicator or remove from active list
});

const unsub5 = mgmt.onStatsChanged((stats: MemoryStats) => {
  // Update stats panel, counters, category breakdown
});

const unsub6 = mgmt.onContradictionDetected((event: ContradictionEvent) => {
  // Optionally surface in UI: "Lokul noticed a change"
});

// Cleanup on component unmount
unsub1();
unsub2();
unsub3();
unsub4();
unsub5();
unsub6();

React Hook (v0.2)

The library ships a minimal useMem() hook. Applications build their own panel hooks on top.

import { useMem } from "@lokul/lokulmem/react";

// Library-level hook — raw data and core actions
function useMem(options: LokulMemOptions): {
  memories: Memory[]; // reactive — updates on every learn() or manage mutation
  stats: MemoryStats; // reactive — updates on every mutation
  isReady: boolean; // false during init()
  error: Error | null;
  augment: typeof memory.augment;
  learn: typeof memory.learn;
  manage: typeof memory.manage;
};

// Application builds panel-specific hooks on top:
// function useMemoryPanel() {
//   const { memories, stats, manage } = useMem(config)
//   const [selectedIds, setSelectedIds] = useState(new Set())
//   const [searchQuery, setSearchQuery] = useState('')
//   const [activeType, setActiveType] = useState('all')
//   // ... application UI state
// }

Integration Examples

// ── WebLLM — browser, local WebGPU ──────────────────────────────────────
import { CreateMLCEngine } from "@mlc-ai/web-llm";
import { LokulMem } from "@lokul/lokulmem";

const engine = await CreateMLCEngine("Qwen2.5-1.5B-Instruct-q4f32_1-MLC");
const memory = new LokulMem({
  storeName: "lokul-user",
  contextWindowSize: 4096,
  onProgress: ({ stage, progress }) => updateLoadingUI(stage, progress),
  onMemoryAdded: (m) => refreshMemoryPanel(),
  onStatsChanged: (s) => updateStatsPanel(s),
});
await memory.init();

async function chat(userMessage, history) {
  const { messages, debug } = await memory.augment(userMessage, history, {
    debug: true,
  });
  console.log("Injected memories:", debug.injected); // inspect what Lokul knows

  const reply = await engine.chat.completions.create({ messages });
  const response = reply.choices[0].message.content;

  const { extracted } = await memory.learn(userMessage, response);
  console.log("Learned:", extracted);

  return response;
}

// ── OpenAI ───────────────────────────────────────────────────────────────
import OpenAI from "openai";
import { encoding_for_model } from "tiktoken";

const enc = encoding_for_model("gpt-4o");
const memory = new LokulMem({
  storeName: "openai-user",
  contextWindowSize: 128000,
  tokenCounter: (text) => enc.encode(text).length, // accurate GPT-4o tokenizer
});
await memory.init();

// ── Anthropic ─────────────────────────────────────────────────────────────
import Anthropic from "@anthropic-ai/sdk";

const memory = new LokulMem({
  storeName: "anthropic-user",
  contextWindowSize: 200000,
});
await memory.init();

// ── Ollama ────────────────────────────────────────────────────────────────
const memory = new LokulMem({
  storeName: "ollama-user",
  contextWindowSize: 8192,
});
await memory.init();

9. Storage Schema

Dexie.js Object Stores

memories

interface Memory {
  id: string; // UUID v4
  content: string; // Natural language fact sentence
  types: string[]; // Multiple types per memory

  // Embedding
  embedding: number[]; // 384-dim float32

  // Cluster assignment
  clusterId: string | null;

  // Strength, confidence, importance
  baseStrength: number; // 1.0 at creation → max 3.0 via reinforcement
  confidence: "high" | "medium" | "low";
  extractionScore: number; // E(s) at extraction time
  emotionalValence: number; // -1.0 to 1.0
  mentionCount: number; // times retrieved and reinforced

  // Pinning
  pinned: boolean; // default false; pinned = λ 0, retrieval priority

  // Temporal validity
  validFrom: number; // Unix ms — when fact became true
  validTo: number | null; // null = still valid

  // Lifecycle timestamps
  createdAt: number;
  lastAccessedAt: number;
  lastReinforced: number;

  // Status
  status: "active" | "faded" | "superseded" | "archived";
  supersededBy: string | null;
  supersededAt: number | null;
  supersessionType: "conflict" | "temporal" | null;

  // Source provenance
  sourceSession: string; // session ID where first extracted
  sourceTurn: number; // turn index in that session
  sourceConversationIds: string[]; // ALL conversation IDs that contributed to this memory

  // Extracted entities
  entities: string[]; // named entities detected during extraction
}

Dexie indexes:

'id, *types, status, clusterId, lastAccessedAt, baseStrength, validFrom, pinned, mentionCount, [status+lastAccessedAt], [status+pinned]'

episodes

interface Episode {
  id: string; // Session UUID
  summary: string; // 2–3 sentence summary
  embedding: number[]; // Mean of session message embeddings
  memoriesExtracted: string[];
  startedAt: number;
  endedAt: number;
  turnCount: number;
  emotionalValence: number;
  topics: string[];
}

edges

Individual records — not a single JSON document. Incremental writes. Scales to tens of thousands.

interface Edge {
  id: string; // `${fromId}__${toId}` — deterministic
  fromId: string;
  toId: string;
  weight: number; // cosine_similarity
  sharedEntities: string[];
  createdAt: number;
  lastUpdated: number;
}

Dexie indexes: 'id, fromId, toId, weight, [fromId+weight]'


clusters

interface Cluster {
  id: string;
  centroid: number[];
  memberIds: string[];
  label: string;
  lastRecomputed: number;
  memberCount: number;
}

Schema Version Chain

const db = new Dexie("lokulmem");
db.version(1).stores({
  memories:
    "id, *types, status, clusterId, lastAccessedAt, baseStrength, validFrom, pinned, mentionCount, [status+lastAccessedAt], [status+pinned]",
  episodes: "id, startedAt",
  edges: "id, fromId, toId, weight, [fromId+weight]",
  clusters: "id",
});
// db.version(2).stores(...).upgrade(tx => ...) — future migrations

Encryption Note

When encryptionPassphrase is provided, memory content strings are encrypted with AES-GCM before writing. Key derivation: PBKDF2, 100,000 iterations, SHA-256. Random 16-byte salt stored in meta store (only unencrypted record). Random 12-byte IV per write, stored alongside ciphertext.

Embeddings are not encrypted. This is deliberate. Encrypting embeddings breaks cosine similarity — the retrieval pipeline would require full decryption on every query, negating all HNSW and brute-force performance. A sophisticated attacker with IndexedDB file-system access can perform embedding inversion to partially recover semantic signal. For a personal memory system on the user's own device, an attacker with file-system access has already compromised far more. This tradeoff is documented, not hidden.

Key UX (v0.3): Derived key not stored by default — user sees empty memory on every reload without passphrase. v0.3 adds keyStorage: 'session' | 'local' | 'none'.


10. Enhanced Features

10.1 Episodic Memory (v0.2)

Session-level compressed summaries stored in episodes. Retrieved in parallel with facts when cosine_similarity(query_embedding, episode.embedding) > 0.75.

[Episode Context — relevant past conversation]
About 3 weeks ago, you had a long conversation about preparing your
Series A pitch deck, focused on financial projections and burn rate framing.
[End Episode Context]

10.2 Emotional Valence Tagging (v0.2)

VADER-style TypeScript lexicon (~3,000 entries, public domain). Sub-millisecond. No LLM call.

|valence| > 0.6base_strength = 1.0 + 0.2 × |valence| at creation.

10.3 Proactive Memory — Push Model (v0.2)

proactive_score(m) = temporal_urgency(m) × memory_strength(m)
temporal_urgency(m) = σ(days_to_event(m) × -2.0)

Memories with proactive_score > 0.7 injected at session start before user says anything.

10.4 Knowledge Graph Edges (v0.2)

Edges created when memories share entities or cosine similarity > 0.70. First-degree neighbours get +0.08 retrieval bonus in second-pass re-ranking. Updated during clustering pass.

10.5 Injection Mode Options (v0.2)

injectionMode: "prepend-system" | // v0.1 default
  "new-system-message" |
  "developer-message" |
  "user-turn-prefix";

10.6 Optional At-Rest Encryption (v0.3)

AES-GCM, PBKDF2, keyStorage option. Full spec in Section 9.


11. Libraries — What to Use, What to Build, What to Skip

Use Directly

Dexie.js — Apache 2.0, actively maintained. Best IndexedDB abstraction. Non-negotiable.

Transformers.js — Apache 2.0, actively maintained. Browser-native WASM model runner. MiniLM-L6-v2 quantised (~22MB weights, cached via Cache API after first download).

Build — Minimal Purpose-Built HNSW

Why not MeMemo: Last commit approximately two years ago. Research code from Georgia Tech. Not maintained as a production library. Depending on it for a critical performance path is a growing liability.

Why not fork MeMemo: Shifts maintenance burden without eliminating it. For 300–400 lines, building is cleaner than inheriting unmaintained code.

What to build: Minimal HNSW in TypeScript, purpose-built for LokulMem's 384-dimensional cosine similarity schema, persisted to IndexedDB.

Reference materials:

  • Malkov & Yashunin 2016 HNSW paper (algorithm)
  • MeMemo source code (study IndexedDB persistence approach, then write your own)
  • A-MEM graph construction (conceptual reference for edge creation)

HNSW parameters: M = 16, ef_construction = 200, ef_search = 50, distance = 1 - cosine_similarity.

Fallback: Brute-force cosine is fully functional for all v0.1 use cases. At 3,000 memories, 384-dim brute-force in a SharedWorker takes ~15–25ms. Do not block v0.1 on HNSW. Ship brute-force. Add HNSW in v0.2.

Study — Don't Use Directly

EntityDB — Study embedding-to-IndexedDB wiring. Don't use: plumbing only.

vector-storage — Study brute-force cosine over IndexedDB. Don't use: plumbing only.

SimpleMem — Study atomic memory unit design and deduplication logic. Don't use: Python server-side.

A-MEM — Study Zettelkasten dynamic linking for the edges store. Don't use: ChromaDB, Python.

railroad-memory — Study developer ergonomics of a conceptually adjacent library. Don't use: no browser-native embeddings, no lifecycle.

Skip Entirely

Mem0, LangMem, MemOS, Zep, MemEngine — Server-first. Architecturally incompatible.


12. Implementation Roadmap

v0.1 — Foundation (6 weeks)

Worker Infrastructure

  • Worker mode detection on main thread: SharedWorker → Worker → main-thread with console warnings
  • navigator.storage.persist() on main thread before worker spawn
  • SharedWorker with MessageChannel port coordination
  • onProgress callback: worker, model, storage, maintenance, ready

Storage

  • Dexie.js schema v1 with all TypeScript interfaces (Section 9)
  • memories, episodes, edges, clusters stores
  • All indexes including compound indexes
  • Schema migration chain

Embedding Engine

  • Transformers.js MiniLM-L6-v2 in worker
  • LRU embedding cache (1,000 entries)
  • Brute-force cosine search for N ≤ 3,000

Extraction Layer

  • Specificity NER: regex + static lookup (~300 lines)
  • Novelty via top-1 retrieval query (not separate O(N) loop)
  • Recurrence tracking within session
  • Composite E score — configurable weights and threshold (tune during alpha)
  • Three-stage contradiction pipeline
  • valid_from / valid_to on every memory record
  • Contradiction event logging and callbacks
  • pinned field with λ = 0 and retrieval priority

Memory Type Classification

  • Seven default types — regex + embedding classifier
  • Extensible custom type system
  • Multiple type tags per memory
  • entities extraction alongside type classification

Retrieval Layer

  • Composite R score — configurable weights (tune during alpha)
  • tokenCounter interface with Math.ceil(length / 4) fallback
  • Token-aware dynamic K
  • prepend-system injection only
  • R > 0.3 floor (tune during alpha)

Debug Output

  • augment() returns LokulMemDebug when debug: true
  • InjectedMemory with score, breakdown, human-readable reason, rank
  • ScoredMemory candidates list with injected: boolean and excludedReason
  • Latency reporting in debug object

Decay and Lifecycle

  • Ebbinghaus decay per-category λ (treat as tunable defaults)
  • Reinforcement on retrieval, mentionCount increment
  • Maintenance sweep at session start
  • k-means clustering in worker (synchronous for v0.1)
  • Cluster retrieval bonus

Public API — manage()

  • list(), get(), getByConversation(), getRecent(), getTop(), getPinned()
  • search(), semanticSearch()
  • getTimeline(), getGrouped(), getInjectionPreview()
  • update(), pin(), unpin(), archive(), unarchive(), delete(), clear()
  • deleteMany(), archiveMany(), pinMany(), unpinMany()
  • stats() with full MemoryStats interface
  • export() JSON, exportToMarkdown(), import()
  • onMemoryAdded(), onMemoryUpdated(), onMemoryDeleted(), onMemoryFaded(), onStatsChanged(), onContradictionDetected() — all return unsubscribe functions

Integration

  • Replace Lokul's internal memory system with LokulMem v0.1
  • Integration examples: WebLLM, OpenAI, Ollama, Anthropic
  • Full TypeScript types exported

v0.2 — Intelligence (4 weeks after v0.1)

  • Minimal HNSW TypeScript implementation (~350 lines), IndexedDB-persisted
  • HNSW lazy loading above hnswThreshold
  • k-means moved to requestIdleCallback + chunked async loops
  • Knowledge graph edges: creation, retrieval bonus
  • Emotional valence tagging: VADER-style TypeScript lexicon
  • Episodic memory: session summarisation, episode store, retrieval path
  • Proactive memory: temporal urgency scoring, push injection at session start
  • injectionMode option: prepend-system, new-system-message, developer-message, user-turn-prefix
  • getRelated() in manage() (graph neighbours)
  • getLineage() in manage()
  • React hook: useMem()
  • Retention policy: setRetentionPolicy() with max age, max facts, min confidence
  • findSimilarFacts() for compaction (safe O(N log N) via HNSW)

v0.3 — Security and Polish (4 weeks after v0.2)

  • At-rest encryption: AES-GCM, PBKDF2, keyStorage: 'session' | 'local' | 'none'
  • Svelte store adapter
  • Full benchmark suite
  • Documentation site
  • npm publish as @lokul/lokulmem — MIT licence

v1.0 — Public Release

  • 100% TypeScript coverage, no any in public API
  • Tree-shakeable ESM bundle
  • Semantic versioning policy
  • Community contribution guidelines

13. Success Metrics

Performance

MetricTargetNotes
Library bundle (excl. model weights)< 2MB gzipped
Model weights — MiniLM quantised~22MBDownloaded once, Cache API
Worker init — after model cached< 800ms
Embedding — LRU hit< 2ms
Embedding — warm cache miss< 10ms
Extraction per turn< 50msIncluding top-1 novelty
Retrieval — brute force (N ≤ 3,000)< 30ms
Retrieval — HNSW (N > 3,000)< 20ms
IndexedDB write per memory< 5ms
Debug output overhead< 5msAdditional cost when debug: true
Storage — 1,000 memories< 25MB
Storage — 10,000 memories< 200MB

Quality

MetricTarget
Extraction precision> 85%
Extraction recall> 75%
Retrieval relevance — top-3 rated relevant> 80%
True conflict detection> 90%
False positive contradictions< 5%
Temporal coexistence correct classification> 85%

Developer Experience

MetricTarget
Integration time< 10 minutes
Lines for basic usage3 (augment + learn)
Required config for sensible defaultsZero
TypeScript coverage — public API100%
Time to first memory retrieval from npm install< 2 minutes

14. Appendix — Mathematical Reference

Cosine Similarity

cosine_similarity(a, b) = (a · b) / (||a|| × ||b||)

384-dimensional MiniLM embeddings. Practical range [0, 1] for natural language.

Ebbinghaus Forgetting Curve

strength(t) = base_strength × e^(-λ × Δt_hours)
t_half = ln(2) / λ ≈ 0.693 / λ

Pinned memories: λ = 0 → strength(t) = base_strength (no decay).

Composite Extraction Score

E(s) = 0.35 × (1 - top1_similarity(s))
     + 0.45 × min(1.0, Σ specificity_weights)
     + 0.20 × min(1.0, 0.2 + 0.25 × occurrences(s))

Threshold default: 0.45. Treat as empirically tunable. Expect to adjust during alpha.

Composite Retrieval Score

R(m, q) =
  0.40 × cosine_similarity(embed(m.content), embed(q))
  + 0.20 × e^(-0.005 × hours_since_created(m))
  + 0.25 × (min(base_strength(m), 3.0) / 3.0) × e^(-λ_m × Δt_hours)
  + 0.15 × max(cosine_similarity(embed(m.content), embed(t)) for t in last_3_turns)

Floor: R > 0.3. Treat as empirically tunable. All components [0, 1]. Composite R ∈ [0, 1].

For pinned memories, w3 component = 1.0 regardless of actual strength value.

K-means (Lloyd's Algorithm)

K = max(3, floor(√(total_active_memories / 2)))

  1. Initialise K centroids from existing embeddings
  2. Assign each memory to nearest centroid by cosine distance
  3. Recompute centroid as mean of assigned embeddings
  4. Repeat until stable or 50 iterations

HNSW Parameters

M = 16, ef_construction = 200, ef_search = 50, distance = 1 - cosine_similarity(a, b)

Temporal Urgency

temporal_urgency(m) = σ(days_to_event(m) × -2.0)
σ(x) = 1 / (1 + e^(-x))

0.5 at event date. Approaches 1.0 as deadline approaches. Zero for memories with no temporal reference detected.

Emotional Valence Boost

base_strength_at_creation = 1.0 + 0.2 × min(1.0, |valence(s)|)

Applied when |valence(s)| > 0.6. Maximum: 1.2 at |valence| = 1.0.


Note on Numerical Constants

Every numerical constant in this document — extraction threshold (0.45), contradiction similarity threshold (0.80), temporal coexistence threshold (0.75 for episodes), retrieval floor (0.30), Ebbinghaus λ values, scoring weights — must be treated as an empirically tunable starting point, not a fixed specification.

LLM embedding spaces are noisy. Real user dialogue is messier than synthetic test data. The constants above are informed by the architecture and prior work in agent memory systems, but they will require adjustment once real users interact with real conversations.

Build every constant as a named, documented, easily adjustable variable. Track which constants were changed and why during alpha. The final v1.0 defaults should be based on observed performance, not the numbers in this document.


LokulMem is built by the Lokul team. It powers the memory system inside Lokul and is released as a standalone open source library under the MIT licence.

This document supersedes all previous versions. v0.4 is the approved pre-build specification.

What's inside

14 sections covering architecture, three algorithmic layers, API, storage schema, roadmap, and mathematical reference

Change this for your project

  • Replace @lokul/lokulmem with your own package name
  • Replace lokulmem_default with your own default store name
  • Replace Lokul Core Team with your own author name
  • Replace February 2026 with your own last-updated date

Where it goes

Keep it in your repository where the agent or team that needs it will read it.

Worth borrowing

  • Three-stage contradiction detection with temporal markers and typed attribute conflicts preserves history instead of silently overwriting
  • Composite relevance score combining semantic similarity, recency, memory strength, and conversation continuity for retrieval ranking
  • Explicit non-goals section prevents scope creep and sets clear boundaries for contributors

Related Documents