Spaces:
Running
Running
// ===== KIMI INTELLIGENT MEMORY SYSTEM ===== | |
class KimiMemorySystem { | |
constructor(database) { | |
this.db = database; | |
this.memoryEnabled = true; | |
this.maxMemoryEntries = 100; | |
// Performance optimization: keyword cache with LRU eviction | |
this.keywordCache = new Map(); // keyword_language -> boolean (is common) | |
this.keywordCacheSize = 1000; // Limit memory usage | |
this.keywordCacheHits = 0; | |
this.keywordCacheMisses = 0; | |
// Performance monitoring | |
this.queryStats = { | |
extractionTime: [], | |
addMemoryTime: [], | |
retrievalTime: [] | |
}; | |
// Centralized configuration for all thresholds and magic numbers | |
this.config = { | |
// Content validation thresholds | |
minContentLength: 2, | |
longContentThreshold: 24, | |
titleWordCount: { | |
preferred: 3, | |
min: 1, | |
max: 5 | |
}, | |
// Similarity and confidence thresholds | |
similarity: { | |
personal: 0.6, // Names can vary more (Jean vs Jean-Pierre) | |
preferences: 0.7, // Preferences can be expressed differently | |
default: 0.8, // General similarity threshold | |
veryHigh: 0.9, // For boost_confidence strategy | |
update: 0.3 // Lower threshold for memory updates | |
}, | |
// Confidence scoring | |
confidence: { | |
base: 0.6, | |
explicitRequest: 1.0, | |
naturalExpression: 0.7, | |
bonusForLongContent: 0.1, | |
bonusForExplicitStatement: 0.3, | |
penaltyForUncertainty: 0.2, | |
min: 0.1, | |
max: 1.0 | |
}, | |
// Memory management | |
cleanup: { | |
maxEntries: 100, | |
ttlDays: 365, | |
batchSize: 100, | |
touchMinutes: 60 | |
}, | |
// Performance settings | |
cache: { | |
keywordCacheSize: 1000, | |
statHistorySize: 100 | |
}, | |
// Scoring weights for importance calculation | |
importance: { | |
categoryWeights: { | |
important: 1.0, | |
personal: 0.9, | |
relationships: 0.85, | |
goals: 0.75, | |
experiences: 0.65, | |
preferences: 0.6, | |
activities: 0.5 | |
}, | |
bonuses: { | |
relationshipMilestone: 0.15, | |
boundaries: 0.15, | |
strongEmotion: 0.05, | |
futureReference: 0.05, | |
longContent: 0.05, | |
highConfidence: 0.05 | |
} | |
}, | |
// Relevance calculation weights | |
relevance: { | |
contentSimilarity: 0.35, | |
keywordOverlap: 0.25, | |
categoryRelevance: 0.1, | |
recencyBonus: 0.1, | |
confidenceBonus: 0.05, | |
importanceBonus: 0.05, | |
recentDaysThreshold: 30 | |
} | |
}; | |
this.memoryCategories = { | |
personal: "Personal Information", | |
preferences: "Likes & Dislikes", | |
relationships: "Relationships & People", | |
activities: "Activities & Hobbies", | |
goals: "Goals & Aspirations", | |
experiences: "Shared Experiences", | |
important: "Important Events" | |
}; | |
// Patterns for automatic memory extraction (multilingual) | |
this.extractionPatterns = { | |
personal: [ | |
// English patterns | |
/(?:my name is|i'm called|call me|i am) (\w+)/i, | |
/(?:i am|i'm) (\d+) years? old/i, | |
/(?:i live in|i'm from|from) ([^,.!?]+)/i, | |
/(?:i work as|my job is|i'm a) ([^,.!?]+)/i, | |
// French patterns | |
/(?:je m'appelle|mon nom est|je suis|je me prénomme|je me nomme) ([^,.!?]+)/i, | |
/(?:j'ai) (\d+) ans?/i, | |
/(?:j'habite à|je vis à|je viens de) ([^,.!?]+)/i, | |
/(?:je travaille comme|mon travail est|je suis) ([^,.!?]+)/i, | |
// Spanish patterns | |
/(?:me llamo|mi nombre es|soy) ([^,.!?]+)/i, | |
/(?:tengo) (\d+) años?/i, | |
/(?:vivo en|soy de) ([^,.!?]+)/i, | |
/(?:trabajo como|mi trabajo es|soy) ([^,.!?]+)/i, | |
// Italian patterns | |
/(?:mi chiamo|il mio nome è|sono) ([^,.!?]+)/i, | |
/(?:ho) (\d+) anni?/i, | |
/(?:abito a|vivo a|sono di) ([^,.!?]+)/i, | |
/(?:lavoro come|il mio lavoro è|sono) ([^,.!?]+)/i, | |
// German patterns | |
/(?:ich heiße|mein name ist|ich bin) ([^,.!?]+)/i, | |
/(?:ich bin) (\d+) jahre? alt/i, | |
/(?:ich wohne in|ich lebe in|ich komme aus) ([^,.!?]+)/i, | |
/(?:ich arbeite als|mein beruf ist|ich bin) ([^,.!?]+)/i, | |
// Japanese patterns | |
/私の名前は([^。!?!?、,.]+)[ですだ]?/i, | |
/私は([^。!?!?、,.]+)です/i, | |
/([^、。!?!?,.]+)と申します/i, | |
/([^、。!?!?,.]+)といいます/i, | |
// Chinese patterns | |
/我叫([^,。!?!?,.]+)/i, | |
/我的名字是([^,。!?!?,.]+)/i, | |
/叫我([^,。!?!?,.]+)/i | |
], | |
preferences: [ | |
// English patterns | |
/(?:i love|i like|i enjoy|i prefer) ([^,.!?]+)/i, | |
/(?:i hate|i dislike|i don't like) ([^,.!?]+)/i, | |
/(?:my favorite|i really like) ([^,.!?]+)/i, | |
// French patterns | |
/(?:j'aime|j'adore|je préfère) ([^,.!?]+)/i, | |
/(?:je déteste|je n'aime pas) ([^,.!?]+)/i, | |
/(?:mon préféré|ma préférée) (?:est|sont) ([^,.!?]+)/i, | |
// Explicit memory requests | |
/(?:ajoute? (?:au|à la) (?:système? )?(?:de )?mémoire|retiens?|mémorise?) (?:que )?(.+)/i, | |
/(?:add to memory|remember|memorize) (?:that )?(.+)/i | |
], | |
relationships: [ | |
// English patterns | |
/(?:my (?:wife|husband|girlfriend|boyfriend|partner)) (?:is|named?) ([^,.!?]+)/i, | |
/(?:my (?:mother|father|sister|brother|friend)) ([^,.!?]+)/i, | |
// French patterns | |
/(?:ma (?:femme|copine|partenaire)|mon (?:mari|copain|partenaire)) (?:s'appelle|est) ([^,.!?]+)/i, | |
/(?:ma (?:mère|sœur)|mon (?:père|frère|ami)) (?:s'appelle|est) ([^,.!?]+)/i, | |
// Spanish patterns | |
/(?:mi (?:esposa|esposo|novia|novio|pareja)) (?:es|se llama) ([^,.!?]+)/i, | |
/(?:mi (?:madre|padre|hermana|hermano|amigo|amiga)) (?:es|se llama) ([^,.!?]+)/i, | |
// Italian patterns | |
/(?:la mia (?:moglie|fidanzata|compagna)|il mio (?:marito|fidanzato|compagno)) (?:è|si chiama) ([^,.!?]+)/i, | |
/(?:mia (?:madre|sorella)|mio (?:padre|fratello|amico)) (?:è|si chiama) ([^,.!?]+)/i, | |
// German patterns | |
/(?:meine (?:frau|freundin|partnerin)|mein (?:mann|freund|partner)) (?:ist|heißt) ([^,.!?]+)/i, | |
/(?:meine (?:mutter|schwester)|mein (?:vater|bruder|freund)) (?:ist|heißt) ([^,.!?]+)/i, | |
// Japanese patterns | |
/(?:私の(?:妻|夫|彼女|彼氏|パートナー))は([^。!?!?、,.]+)(?:です|といいます)/i, | |
/(?:私の(?:母|父|姉|妹|兄|弟|友達))は([^。!?!?、,.]+)(?:です|といいます)/i, | |
// Chinese patterns | |
/(?:我的(?:妻子|丈夫|女朋友|男朋友|伴侣))叫([^,。!?!?,.]+)/i, | |
/(?:我的(?:妈妈|父亲|姐姐|妹妹|哥哥|弟弟|朋友))叫([^,。!?!?,.]+)/i | |
], | |
activities: [ | |
// English patterns | |
/(?:i play|i do|i practice) ([^,.!?]+)/i, | |
/(?:my hobby is|i hobby) ([^,.!?]+)/i, | |
// French patterns | |
/(?:je joue|je fais|je pratique) ([^,.!?]+)/i, | |
/(?:mon passe-temps|mon hobby) (?:est|c'est) ([^,.!?]+)/i, | |
// Spanish patterns | |
/(?:juego|hago|practico) ([^,.!?]+)/i, | |
/(?:mi pasatiempo|mi hobby) (?:es) ([^,.!?]+)/i, | |
// Italian patterns | |
/(?:gioco|faccio|pratico) ([^,.!?]+)/i, | |
/(?:il mio passatempo|il mio hobby) (?:è) ([^,.!?]+)/i, | |
// German patterns | |
/(?:ich spiele|ich mache|ich übe) ([^,.!?]+)/i, | |
/(?:mein hobby ist) ([^,.!?]+)/i, | |
// Japanese patterns | |
/(?:私は)?(?:[^、。!?!?,.]+)が趣味です/i, | |
/趣味は([^。!?!?、,.]+)です/i, | |
// Chinese patterns | |
/(?:我玩|我做|我练习)([^,。!?!?,.]+)/i, | |
/(?:我的爱好是)([^,。!?!?,.]+)/i | |
], | |
goals: [ | |
// English patterns | |
/(?:i want to|i plan to|my goal is) ([^,.!?]+)/i, | |
/(?:i'm learning|i study) ([^,.!?]+)/i, | |
// French patterns | |
/(?:je veux|je vais|mon objectif est) ([^,.!?]+)/i, | |
/(?:j'apprends|j'étudie) ([^,.!?]+)/i, | |
// Spanish patterns | |
/(?:quiero|voy a|mi objetivo es) ([^,.!?]+)/i, | |
/(?:estoy aprendiendo|estudio) ([^,.!?]+)/i, | |
// Italian patterns | |
/(?:voglio|andrò a|il mio obiettivo è) ([^,.!?]+)/i, | |
/(?:sto imparando|studio) ([^,.!?]+)/i, | |
// German patterns | |
/(?:ich möchte|ich will|mein ziel ist) ([^,.!?]+)/i, | |
/(?:ich lerne|ich studiere) ([^,.!?]+)/i, | |
// Japanese patterns | |
/(?:私は)?(?:[^、。!?!?,.]+)したい/i, | |
/(?:学んでいる|勉強している) ([^。!?!?、,.]+)/i, | |
// Chinese patterns | |
/(?:我想|我要|我的目标是)([^,。!?!?,.]+)/i, | |
/(?:我在学习|我学习)([^,。!?!?,.]+)/i | |
], | |
experiences: [ | |
// English patterns | |
/we went to ([^,.!?]+)/i, | |
/we met (?:at|on|in) ([^,.!?]+)/i, | |
/our (?:first date|first kiss|trip|vacation) (?:was|was at|was on|was in|was to) ([^,.!?]+)/i, | |
/our anniversary (?:is|falls on|will be) ([^,.!?]+)/i, | |
/we moved in (?:together )?(?:on|in)?\s*([^,.!?]+)/i, | |
// French patterns | |
/on s'est rencontr[ée]s? (?:à|au|en|le) ([^,.!?]+)/i, | |
/on est all[ée]s? à ([^,.!?]+)/i, | |
/notre (?:premier rendez-vous|première sortie) (?:était|c'était) ([^,.!?]+)/i, | |
/notre anniversaire (?:est|c'est) ([^,.!?]+)/i, | |
/on a emménagé (?:ensemble\s*)?(?:le|en|à)\s*([^,.!?]+)/i, | |
// Spanish patterns | |
/nos conocimos (?:en|el|la) ([^,.!?]+)/i, | |
/fuimos a ([^,.!?]+)/i, | |
/nuestra (?:primera cita|primera salida) (?:fue|era) ([^,.!?]+)/i, | |
/nuestro aniversario (?:es|cae en|será) ([^,.!?]+)/i, | |
/nos mudamos (?:juntos\s*)?(?:el|en|a)\s*([^,.!?]+)/i, | |
// Italian patterns | |
/ci siamo conosciuti (?:a|al|in|il) ([^,.!?]+)/i, | |
/siamo andati a ([^,.!?]+)/i, | |
/il nostro (?:primo appuntamento|primo bacio|viaggio) (?:era|è stato) ([^,.!?]+)/i, | |
/il nostro anniversario (?:è|cade il|sarà) ([^,.!?]+)/i, | |
/ci siamo trasferiti (?:insieme\s*)?(?:il|in|a)\s*([^,.!?]+)/i, | |
// German patterns | |
/wir haben uns (?:in|am) ([^,.!?]+) kennengelernt/i, | |
/wir sind (?:nach|zu) ([^,.!?]+) (?:gegangen|gefahren)/i, | |
/unser (?:erstes date|erster kuss|urlaub) (?:war|fand statt) ([^,.!?]+)/i, | |
/unser jahrestag (?:ist|fällt auf|wird sein) ([^,.!?]+)/i, | |
/wir sind (?:zusammen )?eingezogen (?:am|im|in)\s*([^,.!?]+)/i, | |
// Japanese patterns | |
/私たちは([^、。!?!?,.]+)で出会った/i, | |
/一緒に([^、。!?!?,.]+)へ行った/i, | |
/私たちの記念日(?:は)?([^、。!?!?,.]+)/i, | |
/一緒に引っ越した(?:のは)?([^、。!?!?,.]+)/i, | |
// Chinese patterns | |
/我们在([^,。!?!?,.]+)认识/i, | |
/我们去了([^,。!?!?,.]+)/i, | |
/我们的纪念日是([^,。!?!?,.]+)/i, | |
/我们一起搬家(?:是在)?([^,。!?!?,.]+)/i | |
], | |
important: [ | |
// English patterns | |
/it's important (?:to remember|that) (.+)/i, | |
/please remember (.+)/i, | |
// French patterns | |
/c'est important (?:de se souvenir|que) (.+)/i, | |
/merci de te souvenir (.+)/i, | |
// Spanish patterns | |
/es importante (?:recordar|que) (.+)/i, | |
/por favor recuerda (.+)/i, | |
// Italian patterns | |
/è importante (?:ricordare|che) (.+)/i, | |
/per favore ricorda (.+)/i, | |
// German patterns | |
/es ist wichtig (?:zu erinnern|dass) (.+)/i, | |
/bitte erinnere dich an (.+)/i, | |
// Japanese patterns | |
/重要なのは(.+)です/i, | |
/覚えておいてほしいのは(.+)です/i, | |
// Chinese patterns | |
/重要的是(.+)/i, | |
/请记住(.+)/i | |
] | |
}; | |
// Performance optimization: pre-compile regex patterns | |
this.compiledPatterns = {}; | |
this.initializeCompiledPatterns(); | |
} | |
// Pre-compile all regex patterns for better performance | |
initializeCompiledPatterns() { | |
try { | |
for (const [category, patterns] of Object.entries(this.extractionPatterns)) { | |
this.compiledPatterns[category] = patterns.map(pattern => { | |
if (pattern instanceof RegExp) { | |
return pattern; // Already compiled | |
} | |
return new RegExp(pattern.source, pattern.flags); | |
}); | |
} | |
if (window.KIMI_CONFIG?.DEBUG?.MEMORY) { | |
const totalPatterns = Object.values(this.compiledPatterns).reduce((sum, arr) => sum + arr.length, 0); | |
console.log(`🚀 Pre-compiled ${totalPatterns} regex patterns for memory extraction`); | |
} | |
} catch (error) { | |
console.error("Error pre-compiling regex patterns:", error); | |
// Fallback: use original patterns | |
this.compiledPatterns = this.extractionPatterns; | |
} | |
} | |
// Utility method to get consistent creation timestamp | |
getCreationTimestamp(memory) { | |
// Prefer createdAt, fallback to timestamp for backward compatibility | |
return memory.createdAt || memory.timestamp || new Date(); | |
} | |
// Utility method to calculate days since creation | |
getDaysSinceCreation(memory) { | |
const created = new Date(this.getCreationTimestamp(memory)).getTime(); | |
return (Date.now() - created) / (1000 * 60 * 60 * 24); | |
} | |
async init() { | |
if (!this.db) { | |
console.warn("Database not available for memory system"); | |
return; | |
} | |
try { | |
this.memoryEnabled = await this.db.getPreference( | |
"memorySystemEnabled", | |
window.KIMI_CONFIG?.DEFAULTS?.MEMORY_SYSTEM_ENABLED ?? true | |
); | |
this.selectedCharacter = await this.db.getSelectedCharacter(); | |
await this.createMemoryTables(); | |
// Legacy migrations disabled - uncomment if needed for old databases | |
// await this.migrateIncompatibleIDs(); | |
// this.populateKeywordsForAllMemories().catch(e => console.warn("Keyword population failed", e)); | |
} catch (error) { | |
console.error("Memory system initialization error:", error); | |
} | |
} | |
async createMemoryTables() { | |
// Ensure memory tables exist in database | |
if (!this.db.db.memories) { | |
console.warn("Memory table not found in database schema"); | |
return; | |
} | |
} | |
// MEMORY EXTRACTION from conversation | |
async extractMemoryFromText(userText, kimiResponse = null) { | |
if (!this.memoryEnabled || !userText) return []; | |
// Ensure selectedCharacter is initialized | |
if (!this.selectedCharacter) { | |
this.selectedCharacter = this.db ? await this.db.getSelectedCharacter() : "kimi"; | |
} | |
const extractedMemories = []; | |
const text = userText.toLowerCase(); | |
// Memory extraction processing (debug info reduced for performance) | |
// Enhanced extraction with context awareness | |
const existingMemories = await this.getAllMemories(); | |
// First, check for explicit memory requests | |
const explicitRequests = this.detectExplicitMemoryRequests(userText); | |
if (explicitRequests.length > 0) { | |
// Explicit memory requests detected | |
extractedMemories.push(...explicitRequests); | |
} | |
// Extract using pre-compiled patterns for better performance | |
const patternsToUse = this.compiledPatterns || this.extractionPatterns; | |
for (const [category, patterns] of Object.entries(patternsToUse)) { | |
for (const pattern of patterns) { | |
const match = text.match(pattern); | |
if (match && match[1]) { | |
const content = match[1].trim(); | |
// Skip very short or generic content | |
if (content.length < this.config.minContentLength || this.isGenericContent(content)) { | |
continue; | |
} | |
// Check if this is a meaningful update to existing memory | |
const isUpdate = await this.isMemoryUpdate(category, content, existingMemories); | |
const memory = { | |
category: category, | |
type: "auto_extracted", | |
content: content, | |
sourceText: userText, | |
confidence: this.calculateExtractionConfidence(match, userText), | |
createdAt: new Date(), // Use createdAt consistently | |
character: this.selectedCharacter || "kimi", // Fallback protection | |
isUpdate: isUpdate | |
}; | |
// Pattern match detected | |
extractedMemories.push(memory); | |
} | |
} | |
} | |
// Enhanced pattern detection for more natural expressions | |
const enhancedMemories = await this.detectNaturalExpressions(userText, existingMemories); | |
extractedMemories.push(...enhancedMemories); | |
// Save extracted memories with intelligent deduplication | |
const savedMemories = []; | |
for (const memory of extractedMemories) { | |
try { | |
console.log("💾 Saving memory:", memory.content); | |
const saved = await this.addMemory(memory); | |
if (saved) { | |
savedMemories.push(saved); | |
} else { | |
console.warn("⚠️ Memory was not saved (possibly filtered or merged):", memory.content); | |
} | |
} catch (error) { | |
console.error("❌ Failed to save memory:", { | |
content: memory.content, | |
category: memory.category, | |
error: error.message | |
}); | |
// Continue processing other memories even if one fails | |
} | |
} | |
if (savedMemories.length > 0) { | |
if (window.KIMI_CONFIG?.DEBUG?.MEMORY) { | |
console.log(`✅ Successfully extracted and saved ${savedMemories.length} memories`); | |
} | |
} else if (window.KIMI_CONFIG?.DEBUG?.MEMORY) { | |
console.log("📝 No memories extracted from this text"); | |
} | |
return savedMemories; | |
} | |
// Detect explicit memory requests like "ajoute en mémoire que..." | |
detectExplicitMemoryRequests(text) { | |
const memories = []; | |
const lowerText = text.toLowerCase(); | |
// French patterns for explicit memory requests | |
const frenchPatterns = [ | |
/(?:ajoute?s?(?:r)?|retiens?|mémorise?s?|enregistre?s?|sauvegarde?s?)\s+(?:au|à|en|dans)\s+(?:la\s+|le\s+)?(?:système?\s+(?:de\s+)?)?mémoire\s+(?:que\s+)?(.+)/i, | |
/(?:peux-tu|pourrais-tu|veux-tu)?\s*(?:ajouter|retenir|mémoriser|enregistrer|sauvegarder)\s+(?:que\s+)?(.+)\s+(?:en|dans)\s+(?:la\s+|le\s+)?mémoire/i, | |
/(?:je\s+veux\s+que\s+tu\s+)?(?:retienne?s|mémorise?s|ajoute?s)\s+(?:que\s+)?(.+)/i | |
]; | |
// English patterns for explicit memory requests | |
const englishPatterns = [ | |
/(?:add\s+to\s+memory|remember|memorize|save\s+(?:to\s+)?memory)\s+(?:that\s+)?(.+)/i, | |
/(?:can\s+you|could\s+you)?\s*(?:add|remember|memorize|save)\s+(?:that\s+)?(.+)\s+(?:to\s+|in\s+)?memory/i, | |
/(?:i\s+want\s+you\s+to\s+)?(?:remember|memorize|add)\s+(?:that\s+)?(.+)/i | |
]; | |
// Spanish explicit memory requests | |
const spanishPatterns = [ | |
/(?:añade|agrega|recuerda|memoriza|guarda)\s+(?:en|a)\s+(?:la\s+)?memoria\s+(?:que\s+)?(.+)/i, | |
/(?:puedes|podrías)?\s*(?:añadir|agregar|recordar|memorizar|guardar)\s+(?:que\s+)?(.+)\s+(?:en|a)\s+(?:la\s+)?memoria/i, | |
/(?:quiero\s+que\s+)?(?:recuerdes|memorices|añadas)\s+(?:que\s+)?(.+)/i | |
]; | |
// Italian explicit memory requests | |
const italianPatterns = [ | |
/(?:aggiungi|ricorda|memorizza|salva)\s+(?:nella|in)\s+memoria\s+(?:che\s+)?(.+)/i, | |
/(?:puoi|potresti)?\s*(?:aggiungere|ricordare|memorizzare|salvare)\s+(?:che\s+)?(.+)\s+(?:nella|in)\s+memoria/i, | |
/(?:voglio\s+che\s+)?(?:ricordi|memorizzi|aggiunga)\s+(?:che\s+)?(.+)/i | |
]; | |
// German explicit memory requests | |
const germanPatterns = [ | |
/(?:füge|merke|speichere)\s+(?:es\s+)?(?:in|zur)\s+?gedächtnis|speicher\s+(?:dass\s+)?(.+)/i, | |
/(?:kannst\s+du|könntest\s+du)?\s*(?:hinzufügen|merken|speichern)\s+(?:dass\s+)?(.+)\s+(?:in|zum)\s+(?:gedächtnis|speicher)/i, | |
/(?:ich\s+möchte\s+dass\s+du)\s*(?:merkst|speicherst|hinzufügst)\s+(?:dass\s+)?(.+)/i | |
]; | |
// Japanese explicit memory requests | |
const japanesePatterns = [ | |
/記憶に(?:追加|保存|覚えて)(?:して)?(?:ほしい|ください)?(?:、)?(.+)/i, | |
/(?:覚えて|記憶して)(?:ほしい|ください)?(?:、)?(.+)/i | |
]; | |
// Chinese explicit memory requests | |
const chinesePatterns = [ | |
/把(.+)记在(?:记忆|内存|记忆库)里/i, | |
/(?:请)?记住(?:这件事|这个|以下)?(.+)/i, | |
/保存到记忆(?:里|中)(?:的是)?(.+)/i | |
]; | |
const allPatterns = [ | |
...frenchPatterns, | |
...englishPatterns, | |
...spanishPatterns, | |
...italianPatterns, | |
...germanPatterns, | |
...japanesePatterns, | |
...chinesePatterns | |
]; | |
for (const pattern of allPatterns) { | |
const match = lowerText.match(pattern); | |
if (match && match[1]) { | |
const content = match[1].trim(); | |
// Determine category based on content | |
const category = this.categorizeExplicitMemory(content); | |
memories.push({ | |
category: category, | |
type: "explicit_request", | |
content: content, | |
sourceText: text, | |
confidence: 1.0, // High confidence for explicit requests | |
timestamp: new Date(), | |
character: this.selectedCharacter, | |
isUpdate: false | |
}); | |
break; // Only take the first match to avoid duplicates | |
} | |
} | |
return memories; | |
} | |
// Categorize explicit memory based on content analysis | |
categorizeExplicitMemory(content) { | |
const lowerContent = content.toLowerCase(); | |
// Preference indicators | |
if ( | |
lowerContent.includes("j'aime") || | |
lowerContent.includes("i like") || | |
lowerContent.includes("j'adore") || | |
lowerContent.includes("i love") || | |
lowerContent.includes("je préfère") || | |
lowerContent.includes("i prefer") || | |
lowerContent.includes("je déteste") || | |
lowerContent.includes("i hate") | |
) { | |
return "preferences"; | |
} | |
// Personal information indicators | |
if ( | |
lowerContent.includes("je m'appelle") || | |
lowerContent.includes("my name is") || | |
(lowerContent.includes("j'ai") && lowerContent.includes("ans")) || | |
lowerContent.includes("years old") || | |
lowerContent.includes("j'habite") || | |
lowerContent.includes("i live") | |
) { | |
return "personal"; | |
} | |
// Relationship indicators | |
if ( | |
lowerContent.includes("ma femme") || | |
lowerContent.includes("my wife") || | |
lowerContent.includes("mon mari") || | |
lowerContent.includes("my husband") || | |
lowerContent.includes("mon ami") || | |
lowerContent.includes("my friend") || | |
lowerContent.includes("ma famille") || | |
lowerContent.includes("my family") | |
) { | |
return "relationships"; | |
} | |
// Activity indicators | |
if ( | |
lowerContent.includes("je joue") || | |
lowerContent.includes("i play") || | |
lowerContent.includes("je pratique") || | |
lowerContent.includes("i practice") || | |
lowerContent.includes("mon hobby") || | |
lowerContent.includes("my hobby") | |
) { | |
return "activities"; | |
} | |
// Goal indicators | |
if ( | |
lowerContent.includes("je veux") || | |
lowerContent.includes("i want") || | |
lowerContent.includes("mon objectif") || | |
lowerContent.includes("my goal") || | |
lowerContent.includes("j'apprends") || | |
lowerContent.includes("i'm learning") | |
) { | |
return "goals"; | |
} | |
// Default to preferences for most explicit requests | |
return "preferences"; | |
} | |
// Check if content is too generic to be useful | |
isGenericContent(content) { | |
const genericWords = ["yes", "no", "ok", "okay", "sure", "thanks", "hello", "hi", "bye"]; | |
return genericWords.includes(content.toLowerCase()) || content.length < this.config.minContentLength; | |
} | |
// Calculate confidence based on context and pattern strength | |
calculateExtractionConfidence(match, fullText) { | |
let confidence = this.config.confidence.base; // Base confidence from config | |
// Boost confidence for explicit statements | |
const lower = fullText.toLowerCase(); | |
if ( | |
lower.includes("my name is") || | |
lower.includes("i am called") || | |
lower.includes("je m'appelle") || | |
lower.includes("mon nom est") || | |
lower.includes("je me prénomme") || | |
lower.includes("je me nomme") || | |
lower.includes("me llamo") || | |
lower.includes("mi nombre es") || | |
lower.includes("mi chiamo") || | |
lower.includes("il mio nome è") || | |
lower.includes("ich heiße") || | |
lower.includes("mein name ist") || | |
lower.includes("と申します") || | |
lower.includes("私の名前は") || | |
lower.includes("我叫") || | |
lower.includes("我的名字是") | |
) { | |
confidence += this.config.confidence.bonusForExplicitStatement; | |
} | |
// Boost for longer, more specific content | |
if (match[1] && match[1].trim().length > this.config.longContentThreshold) { | |
confidence += this.config.confidence.bonusForLongContent; | |
} | |
// Reduce confidence for uncertain language | |
if (fullText.includes("maybe") || fullText.includes("perhaps") || fullText.includes("might")) { | |
confidence -= this.config.confidence.penaltyForUncertainty; | |
} | |
return Math.min(this.config.confidence.max, Math.max(this.config.confidence.min, confidence)); | |
} | |
// Generate a short title (2-5 words max) from content for auto-extracted memories | |
generateTitleFromContent(content) { | |
if (!content || typeof content !== "string") return ""; | |
// Remove surrounding punctuation and collapse whitespace | |
const cleaned = content | |
.replace(/[\n\r]+/g, " ") | |
.replace(/["'“”‘’–—:;()\[\]{}]+/g, "") | |
.trim(); | |
const words = cleaned.split(/\s+/).filter(Boolean); | |
if (words.length === 0) return ""; | |
// Prefer 3 words when available, minimum 2 when possible, maximum 5 | |
let take; | |
if (words.length >= this.config.titleWordCount.preferred) take = this.config.titleWordCount.preferred; | |
else take = words.length; // 1 or 2 | |
take = Math.min(this.config.titleWordCount.max, Math.max(this.config.titleWordCount.min, take)); | |
const slice = words.slice(0, take); | |
// Capitalize first word for nicer title | |
slice[0] = slice[0].charAt(0).toUpperCase() + slice[0].slice(1); | |
return slice.join(" "); | |
} | |
// Check if this is an update to existing memory rather than new info | |
async isMemoryUpdate(category, content, existingMemories) { | |
const categoryMemories = existingMemories.filter(m => m.category === category); | |
for (const memory of categoryMemories) { | |
const similarity = this.calculateSimilarity(memory.content, content); | |
if (similarity > this.config.similarity.update) { | |
// Lower threshold for updates | |
return true; | |
} | |
} | |
return false; | |
} | |
// Detect natural expressions that patterns might miss | |
async detectNaturalExpressions(text, existingMemories) { | |
const naturalMemories = []; | |
const lowerText = text.toLowerCase(); | |
// Detect name mentions in natural context (multilingual) | |
const namePatterns = [ | |
// English | |
/call me (\w+)/i, | |
/(\w+) here[,.]?/i, | |
/this is (\w+)/i, | |
/(\w+) speaking/i, | |
// French | |
/appelle-?moi (\w+)/i, | |
/on m'appelle (\w+)/i, | |
/c'est (\w+)/i, | |
// Spanish | |
/llámame (\w+)/i, | |
/me llaman (\w+)/i, | |
/soy (\w+)/i, | |
// Italian | |
/chiamami (\w+)/i, | |
/mi chiamano (\w+)/i, | |
/sono (\w+)/i, | |
// German | |
/nenn mich (\w+)/i, | |
/man nennt mich (\w+)/i, | |
/ich bin (\w+)/i, | |
// Japanese | |
/(?:私は)?(\w+)です/i, | |
// Chinese | |
/我是(\w+)/i, | |
/叫我(\w+)/i | |
]; | |
for (const pattern of namePatterns) { | |
const match = lowerText.match(pattern); | |
if (match && match[1] && match[1].length > 1) { | |
const name = match[1].trim(); | |
// Skip if too generic | |
if (!this.isGenericContent(name) && !this.isCommonWord(name)) { | |
naturalMemories.push({ | |
category: "personal", | |
type: "auto_extracted", | |
content: name, | |
sourceText: text, | |
confidence: 0.7, | |
createdAt: new Date(), // Use createdAt consistently | |
character: this.selectedCharacter || "kimi" // Fallback protection | |
}); | |
} | |
} | |
} | |
return naturalMemories; | |
} | |
// Check if word is too common to be a name | |
isCommonWord(word, language = "en") { | |
// Use existing constants if available | |
if (window.KIMI_COMMON_WORDS && window.KIMI_COMMON_WORDS[language]) { | |
return window.KIMI_COMMON_WORDS[language].includes(word.toLowerCase()); | |
} | |
// Fallback to original English list | |
const commonWords = [ | |
"the", | |
"and", | |
"for", | |
"are", | |
"but", | |
"not", | |
"you", | |
"all", | |
"can", | |
"had", | |
"her", | |
"was", | |
"one", | |
"our", | |
"out", | |
"day", | |
"get", | |
"has", | |
"him", | |
"his", | |
"how", | |
"man", | |
"new", | |
"now", | |
"old", | |
"see", | |
"two", | |
"way", | |
"who", | |
"boy", | |
"did", | |
"its", | |
"let", | |
"put", | |
"say", | |
"she", | |
"too", | |
"use" | |
]; | |
return commonWords.includes(word.toLowerCase()); | |
} | |
// MANUAL MEMORY MANAGEMENT | |
async addMemory(memoryData) { | |
if (!this.db || !this.memoryEnabled) return; | |
try { | |
// Check for duplicates with intelligent merging | |
const existing = await this.findSimilarMemory(memoryData); | |
if (existing) { | |
// Intelligent merge strategy | |
return await this.mergeMemories(existing, memoryData); | |
} | |
// Add memory with metadata (let DB auto-generate ID) | |
const now = new Date(); | |
const memory = { | |
category: memoryData.category || "personal", | |
type: memoryData.type || "manual", | |
content: memoryData.content, | |
// precomputed keywords for faster matching and relevance | |
keywords: this.deriveKeywords(memoryData.content), | |
// Title: use provided title or generate for auto_extracted | |
title: | |
memoryData.title && typeof memoryData.title === "string" | |
? memoryData.title | |
: memoryData.type === "auto_extracted" | |
? this.generateTitleFromContent(memoryData.content) | |
: "", | |
sourceText: memoryData.sourceText || "", | |
confidence: memoryData.confidence || 1.0, | |
createdAt: memoryData.createdAt || memoryData.timestamp || now, // Unified timestamp handling | |
character: memoryData.character || this.selectedCharacter || "kimi", // Fallback protection | |
isActive: true, | |
tags: [...new Set([...(memoryData.tags || []), ...this.deriveMemoryTags(memoryData)])], | |
lastModified: now, | |
lastAccess: now, | |
accessCount: 0, | |
importance: this.calculateImportance(memoryData) | |
}; | |
if (this.db.db.memories) { | |
const id = await this.db.db.memories.add(memory); | |
memory.id = id; // Store the auto-generated ID | |
if (window.KIMI_CONFIG?.DEBUG?.MEMORY) { | |
console.log(`Memory added with ID: ${id}`); | |
} | |
} | |
// Cleanup old memories if we exceed limit | |
await this.cleanupOldMemories(); | |
// Notify LLM system to refresh context | |
this.notifyLLMContextUpdate(); | |
return memory; | |
} catch (error) { | |
console.error("Error adding memory:", error); | |
return null; // Return null instead of undefined for clearer error handling | |
} | |
} | |
// Intelligent memory merging | |
async mergeMemories(existingMemory, newMemoryData) { | |
try { | |
// Determine merge strategy based on content and confidence | |
const strategy = this.determineMergeStrategy(existingMemory, newMemoryData); | |
let mergedContent = existingMemory.content; | |
let mergedConfidence = existingMemory.confidence; | |
let mergedTags = [...(existingMemory.tags || [])]; | |
switch (strategy) { | |
case "update_content": | |
// New information is more confident/recent | |
mergedContent = newMemoryData.content; | |
mergedConfidence = Math.max(existingMemory.confidence, newMemoryData.confidence || 0.8); | |
break; | |
case "merge_content": | |
// Combine information intelligently | |
if ( | |
existingMemory.category === "personal" && | |
this.areRelatedNames(existingMemory.content, newMemoryData.content) | |
) { | |
// Handle name variants | |
mergedContent = this.mergeNames(existingMemory.content, newMemoryData.content); | |
} else { | |
// General merge - keep most specific | |
mergedContent = | |
newMemoryData.content.length > existingMemory.content.length | |
? newMemoryData.content | |
: existingMemory.content; | |
} | |
mergedConfidence = (existingMemory.confidence + (newMemoryData.confidence || 0.8)) / 2; | |
break; | |
case "add_variant": | |
// Store as variant/alias | |
mergedTags.push(`alias:${newMemoryData.content}`); | |
break; | |
case "boost_confidence": | |
// Same content, boost confidence | |
mergedConfidence = Math.min(1.0, existingMemory.confidence + 0.1); | |
break; | |
} | |
// Update existing memory | |
const updatedMemory = { | |
...existingMemory, | |
content: mergedContent, | |
confidence: mergedConfidence, | |
tags: [...new Set([...mergedTags, ...this.deriveMemoryTags(newMemoryData)])], // Remove duplicates | |
lastModified: new Date(), | |
accessCount: (existingMemory.accessCount || 0) + 1, | |
importance: Math.max(existingMemory.importance || 0.5, this.calculateImportance(newMemoryData)) | |
}; | |
await this.updateMemory(existingMemory.id, updatedMemory); | |
return updatedMemory; | |
} catch (error) { | |
console.error("Error merging memories:", error); | |
return existingMemory; | |
} | |
} | |
// Simplified memory merge strategy determination | |
determineMergeStrategy(existing, newData) { | |
const similarity = this.calculateSimilarity(existing.content, newData.content); | |
const newConfidence = newData.confidence || this.config.confidence.base; | |
const existingConfidence = existing.confidence || this.config.confidence.base; | |
// Very high similarity (>90%) - boost confidence if new is more confident | |
if (similarity > this.config.similarity.veryHigh) { | |
return newConfidence > existingConfidence ? "boost_confidence" : "merge_content"; | |
} | |
// High similarity (>70%) - decide based on content length and specificity | |
if (similarity > this.config.similarity.preferences) { | |
// If new content is significantly longer (50% more), it's likely more detailed | |
if (newData.content.length > existing.content.length * 1.5) { | |
return "update_content"; | |
} | |
// If existing is longer, merge to preserve information | |
return "merge_content"; | |
} | |
// For personal names, handle as variants if they're related | |
if (existing.category === "personal" && this.areRelatedNames(existing.content, newData.content)) { | |
return "add_variant"; | |
} | |
// Default strategy for moderate similarity | |
return "merge_content"; | |
} | |
// Merge name variants intelligently | |
mergeNames(name1, name2) { | |
// Keep the longest/most formal version as primary | |
if (name1.length > name2.length) { | |
return name1; | |
} else if (name2.length > name1.length) { | |
return name2; | |
} | |
// If same length, keep the first one | |
return name1; | |
} | |
// Calculate importance of memory for prioritization | |
calculateImportance(memoryData) { | |
let importance = 0.5; // Base importance | |
// Category base weights | |
const categoryWeights = { | |
important: 1.0, | |
personal: 0.9, | |
relationships: 0.85, | |
goals: 0.75, | |
experiences: 0.65, | |
preferences: 0.6, | |
activities: 0.5 | |
}; | |
importance = categoryWeights[memoryData.category] || 0.5; | |
const content = (memoryData.content || "").toLowerCase(); | |
const tags = new Set([...(memoryData.tags || []), ...this.deriveMemoryTags(memoryData)]); | |
// Heuristic boosts for meaningful relationship milestones and commitments | |
const milestoneTags = [ | |
"relationship:first_meet", | |
"relationship:first_date", | |
"relationship:first_kiss", | |
"relationship:anniversary", | |
"relationship:moved_in", | |
"relationship:engaged", | |
"relationship:married", | |
"relationship:breakup" | |
]; | |
if ([...tags].some(t => milestoneTags.includes(t))) importance += 0.15; | |
// Boundaries and consent are high priority to remember | |
if ([...tags].some(t => t.startsWith("boundary:"))) importance += 0.15; | |
// Preferences tied to strong like/dislike | |
if ( | |
content.includes("i love") || | |
content.includes("j'adore") || | |
content.includes("i hate") || | |
content.includes("je déteste") | |
) { | |
importance += 0.05; | |
} | |
// Temporal cues: future commitments or dates | |
if (/(\bnext\b|\btomorrow\b|\bce soir\b|\bdemain\b|\bmañana\b|\bdomani\b|\bmorgen\b)/i.test(content)) { | |
importance += 0.05; | |
} | |
// Longer details and high confidence | |
if (memoryData.content && memoryData.content.length > this.config.longContentThreshold) | |
importance += this.config.importance.bonuses.longContent; | |
if (memoryData.confidence && memoryData.confidence > 0.9) importance += this.config.importance.bonuses.highConfidence; | |
// Round to two decimals to avoid floating point artifacts | |
return Math.min(1.0, Math.round(importance * 100) / 100); | |
} | |
// Derive semantic tags from memory content to assist prioritization and merging | |
deriveMemoryTags(memoryData) { | |
const tags = []; | |
const text = (memoryData.content || "").toLowerCase(); | |
const category = memoryData.category || ""; | |
// Relationship status and milestones | |
if (/(single|célibataire|soltero|single|ledig)/i.test(text)) tags.push("relationship:status_single"); | |
if (/(in a relationship|en couple|together|ensemble|pareja|coppia|beziehung)/i.test(text)) | |
tags.push("relationship:status_in_relationship"); | |
if (/(engaged|fiancé|fiancée|promis|promised|verlobt)/i.test(text)) tags.push("relationship:status_engaged"); | |
if (/(married|marié|mariée|casado|sposato|verheiratet)/i.test(text)) tags.push("relationship:status_married"); | |
if (/(broke up|rupture|separated|separado|separati|getrennt)/i.test(text)) tags.push("relationship:breakup"); | |
if (/(first date|premier rendez-vous|primera cita|primo appuntamento)/i.test(text)) tags.push("relationship:first_date"); | |
if (/(first kiss|premier baiser|primer beso|primo bacio)/i.test(text)) tags.push("relationship:first_kiss"); | |
if (/(anniversary|anniversaire|aniversario|anniversario|jahrestag)/i.test(text)) tags.push("relationship:anniversary"); | |
if (/(moved in together|emménagé ensemble|mudamos juntos|trasferiti insieme|zusammen eingezogen)/i.test(text)) | |
tags.push("relationship:moved_in"); | |
if (/(met at|rencontré à|conocimos en|conosciuti a|kennengelernt)/i.test(text)) tags.push("relationship:first_meet"); | |
// Boundaries and consent (keep generic and non-graphic) | |
if (/(i don't like|je n'aime pas|no me gusta|non mi piace|ich mag nicht)\s+[^,.!?]+/i.test(text)) | |
tags.push("boundary:dislike"); | |
if (/(i prefer|je préfère|prefiero|preferisco|ich bevorzuge)\s+[^,.!?]+/i.test(text)) tags.push("boundary:preference"); | |
if (/(no|pas)\s+(?:kissing|baiser|beso|bacio|küssen)/i.test(text)) tags.push("boundary:limit"); | |
if (/(consent|consentement|consentimiento|consenso|einwilligung)/i.test(text)) tags.push("boundary:consent"); | |
// Time-related tags | |
if (/(today|ce jour|hoy|oggi|heute|今日)/i.test(text)) tags.push("time:today"); | |
if (/(tomorrow|demain|mañana|domani|morgen|明日)/i.test(text)) tags.push("time:tomorrow"); | |
if (/(next week|semaine prochaine|la próxima semana|la prossima settimana|nächste woche)/i.test(text)) | |
tags.push("time:next_week"); | |
// Category-specific hints | |
if (category === "preferences") tags.push("type:preference"); | |
if (category === "personal") tags.push("type:personal"); | |
if (category === "relationships") tags.push("type:relationship"); | |
if (category === "experiences") tags.push("type:experience"); | |
if (category === "goals") tags.push("type:goal"); | |
if (category === "important") tags.push("type:important"); | |
return tags; | |
} | |
async updateMemory(memoryId, updateData) { | |
if (!this.db) return false; | |
try { | |
// Ensure memoryId is the correct type | |
const numericId = typeof memoryId === "string" ? parseInt(memoryId) : memoryId; | |
// Vérifier d'abord que la mémoire existe | |
const existingMemory = await this.db.db.memories.get(numericId); | |
if (!existingMemory) { | |
console.error(`❌ Memory with ID ${numericId} not found in database`); | |
return false; | |
} | |
console.log(`🔄 Updating memory ${numericId}:`, { existing: existingMemory, update: updateData }); | |
const update = { | |
...updateData, | |
lastModified: new Date() | |
}; | |
if (this.db.db.memories) { | |
const result = await this.db.db.memories.update(numericId, update); | |
console.log(`Memory update result for ID ${numericId}:`, result); | |
if (result > 0) { | |
console.log("✅ Memory updated successfully"); | |
// Notify LLM system to refresh context | |
this.notifyLLMContextUpdate(); | |
return true; | |
} else { | |
console.error("❌ Memory update failed - no rows affected"); | |
return false; | |
} | |
} | |
} catch (error) { | |
console.error("Error updating memory:", error, { memoryId, updateData }); | |
return false; | |
} | |
} | |
async deleteMemory(memoryId) { | |
if (!this.db) return false; | |
try { | |
// Ensure memoryId is the correct type | |
const numericId = typeof memoryId === "string" ? parseInt(memoryId) : memoryId; | |
if (this.db.db.memories) { | |
const result = await this.db.db.memories.delete(numericId); | |
console.log(`Memory delete result for ID ${numericId}:`, result); | |
// Notify LLM system to refresh context | |
if (result) { | |
this.notifyLLMContextUpdate(); | |
} | |
return result; | |
} | |
} catch (error) { | |
console.error("Error deleting memory:", error, { memoryId }); | |
return false; | |
} | |
} | |
notifyLLMContextUpdate() { | |
// Debounce context updates to avoid excessive calls | |
if (this.contextUpdateTimeout) { | |
clearTimeout(this.contextUpdateTimeout); | |
} | |
this.contextUpdateTimeout = setTimeout(() => { | |
if (window.kimiLLM && typeof window.kimiLLM.refreshMemoryContext === "function") { | |
window.kimiLLM.refreshMemoryContext(); | |
} | |
}, 500); | |
} | |
async getMemoriesByCategory(category, character = null) { | |
if (!this.db) return []; | |
try { | |
character = character || this.selectedCharacter || "kimi"; // Unified fallback | |
if (this.db.db.memories) { | |
const memories = await this.db.db.memories | |
.where("[character+category]") | |
.equals([character, category]) | |
.and(m => m.isActive) | |
.reverse() | |
.sortBy("timestamp"); | |
// Update lastAccess/accessCount for top results to improve prioritization | |
this._touchMemories(memories, 10).catch(() => {}); | |
return memories; | |
} | |
} catch (error) { | |
console.error("Error getting memories by category:", error); | |
return []; | |
} | |
} | |
async getAllMemories(character = null) { | |
if (!this.db) return []; | |
try { | |
character = character || this.selectedCharacter || "kimi"; | |
if (this.db.db.memories) { | |
// Use simple character filter - compatible with all data | |
const memories = await this.db.db.memories | |
.where("character") | |
.equals(character) | |
.filter(memory => memory.isActive !== false) // Include records without isActive field | |
.reverse() | |
.sortBy("timestamp"); | |
if (window.KIMI_DEBUG_MEMORIES) { | |
console.log(`Retrieved ${memories.length} memories for character: ${character}`); | |
} | |
// Touch top memories to update access metrics | |
this._touchMemories(memories, 10).catch(() => {}); | |
return memories; | |
} | |
} catch (error) { | |
console.error("Error getting all memories:", error); | |
return []; | |
} | |
} | |
async findSimilarMemory(memoryData) { | |
if (!this.db) return null; | |
try { | |
const memories = await this.getMemoriesByCategory(memoryData.category); | |
// Precompute keywords for new memory | |
const newKeys = this.deriveKeywords(memoryData.content || ""); | |
// Enhanced similarity check with multiple criteria | |
for (const memory of memories) { | |
// Prefilter by keyword overlap to reduce false positives and improve perf | |
const memKeys = memory.keywords || this.deriveKeywords(memory.content || ""); | |
const overlap = newKeys.filter(k => memKeys.includes(k)).length; | |
if (newKeys.length > 0 && overlap === 0) continue; // no shared keywords -> likely different | |
const contentSimilarity = this.calculateSimilarity(memory.content, memoryData.content); | |
// Different thresholds based on category | |
const threshold = this.config.similarity[memoryData.category] || this.config.similarity.default; | |
if (contentSimilarity > threshold) { | |
return memory; | |
} | |
// Special handling for names (check if one is contained in the other) | |
if (memoryData.category === "personal" && this.areRelatedNames(memory.content, memoryData.content)) { | |
return memory; | |
} | |
} | |
} catch (error) { | |
console.error("Error finding similar memory:", error); | |
} | |
return null; | |
} | |
// Check if two names are related (nicknames, variants, etc.) | |
areRelatedNames(name1, name2) { | |
const n1 = name1.toLowerCase().trim(); | |
const n2 = name2.toLowerCase().trim(); | |
// Exact match | |
if (n1 === n2) return true; | |
// One contains the other (Jean-Pierre vs Jean) | |
if (n1.includes(n2) || n2.includes(n1)) return true; | |
// Common nickname patterns | |
const nicknames = { | |
jean: ["jp", "jeannot"], | |
pierre: ["pete", "pietro"], | |
marie: ["mary", "maria"], | |
michael: ["mike", "mick"], | |
william: ["bill", "will", "willy"], | |
robert: ["bob", "rob", "bobby"], | |
richard: ["rick", "dick", "richie"], | |
thomas: ["tom", "tommy"], | |
christopher: ["chris", "kit"], | |
anthony: ["tony", "ant"] | |
}; | |
for (const [full, nicks] of Object.entries(nicknames)) { | |
if ((n1 === full && nicks.includes(n2)) || (n2 === full && nicks.includes(n1))) { | |
return true; | |
} | |
} | |
return false; | |
} | |
calculateSimilarity(text1, text2) { | |
// Enhanced similarity calculation | |
const words1 = text1 | |
.toLowerCase() | |
.split(/\s+/) | |
.filter(w => w.length > 2); | |
const words2 = text2 | |
.toLowerCase() | |
.split(/\s+/) | |
.filter(w => w.length > 2); | |
if (words1.length === 0 || words2.length === 0) { | |
return text1.toLowerCase() === text2.toLowerCase() ? 1 : 0; | |
} | |
const intersection = words1.filter(word => words2.includes(word)); | |
const union = [...new Set([...words1, ...words2])]; | |
let similarity = intersection.length / union.length; | |
// Boost similarity for exact substring matches | |
if (text1.toLowerCase().includes(text2.toLowerCase()) || text2.toLowerCase().includes(text1.toLowerCase())) { | |
similarity += 0.2; | |
} | |
return Math.min(1.0, similarity); | |
} | |
// Derive a set of normalized keywords from text | |
deriveKeywords(text) { | |
if (!text || typeof text !== "string") return []; | |
return [ | |
...new Set( | |
text | |
.toLowerCase() | |
.replace(/[\p{P}\p{S}]/gu, " ") | |
.split(/\s+/) | |
.filter(w => w.length > 2 && !this.isCommonWordSafe(w)) | |
) | |
]; | |
} | |
// Safe wrapper for isCommonWord to avoid undefined function errors | |
isCommonWordSafe(word, language = "en") { | |
const cacheKey = `${word.toLowerCase()}_${language}`; | |
// Check cache first | |
if (this.keywordCache.has(cacheKey)) { | |
this.keywordCacheHits++; | |
return this.keywordCache.get(cacheKey); | |
} | |
// Cache miss - compute the result | |
this.keywordCacheMisses++; | |
let isCommon = false; | |
try { | |
isCommon = typeof this.isCommonWord === "function" ? this.isCommonWord(word, language) : false; | |
} catch (error) { | |
console.warn("Error checking common word:", error); | |
isCommon = false; | |
} | |
// Add to cache with LRU eviction | |
if (this.keywordCache.size >= this.keywordCacheSize) { | |
// Simple LRU: remove oldest entry (first in Map) | |
const firstKey = this.keywordCache.keys().next().value; | |
this.keywordCache.delete(firstKey); | |
} | |
this.keywordCache.set(cacheKey, isCommon); | |
return isCommon; | |
} | |
// Get cache statistics for debugging | |
getKeywordCacheStats() { | |
const total = this.keywordCacheHits + this.keywordCacheMisses; | |
return { | |
size: this.keywordCache.size, | |
hits: this.keywordCacheHits, | |
misses: this.keywordCacheMisses, | |
hitRate: total > 0 ? ((this.keywordCacheHits / total) * 100).toFixed(2) + "%" : "0%" | |
}; | |
} | |
// Get performance statistics for debugging and optimization | |
getPerformanceStats() { | |
const calculateStats = times => { | |
if (times.length === 0) return { avg: 0, max: 0, min: 0, count: 0 }; | |
return { | |
avg: Math.round((times.reduce((sum, t) => sum + t, 0) / times.length) * 100) / 100, | |
max: Math.round(Math.max(...times) * 100) / 100, | |
min: Math.round(Math.min(...times) * 100) / 100, | |
count: times.length | |
}; | |
}; | |
return { | |
keywordCache: this.getKeywordCacheStats(), | |
extraction: calculateStats(this.queryStats.extractionTime), | |
addMemory: calculateStats(this.queryStats.addMemoryTime), | |
retrieval: calculateStats(this.queryStats.retrievalTime) | |
}; | |
} | |
// Performance wrapper for memory extraction | |
async extractMemoryFromTextTimed(userText, kimiResponse = null) { | |
const start = performance.now(); | |
const result = await this.extractMemoryFromText(userText, kimiResponse); | |
const duration = performance.now() - start; | |
this.queryStats.extractionTime.push(duration); | |
if (this.queryStats.extractionTime.length > 100) { | |
this.queryStats.extractionTime.shift(); // Keep only last 100 measurements | |
} | |
if (duration > 100 && window.KIMI_CONFIG?.DEBUG?.MEMORY) { | |
console.warn(`🐌 Slow memory extraction: ${duration.toFixed(2)}ms for text length ${userText?.length || 0}`); | |
} | |
return result; | |
} | |
// Get current configuration for debugging and monitoring | |
getConfiguration() { | |
return { | |
...this.config, | |
memoryCategories: this.memoryCategories, | |
runtime: { | |
memoryEnabled: this.memoryEnabled, | |
maxMemoryEntries: this.maxMemoryEntries, | |
selectedCharacter: this.selectedCharacter, | |
keywordCacheSize: this.keywordCache.size, | |
compiledPatternsCount: Object.values(this.compiledPatterns || {}).reduce((sum, arr) => sum + arr.length, 0) | |
} | |
}; | |
} | |
// Update configuration at runtime (for advanced users) | |
updateConfiguration(configPath, value) { | |
const keys = configPath.split("."); | |
let current = this.config; | |
// Navigate to the parent object | |
for (let i = 0; i < keys.length - 1; i++) { | |
if (!current[keys[i]]) current[keys[i]] = {}; | |
current = current[keys[i]]; | |
} | |
// Set the value | |
const lastKey = keys[keys.length - 1]; | |
const oldValue = current[lastKey]; | |
current[lastKey] = value; | |
if (window.KIMI_CONFIG?.DEBUG?.MEMORY) { | |
console.log(`🔧 Configuration updated: ${configPath} = ${value} (was: ${oldValue})`); | |
} | |
return { oldValue, newValue: value }; | |
} | |
async cleanupOldMemories() { | |
if (!this.db) return; | |
try { | |
// Retrieve all active memories for the current character | |
const memories = await this.getAllMemories(); | |
const maxEntries = window.KIMI_MAX_MEMORIES || this.maxMemoryEntries || 100; | |
const ttlDays = window.KIMI_MEMORY_TTL_DAYS || 365; | |
// Soft-expire memories older than TTL by marking isActive=false | |
const now = Date.now(); | |
const ttlMs = ttlDays * 24 * 60 * 60 * 1000; | |
const expiredMemories = []; | |
for (const mem of memories) { | |
const created = new Date(this.getCreationTimestamp(mem)).getTime(); | |
if (now - created > ttlMs) { | |
try { | |
await this.updateMemory(mem.id, { isActive: false }); | |
expiredMemories.push(mem.id); | |
} catch (e) { | |
console.error(`Memory expiration failed for ID ${mem.id}:`, { | |
error: e.message, | |
memoryId: mem.id, | |
createdAt: this.getCreationTimestamp(mem), | |
character: mem.character | |
}); | |
// Continue with other memories even if one fails | |
} | |
} | |
} | |
if (window.KIMI_CONFIG?.DEBUG?.MEMORY && expiredMemories.length > 0) { | |
console.log(`Successfully expired ${expiredMemories.length} memories:`, expiredMemories); | |
} | |
// Refresh active memories after TTL purge | |
const activeMemories = (await this.getAllMemories()).filter(m => m.isActive); | |
// If still more than maxEntries, mark lowest-priority ones inactive (soft delete) | |
if (activeMemories.length > maxEntries) { | |
// Sort by a combined score: low importance + old timestamp + low access | |
activeMemories.sort((a, b) => { | |
const scoreA = | |
(a.importance || 0.5) * -1 + | |
(a.accessCount || 0) * 0.01 + | |
new Date(this.getCreationTimestamp(a)).getTime() / (1000 * 60 * 60 * 24); | |
const scoreB = | |
(b.importance || 0.5) * -1 + | |
(b.accessCount || 0) * 0.01 + | |
new Date(this.getCreationTimestamp(b)).getTime() / (1000 * 60 * 60 * 24); | |
return scoreB - scoreA; | |
}); | |
const toDeactivate = activeMemories.slice(maxEntries); | |
const deactivatedMemories = []; | |
const failedDeactivations = []; | |
for (const mem of toDeactivate) { | |
try { | |
await this.updateMemory(mem.id, { isActive: false }); | |
deactivatedMemories.push(mem.id); | |
} catch (e) { | |
console.error(`Memory deactivation failed for ID ${mem.id}:`, { | |
error: e.message, | |
memoryId: mem.id, | |
importance: mem.importance, | |
character: mem.character | |
}); | |
failedDeactivations.push(mem.id); | |
} | |
} | |
if (window.KIMI_CONFIG?.DEBUG?.MEMORY) { | |
console.log( | |
`Memory cleanup: ${deactivatedMemories.length} deactivated, ${failedDeactivations.length} failed` | |
); | |
} | |
} | |
} catch (error) { | |
console.error("Error cleaning up old memories:", error); | |
} | |
} | |
// MEMORY RETRIEVAL FOR LLM | |
async getRelevantMemories(context = "", limit = 10) { | |
if (!this.memoryEnabled) return []; | |
try { | |
const allMemories = await this.getAllMemories(); | |
if (allMemories.length === 0) return []; | |
if (!context) { | |
// Return most important and recent memories | |
const res = this.selectMostImportantMemories(allMemories, limit); | |
// touch top results to update access metrics | |
this._touchMemories(res, limit).catch(() => {}); | |
return res; | |
} | |
// Score memories based on relevance to context | |
const scoredMemories = allMemories.map(memory => ({ | |
...memory, | |
relevanceScore: this.calculateRelevance(memory, context) | |
})); | |
// Sort by relevance and return top results | |
scoredMemories.sort((a, b) => b.relevanceScore - a.relevanceScore); | |
// Filter out very low relevance memories | |
const relevantMemories = scoredMemories.filter(m => m.relevanceScore > 0.1); | |
const out = relevantMemories.slice(0, limit).map(r => r); | |
// touch top results to update access metrics | |
this._touchMemories( | |
out.map(r => r), | |
limit | |
).catch(() => {}); | |
return out; | |
} catch (error) { | |
console.error("Error getting relevant memories:", error); | |
return []; | |
} | |
} | |
// Select most important memories when no context is provided | |
selectMostImportantMemories(memories, limit) { | |
// Score by importance, recency, and access count | |
const scoredMemories = memories.map(memory => { | |
let score = memory.importance || 0.5; | |
// Boost recent memories | |
const daysSinceCreation = this.getDaysSinceCreation(memory); | |
score += Math.max(0, (7 - daysSinceCreation) / 7) * 0.2; // Recent boost | |
// Boost frequently accessed memories | |
const accessCount = memory.accessCount || 0; | |
score += Math.min(accessCount / 10, 0.2); // Access boost | |
// Boost high confidence memories | |
score += (memory.confidence || 0.5) * 0.1; | |
return { ...memory, importanceScore: score }; | |
}); | |
scoredMemories.sort((a, b) => b.importanceScore - a.importanceScore); | |
return scoredMemories.slice(0, limit); | |
} | |
calculateRelevance(memory, context) { | |
const contextWords = context | |
.toLowerCase() | |
.split(/\s+/) | |
.filter(w => w.length > 2); | |
const memoryWords = memory.content | |
.toLowerCase() | |
.split(/\s+/) | |
.filter(w => w.length > 2); | |
let score = 0; | |
// Enhanced content similarity with keyword matching | |
score += this.calculateSimilarity(memory.content, context) * this.config.relevance.contentSimilarity; | |
// Keyword overlap boost (derived keywords) | |
try { | |
const memKeys = memory.keywords || this.deriveKeywords(memory.content || ""); | |
const ctxKeys = this.deriveKeywords(context || ""); | |
const keyOverlap = ctxKeys.filter(k => memKeys.includes(k)).length; | |
if (ctxKeys.length > 0) { | |
score += (keyOverlap / ctxKeys.length) * this.config.relevance.keywordOverlap; | |
} | |
} catch (e) { | |
// fallback to original keyword matching | |
let keywordMatches = 0; | |
for (const word of contextWords) { | |
if (memoryWords.includes(word)) { | |
keywordMatches++; | |
} | |
} | |
if (contextWords.length > 0) { | |
score += (keywordMatches / contextWords.length) * this.config.relevance.keywordOverlap; | |
} | |
} | |
// Category relevance bonus based on context | |
score += this.getCategoryRelevance(memory.category, context) * this.config.relevance.categoryRelevance; | |
// Recent memories get bonus for current conversation | |
const daysSinceCreation = this.getDaysSinceCreation(memory); | |
score += | |
Math.max( | |
0, | |
(this.config.relevance.recentDaysThreshold - daysSinceCreation) / this.config.relevance.recentDaysThreshold | |
) * this.config.relevance.recencyBonus; | |
// Confidence and importance boost | |
score += (memory.confidence || 0.5) * this.config.relevance.confidenceBonus; | |
score += (memory.importance || 0.5) * this.config.relevance.importanceBonus; | |
return Math.min(1.0, score); | |
} | |
// Determine if memory category is relevant to current context | |
getCategoryRelevance(category, context) { | |
const contextLower = context.toLowerCase(); | |
const categoryKeywords = { | |
personal: [ | |
"name", | |
"age", | |
"live", | |
"work", | |
"job", | |
"who", | |
"am", | |
"myself", | |
"appelle", | |
"nombre", | |
"chiamo", | |
"heiße", | |
"名前", | |
"名字", | |
"我叫" | |
], | |
preferences: [ | |
"like", | |
"love", | |
"hate", | |
"prefer", | |
"enjoy", | |
"favorite", | |
"dislike", | |
"j'aime", | |
"j'adore", | |
"je préfère", | |
"je déteste", | |
"me gusta", | |
"prefiero", | |
"odio", | |
"mi piace", | |
"preferisco", | |
"ich mag", | |
"ich bevorzuge", | |
"hasse" | |
], | |
relationships: [ | |
"family", | |
"friend", | |
"wife", | |
"husband", | |
"partner", | |
"mother", | |
"father", | |
"girlfriend", | |
"boyfriend", | |
"anniversary", | |
"date", | |
"kiss", | |
"move in", | |
"famille", | |
"ami", | |
"copine", | |
"copain", | |
"anniversaire", | |
"rendez-vous", | |
"baiser", | |
"emménagé", | |
"pareja", | |
"cita", | |
"beso", | |
"aniversario", | |
"mudarnos", | |
"fidanzata", | |
"fidanzato", | |
"anniversario", | |
"bacio", | |
"trasferiti", | |
"freundin", | |
"freund", | |
"jahrestag", | |
"kuss", | |
"eingezogen" | |
], | |
activities: [ | |
"play", | |
"hobby", | |
"sport", | |
"activity", | |
"practice", | |
"do", | |
"joue", | |
"passe-temps", | |
"hobby", | |
"juego", | |
"pasatiempo", | |
"gioco", | |
"passatempo", | |
"spiele", | |
"hobby" | |
], | |
goals: [ | |
"want", | |
"plan", | |
"goal", | |
"dream", | |
"hope", | |
"wish", | |
"future", | |
"veux", | |
"objectif", | |
"apprends", | |
"aprendo", | |
"voglio", | |
"obiettivo", | |
"lerne", | |
"ziel" | |
], | |
experiences: [ | |
"remember", | |
"happened", | |
"story", | |
"experience", | |
"time", | |
"we met", | |
"first date", | |
"first kiss", | |
"anniversary", | |
"rencontré", | |
"premier rendez-vous", | |
"premier baiser", | |
"anniversaire", | |
"conocimos", | |
"primera cita", | |
"primer beso", | |
"aniversario", | |
"conosciuti", | |
"primo appuntamento", | |
"primo bacio", | |
"anniversario", | |
"kennengelernt", | |
"erstes date", | |
"erster kuss", | |
"jahrestag" | |
], | |
important: [ | |
"important", | |
"remember", | |
"special", | |
"never forget", | |
"important", | |
"souvenir", | |
"spécial", | |
"importante", | |
"recuerda", | |
"importante", | |
"ricorda", | |
"wichtig", | |
"erinnere" | |
] | |
}; | |
const keywords = categoryKeywords[category] || []; | |
let relevance = 0; | |
for (const keyword of keywords) { | |
if (contextLower.includes(keyword)) { | |
relevance += 0.2; | |
} | |
} | |
return Math.min(1.0, relevance); | |
} | |
// Update access count when memory is used | |
async recordMemoryAccess(memoryId) { | |
try { | |
const memory = await this.db.db.memories.get(memoryId); | |
if (memory) { | |
memory.accessCount = (memory.accessCount || 0) + 1; | |
memory.lastAccess = new Date(); | |
await this.db.db.memories.put(memory); | |
} | |
} catch (error) { | |
console.error("Error recording memory access:", error); | |
} | |
} | |
// Touch multiple memories to update lastAccess and accessCount | |
async _touchMemories(memories, limit = 5) { | |
if (!this.db || !Array.isArray(memories) || memories.length === 0) return; | |
try { | |
const top = memories.slice(0, limit); | |
const now = new Date(); | |
const minMinutes = window.KIMI_MEMORY_TOUCH_MINUTES || 60; | |
const minTouchInterval = minMinutes * 60 * 1000; | |
// Batch collection: gather all updates before executing | |
const batchUpdates = []; | |
for (const m of top) { | |
try { | |
const id = m.id; | |
const existing = await this.db.db.memories.get(id); | |
if (existing) { | |
const lastAccess = existing.lastAccess ? new Date(existing.lastAccess).getTime() : 0; | |
// Only touch if enough time has passed | |
if (now.getTime() - lastAccess > minTouchInterval) { | |
batchUpdates.push({ | |
key: id, | |
changes: { | |
accessCount: (existing.accessCount || 0) + 1, | |
lastAccess: now | |
} | |
}); | |
} | |
} | |
} catch (e) { | |
console.warn("Error preparing memory touch batch for", m && m.id, e); | |
} | |
} | |
// Execute all updates in a single batch operation | |
if (batchUpdates.length > 0) { | |
if (this.db.db.memories.bulkUpdate) { | |
// Use bulkUpdate if available (Dexie 3.x+) | |
await this.db.db.memories.bulkUpdate(batchUpdates); | |
} else { | |
// Fallback: parallel individual updates (still better than sequential) | |
const updatePromises = batchUpdates.map(update => this.db.db.memories.update(update.key, update.changes)); | |
await Promise.all(updatePromises); | |
} | |
if (window.KIMI_CONFIG?.DEBUG?.MEMORY) { | |
console.log(`📊 Batch touched ${batchUpdates.length} memories`); | |
} | |
} | |
} catch (e) { | |
console.warn("Error in _touchMemories batch processing", e); | |
} | |
} | |
// ===== MEMORY SCORING & RANKING ===== | |
scoreMemory(memory) { | |
// Factors: importance (0-1), recency, frequency, confidence | |
const now = Date.now(); | |
const created = memory.createdAt | |
? new Date(memory.createdAt).getTime() | |
: memory.timestamp | |
? new Date(memory.timestamp).getTime() | |
: now; | |
const lastAccess = memory.lastAccess ? new Date(memory.lastAccess).getTime() : created; | |
const ageMs = Math.max(1, now - created); | |
const sinceLastAccessMs = Math.max(1, now - lastAccess); | |
// Recency: exponential decay | |
const recency = Math.exp(-sinceLastAccessMs / (1000 * 60 * 60 * 24 * 14)); // 14-day half-life approx | |
const freshness = Math.exp(-ageMs / (1000 * 60 * 60 * 24 * 60)); // 60-day aging | |
const freq = Math.log10((memory.accessCount || 0) + 1) / Math.log10(50); // normalized frequency (cap ~50) | |
const importance = typeof memory.importance === "number" ? memory.importance : 0.5; | |
const confidence = typeof memory.confidence === "number" ? memory.confidence : 0.5; | |
// Weighted sum using global knobs | |
const wImportance = window.KIMI_WEIGHT_IMPORTANCE || 0.35; | |
const wRecency = window.KIMI_WEIGHT_RECENCY || 0.2; | |
const wFrequency = window.KIMI_WEIGHT_FREQUENCY || 0.15; | |
const wConfidence = window.KIMI_WEIGHT_CONFIDENCE || 0.2; | |
const wFreshness = window.KIMI_WEIGHT_FRESHNESS || 0.1; | |
const score = | |
importance * wImportance + recency * wRecency + freq * wFrequency + confidence * wConfidence + freshness * wFreshness; | |
return Number(score.toFixed(6)); | |
} | |
async getRankedMemories(contextText = "", limit = 7) { | |
const all = await this.getAllMemories(); | |
if (!all.length) return []; | |
// Optional basic context relevance boost | |
const ctxLower = (contextText || "").toLowerCase(); | |
// Favor pinned memories by boosting their base score | |
return all | |
.map(m => { | |
let baseScore = this.scoreMemory(m); | |
if (m.tags && m.tags.includes && m.tags.includes("pinned")) baseScore += 0.2; | |
if (ctxLower && m.content && ctxLower.includes(m.content.toLowerCase().split(" ")[0])) { | |
baseScore += 0.05; // tiny relevance boost | |
} | |
return { memory: m, score: baseScore }; | |
}) | |
.sort((a, b) => b.score - a.score) | |
.slice(0, limit) | |
.map(r => r.memory); | |
} | |
// Pin/unpin APIs to manually mark important memories | |
async pinMemory(memoryId) { | |
if (!this.db) return false; | |
try { | |
const m = await this.db.db.memories.get(memoryId); | |
if (!m) return false; | |
const tags = new Set([...(m.tags || []), "pinned"]); | |
await this.db.db.memories.update(memoryId, { tags: [...tags], importance: Math.max(m.importance || 0.5, 0.95) }); | |
return true; | |
} catch (e) { | |
console.error("Error pinning memory", e); | |
return false; | |
} | |
} | |
async unpinMemory(memoryId) { | |
if (!this.db) return false; | |
try { | |
const m = await this.db.db.memories.get(memoryId); | |
if (!m) return false; | |
const tags = new Set([...(m.tags || [])]); | |
tags.delete("pinned"); | |
await this.db.db.memories.update(memoryId, { tags: [...tags] }); | |
return true; | |
} catch (e) { | |
console.error("Error unpinning memory", e); | |
return false; | |
} | |
} | |
// Summarize recent memories into a non-destructive summary memory | |
async summarizeRecentMemories(days = 7, options = { category: null, archiveSources: false }) { | |
if (!this.db) return null; | |
try { | |
const cutoff = Date.now() - (days || 7) * 24 * 60 * 60 * 1000; | |
const all = await this.getAllMemories(); | |
// Exclude existing summaries to avoid summarizing summaries repeatedly | |
const recent = all.filter( | |
m => | |
new Date(this.getCreationTimestamp(m)).getTime() >= cutoff && | |
m.isActive && | |
m.type !== "summary" && | |
!(m.tags && m.tags.includes("summary")) | |
); | |
if (!recent.length) return null; | |
// Group by top keyword | |
const groups = {}; | |
for (const m of recent) { | |
const keys = m.keywords && m.keywords.length ? m.keywords : this.deriveKeywords(m.content || ""); | |
const top = keys[0] || "misc"; | |
groups[top] = groups[top] || []; | |
groups[top].push(m); | |
} | |
// Build a simple summary per group | |
const summaries = []; | |
for (const [k, items] of Object.entries(groups)) { | |
const contents = items.map(i => i.content).slice(0, 6); | |
summaries.push(`${k}: ${contents.join(" | ")}`); | |
} | |
const summaryContent = `Summary (${days}d): ` + summaries.join(" \n"); | |
const summaryJson = { summary: summaries }; | |
const summaryMemory = { | |
category: options.category || "experiences", | |
type: "summary", | |
content: summaryContent, | |
sourceText: summaryContent, | |
summaryJson: JSON.stringify(summaryJson), | |
confidence: 0.9, | |
createdAt: new Date(), // Use createdAt consistently | |
character: this.selectedCharacter, | |
isActive: true, | |
tags: ["summary"] | |
}; | |
const saved = await this.addMemory(summaryMemory); | |
// Optionally archive sources (soft-deactivate) | |
if (options.archiveSources) { | |
for (const m of recent) { | |
try { | |
await this.updateMemory(m.id, { isActive: false }); | |
} catch (e) { | |
console.warn("Failed to archive source memory", m.id, e); | |
} | |
} | |
} | |
return saved; | |
} catch (e) { | |
console.error("Error summarizing memories", e); | |
return null; | |
} | |
} | |
// Summarize recent memories and replace sources (hard delete) - destructive | |
async summarizeAndReplace(days = 7, options = { category: null }) { | |
if (!this.db) return null; | |
try { | |
const cutoff = Date.now() - (days || 7) * 24 * 60 * 60 * 1000; | |
const all = await this.getAllMemories(); | |
// Exclude existing summaries to avoid recursive summarization | |
const recent = all.filter( | |
m => | |
new Date(this.getCreationTimestamp(m)).getTime() >= cutoff && | |
m.isActive && | |
m.type !== "summary" && | |
!(m.tags && m.tags.includes("summary")) | |
); | |
if (!recent.length) return null; | |
// Build aggregate content from readable fields in chronological order | |
recent.sort((a, b) => new Date(this.getCreationTimestamp(a)) - new Date(this.getCreationTimestamp(b))); | |
const texts = recent | |
.map(r => { | |
const raw = | |
(r.title && r.title.trim()) || | |
(r.sourceText && r.sourceText.trim()) || | |
(r.content && r.content.trim()) || | |
""; | |
if (!raw) return ""; | |
// Normalize whitespace and remove stray leading punctuation | |
let t = raw.replace(/\s+/g, " ").replace(/^[^\p{L}\p{N}]+/u, ""); | |
// Capitalize first meaningful letter | |
if (t && t.length > 0) t = t.charAt(0).toUpperCase() + t.slice(1); | |
return t; | |
}) | |
.filter(Boolean) | |
.slice(0, 200); | |
const summaryContent = `Summary (${days}d):\n` + texts.map(t => `- ${t}`).join("\n"); | |
const summaryJson = { summary: texts }; | |
const summaryMemory = { | |
category: options.category || "experiences", | |
type: "summary", | |
title: `Summary - last ${days} days`, | |
content: summaryContent, | |
// Store the actual summary also in sourceText so editors/UIs show it | |
sourceText: summaryContent, | |
summaryJson: JSON.stringify(summaryJson), | |
confidence: 0.95, | |
timestamp: new Date(), | |
character: this.selectedCharacter, | |
isActive: true, | |
tags: ["summary", "replaced"] | |
}; | |
// Add summary directly to DB to avoid addMemory's merge logic | |
let saved = null; | |
if (this.db && this.db.db && this.db.db.memories) { | |
try { | |
const id = await this.db.db.memories.add(summaryMemory); | |
summaryMemory.id = id; | |
saved = summaryMemory; | |
console.log("Summary added with ID:", id); | |
// Read back the saved record to verify stored fields | |
try { | |
const savedRec = await this.db.db.memories.get(id); | |
console.log("Saved summary record:", { id, content: savedRec.content, sourceText: savedRec.sourceText }); | |
} catch (e) { | |
console.warn("Unable to read back saved summary", e); | |
} | |
} catch (e) { | |
console.error("Failed to write summary directly to DB", e); | |
} | |
} else { | |
// Fallback to addMemory if DB not available | |
saved = await this.addMemory(summaryMemory); | |
} | |
// Hard-delete sources | |
for (const m of recent) { | |
try { | |
if (this.db && this.db.db && this.db.db.memories) { | |
await this.db.db.memories.delete(m.id); | |
} | |
} catch (e) { | |
console.warn("Failed to delete source memory", m.id, e); | |
} | |
} | |
// Notify LLM to refresh context | |
this.notifyLLMContextUpdate(); | |
return saved; | |
} catch (e) { | |
console.error("Error in summarizeAndReplace", e); | |
return null; | |
} | |
} | |
// MEMORY STATISTICS | |
async getMemoryStats() { | |
try { | |
const memories = await this.getAllMemories(); | |
const stats = { | |
total: memories.length, | |
byCategory: {}, | |
averageConfidence: 0, | |
oldestMemory: null, | |
newestMemory: null | |
}; | |
if (memories.length > 0) { | |
// Category breakdown | |
for (const memory of memories) { | |
stats.byCategory[memory.category] = (stats.byCategory[memory.category] || 0) + 1; | |
} | |
// Average confidence | |
stats.averageConfidence = memories.reduce((sum, m) => sum + m.confidence, 0) / memories.length; | |
// Oldest and newest | |
const sortedByDate = [...memories].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); | |
stats.oldestMemory = sortedByDate[0]; | |
stats.newestMemory = sortedByDate[sortedByDate.length - 1]; | |
} | |
return stats; | |
} catch (error) { | |
console.error("Error getting memory stats:", error); | |
return { total: 0, byCategory: {}, averageConfidence: 0 }; | |
} | |
} | |
// MEMORY TOGGLE | |
async toggleMemorySystem(enabled) { | |
this.memoryEnabled = enabled; | |
if (this.db) { | |
await this.db.setPreference("memorySystemEnabled", enabled); | |
} | |
} | |
// EXPORT/IMPORT MEMORIES | |
async exportMemories() { | |
try { | |
const memories = await this.getAllMemories(); | |
return { | |
exportDate: new Date().toISOString(), | |
character: this.selectedCharacter, | |
memories: memories, | |
version: "1.0" | |
}; | |
} catch (error) { | |
console.error("Error exporting memories:", error); | |
return null; | |
} | |
} | |
async importMemories(importData) { | |
if (!importData || !importData.memories) return false; | |
try { | |
for (const memory of importData.memories) { | |
await this.addMemory({ | |
...memory, | |
type: "imported", | |
character: this.selectedCharacter | |
}); | |
} | |
return true; | |
} catch (error) { | |
console.error("Error importing memories:", error); | |
return false; | |
} | |
} | |
// MIGRATION UTILITIES | |
async migrateIncompatibleIDs() { | |
if (!this.db) return false; | |
try { | |
console.log("🔧 Début de la migration des IDs incompatibles..."); | |
// Récupérer toutes les mémoires | |
const allMemories = await this.db.db.memories.toArray(); | |
console.log(`📊 ${allMemories.length} mémoires trouvées`); | |
const incompatibleMemories = allMemories.filter(memory => { | |
// Les IDs auto-increment sont des entiers séquentiels (1, 2, 3...) | |
// Les anciens IDs manuels sont des nombres très grands (timestamps) | |
return memory.id > 10000; // Seuil arbitraire pour détecter les anciens IDs | |
}); | |
if (incompatibleMemories.length === 0) { | |
console.log("✅ Aucune migration nécessaire"); | |
return true; | |
} | |
console.log(`🔄 Migration de ${incompatibleMemories.length} mémoires avec IDs incompatibles`); | |
// Sauvegarder les données avant suppression | |
const dataToMigrate = incompatibleMemories.map(memory => { | |
const { id, ...memoryData } = memory; // Enlever l'ancien ID | |
return memoryData; | |
}); | |
// Supprimer les anciennes entrées | |
await this.db.db.memories.bulkDelete(incompatibleMemories.map(m => m.id)); | |
// Réinsérer avec de nouveaux IDs auto-générés | |
const newIds = await this.db.db.memories.bulkAdd(dataToMigrate); | |
console.log(`✅ Migration terminée. Nouveaux IDs:`, newIds); | |
return true; | |
} catch (error) { | |
console.error("❌ Erreur lors de la migration:", error); | |
return false; | |
} | |
} | |
// Background migration: populate keywords for all existing memories if missing | |
async populateKeywordsForAllMemories() { | |
if (!this.db || !this.db.db.memories) return false; | |
try { | |
console.log("🔧 Starting background keyword population..."); | |
const all = await this.db.db.memories.toArray(); | |
const ops = []; | |
for (const mem of all) { | |
if (!mem.keywords || !Array.isArray(mem.keywords) || mem.keywords.length === 0) { | |
const keys = this.deriveKeywords(mem.content || ""); | |
ops.push(this.db.db.memories.update(mem.id, { keywords: keys })); | |
} | |
// batch in small chunks to avoid blocking | |
if (ops.length >= 50) { | |
await Promise.all(ops); | |
ops.length = 0; | |
} | |
} | |
if (ops.length) await Promise.all(ops); | |
console.log("✅ Keyword population complete"); | |
return true; | |
} catch (e) { | |
console.warn("Error populating keywords", e); | |
return false; | |
} | |
} | |
} | |
window.KimiMemorySystem = KimiMemorySystem; | |
export default KimiMemorySystem; | |