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
Tokenization
Content is broken into tokens (words) and indexed
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
Match Query
Search query is tokenized and matched against index
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.
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
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
Decay Old Memories
Reduce importance by 5% for entries not accessed in 7+ days
Prune Low-Value
Delete entries with:
importance < 0.1
accessCount == 0
Older than 30 days
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 [];
}
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
Comparison with Vector Search
Tiny Claw Memory Vector Embeddings Dependencies Zero (built-in SQLite FTS5) OpenAI API or local embedding model Cost Free $0.0001/token (OpenAI) or GPU compute Speed <1ms search 50β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