Skip to main content

Memory System

Tiny Claw’s memory system is a 3-layer adaptive architecture that learns what to remember and what to forget. It combines episodic memory, full-text search, and temporal decay to provide human-like memory behavior without external API dependencies.

Architecture Overview

Layer 1: Episodic

Timestamped events with outcomes and importance scoring

Layer 2: Semantic

FTS5 full-text search with BM25 ranking (built into bun:sqlite)

Layer 3: Temporal

Ebbinghaus forgetting curve + access frequency strengthening

Why This Approach?

Zero external dependencies. Beats vector search (OpenAI embeddings) by combining FTS5 + temporal awareness + importance scoring β€” all running 100% local, offline-capable.
Traditional AI agents use flat conversation history that grows unbounded. Tiny Claw’s memory system:
  • Learns importance β€” not all memories are equal
  • Decays over time β€” old memories fade unless reinforced
  • Strengthens with access β€” frequently accessed memories become stronger
  • Prunes contradictions β€” consolidates conflicting information
  • Merges duplicates β€” reduces redundancy

Episodic Memory

Episodic memories are timestamped events with structured metadata.

Event Types

packages/types/src/index.ts
export type EpisodicEventType =
  | 'task_completed'
  | 'preference_learned'
  | 'correction'
  | 'delegation_result'
  | 'fact_stored';

Record Structure

packages/types/src/index.ts
export interface EpisodicRecord {
  id: string;
  userId: string;
  eventType: EpisodicEventType;
  content: string;
  outcome: string | null;
  importance: number;          // 0.0–1.0
  accessCount: number;         // How many times accessed
  createdAt: number;
  lastAccessedAt: number;
}

Default Importance Scores

packages/memory/src/index.ts
const DEFAULT_IMPORTANCE: Record<EpisodicEventType, number> = {
  correction: 0.9,            // User corrections are critical
  preference_learned: 0.8,    // Personal preferences are important
  fact_stored: 0.6,           // Facts are moderately important
  task_completed: 0.5,        // Task history is useful
  delegation_result: 0.5,     // Sub-agent outcomes
};

Recording Events

const memoryId = memory.recordEvent(userId, {
  type: 'preference_learned',
  content: 'User prefers TypeScript over JavaScript',
  outcome: 'Will prioritize TypeScript in code examples',
  importance: 0.85, // Optional override
});

Semantic Search (FTS5)

FTS5 (Full-Text Search 5) is SQLite’s built-in search engine using BM25 ranking.

How It Works

1

Tokenization

Content is broken into tokens (words) and indexed
2

BM25 Ranking

Matches are scored using BM25 (Best Match 25) algorithm:
  • Term frequency (TF) β€” how often term appears in document
  • Inverse document frequency (IDF) β€” how rare term is across all documents
  • Document length normalization
3

Match Query

Search query is tokenized and matched against index
4

Rank Results

Results sorted by BM25 score (more negative = better match)

Normalization

packages/memory/src/index.ts
function normalizeFTSRank(rank: number, maxAbsRank: number): number {
  if (maxAbsRank === 0) return 0;
  // rank is negative in FTS5 β€” more negative = better match
  return Math.min(1.0, Math.abs(rank) / maxAbsRank);
}

Query Sanitization

packages/memory/src/index.ts
function sanitizeFTSQuery(query: string): string {
  return query
    .toLowerCase()
    .replace(/[^a-z0-9\s]/g, ' ')
    .split(/\s+/)
    .filter((w) => w.length > 1)
    .join(' OR ');
}

Temporal Decay

Memories fade over time using the Ebbinghaus forgetting curve, but are strengthened by access frequency.

Formula

packages/memory/src/index.ts
function computeTemporalScore(
  lastAccessedAt: number,
  accessCount: number,
  now: number
): number {
  const daysSinceAccess = Math.max(0, (now - lastAccessedAt) / MS_PER_DAY);
  const decay = Math.exp(-0.05 * daysSinceAccess);
  const accessBonus = 1 + 0.02 * accessCount;
  return Math.min(1.0, decay * accessBonus);
}
Explanation:
  • e^(-0.05 * days) β€” exponential decay over time
  • 1 + 0.02 * accessCount β€” 2% bonus per access
  • Clamped to 1.0 maximum

Decay Curve

Importance
1.0 |β€’
    |  β€’
0.8 |    β€’
    |      β€’
0.6 |        β€’
    |          β€’
0.4 |            β€’
    |               β€’
0.2 |                  β€’
    |                     β€’
0.0 +----------------------β†’ Days
    0   5   10  15  20  25
With access bonus (10 accesses):
Importance
1.2 |β€’β€’β€’β€’β€’
    |      β€’
1.0 |        β€’
    |          β€’
0.8 |            β€’
    |              β€’
0.6 |                β€’
    |                  β€’
0.4 |                    β€’
    |                      β€’
0.2 |                        β€’
0.0 +---------------------------β†’ Days
    0   5   10  15  20  25  30

Combined Scoring

Relevance Formula

packages/memory/src/index.ts
const relevanceScore = 
  ftsScore * 0.4 +           // Semantic match (40%)
  temporalScore * 0.3 +      // Recency/access (30%)
  importanceScore * 0.3;     // Intrinsic importance (30%)
Why these weights?
  • 40% semantic β€” content relevance is most important
  • 30% temporal β€” recent and frequently accessed memories matter
  • 30% importance β€” event type influences retention

Search Implementation

packages/memory/src/index.ts
search(userId: string, query: string, limit = 20): MemorySearchResult[] {
  const now = Date.now();
  const results: MemorySearchResult[] = [];

  // FTS5 search
  const ftsQuery = sanitizeFTSQuery(query);
  const ftsResults = db.searchEpisodicFTS(ftsQuery, userId, 50);
  const maxAbsRank = Math.max(...ftsResults.map((r) => Math.abs(r.rank)));

  for (const ftsRow of ftsResults) {
    const record = db.getEpisodicEvent(ftsRow.id);
    if (!record) continue;

    const ftsScore = normalizeFTSRank(ftsRow.rank, maxAbsRank);
    const temporalScore = computeTemporalScore(
      record.lastAccessedAt,
      record.accessCount,
      now
    );
    const importanceScore = record.importance;

    const relevanceScore = 
      ftsScore * 0.4 + temporalScore * 0.3 + importanceScore * 0.3;

    results.push({
      id: record.id,
      content: record.content + (record.outcome ? ` β†’ ${record.outcome}` : ''),
      relevanceScore,
      source: 'episodic',
    });
  }

  // Sort by relevance and limit
  results.sort((a, b) => b.relevanceScore - a.relevanceScore);
  return results.slice(0, limit);
}

Memory Consolidation

Periodic cleanup and optimization of the memory store.

Consolidation Steps

1

Decay Old Memories

Reduce importance by 5% for entries not accessed in 7+ days
2

Prune Low-Value

Delete entries with:
  • importance < 0.1
  • accessCount == 0
  • Older than 30 days
3

Merge Duplicates

Find highly similar entries (>80% token overlap) and merge:
  • Keep newer entry
  • Bump importance by 20% of older entry’s importance
  • Sum access counts
  • Delete older entry

Implementation

packages/memory/src/index.ts
consolidate(userId: string): { merged: number; pruned: number; decayed: number } {
  let merged = 0, pruned = 0, decayed = 0;

  // Step 1: Decay
  decayed = db.decayEpisodicImportance(userId, 7, 0.95);

  // Step 2: Prune
  pruned = db.pruneEpisodicEvents(userId, 0.1, 0, 30 * MS_PER_DAY);

  // Step 3: Merge duplicates
  const events = db.getEpisodicEvents(userId, 200);
  const toDelete: string[] = [];

  for (let i = 0; i < events.length; i++) {
    if (toDelete.includes(events[i].id)) continue;
    
    for (let j = i + 1; j < events.length; j++) {
      if (toDelete.includes(events[j].id)) continue;
      
      if (events[i].eventType === events[j].eventType &&
          contentSimilarity(events[i].content, events[j].content) > 0.8) {
        
        const newer = events[i];
        const older = events[j];
        
        db.updateEpisodicEvent(newer.id, {
          importance: Math.min(1.0, newer.importance + older.importance * 0.2),
          accessCount: newer.accessCount + older.accessCount,
        });
        
        toDelete.push(older.id);
        merged++;
      }
    }
  }

  if (toDelete.length > 0) {
    db.deleteEpisodicEvents(toDelete);
  }

  return { merged, pruned, decayed };
}

Context Injection

Memories are injected into the agent’s system prompt for context-aware responses.

Context Builder

packages/memory/src/index.ts
getContextForAgent(userId: string, query?: string): string {
  const sections: string[] = [];

  // Query-specific memories
  if (query) {
    const results = this.search(userId, query, 10);
    if (results.length > 0) {
      sections.push('\n## Relevant Memories');
      for (const result of results) {
        const sourceLabel = result.source === 'episodic' ? 'πŸ“' : 'πŸ”‘';
        sections.push(`${sourceLabel} ${result.content}`);
      }
    }
  }

  // High-importance recent memories
  const recentEvents = db.getEpisodicEvents(userId, 5);
  const highImportance = recentEvents.filter((e) => e.importance >= 0.7);

  if (highImportance.length > 0) {
    sections.push('\n## Important Context');
    for (const event of highImportance) {
      const label = 
        event.eventType === 'correction' ? '⚠️ Correction' :
        event.eventType === 'preference_learned' ? '⭐ Preference' :
        'πŸ“Œ Note';
      sections.push(`${label}: ${event.content}`);
    }
  }

  return sections.length > 0 ? sections.join('\n') : '';
}

Example Context

## Relevant Memories
πŸ“ User prefers TypeScript over JavaScript β†’ Will prioritize TypeScript in code examples
πŸ“ User uses Bun for all projects β†’ Suggest Bun-specific solutions when applicable
πŸ”‘ favorite_editor: VSCode

## Important Context
⚠️ Correction: Never use `any` type in TypeScript β€” always infer or explicitly type
⭐ Preference: Prefers functional programming style over OOP
πŸ“Œ Note: User is building an AI agent framework called Tiny Claw

Memory Reinforcement

When a memory is accessed, it’s reinforced:
packages/memory/src/index.ts
reinforce(memoryId: string): void {
  const record = db.getEpisodicEvent(memoryId);
  if (!record) return;

  db.updateEpisodicEvent(memoryId, {
    accessCount: record.accessCount + 1,
    lastAccessedAt: Date.now(),
  });
}
Effect:
  • Increases accessCount β†’ higher temporal score
  • Updates lastAccessedAt β†’ resets decay timer

Legacy Key-Value Memory

Backward compatible with simple key-value storage:
// Store
db.saveMemory(userId, 'favorite_color', 'blue');

// Retrieve
const memory = db.getMemory(userId);
console.log(memory['favorite_color']); // 'blue'
Key-value entries are included in search results with lower weight (50%).

Memory Engine Interface

packages/types/src/index.ts
export interface MemoryEngine {
  recordEvent(userId: string, event: {...}): string;
  search(userId: string, query: string, limit?: number): MemorySearchResult[];
  consolidate(userId: string): { merged: number; pruned: number; decayed: number };
  getContextForAgent(userId: string, query?: string): string;
  reinforce(memoryId: string): void;
  getEvent(id: string): EpisodicRecord | null;
  getEvents(userId: string, limit?: number): EpisodicRecord[];
}

Performance

FTS5 Index

Sub-millisecond full-text search on 10k+ memories

SQLite Storage

Single-file database, zero external dependencies

Offline-Capable

No API calls required for memory operations

Compact

~50KB compressed, pure TypeScript
Tiny Claw MemoryVector Embeddings
DependenciesZero (built-in SQLite FTS5)OpenAI API or local embedding model
CostFree$0.0001/token (OpenAI) or GPU compute
Speed<1ms search50–200ms API call or 10–50ms local
Offlineβœ… Fully offline❌ Requires API or local model
Temporal Awarenessβœ… Decay + reinforcement❌ Static vectors
Importance Scoringβœ… Event-type based❌ Uniform weight

Next: Heartware

Learn about the personality system