jaeswift-website/js/chat-memory.js

272 lines
8.8 KiB
JavaScript

/* ===================================================
JAESWIFT.XYZ — JAE-AI Memory System (Memoria-style)
Browser-local persistent memory for chat continuity
All data stays in localStorage — never leaves device
except as context in the next user message.
=================================================== */
(function (global) {
'use strict';
const MEM_KEY = 'jae-ai-memory-v1';
const HIST_KEY = 'jae-ai-history-v1';
const MAX_MEMORIES = 100;
const MAX_HISTORY = 50;
const EXTRACT_EVERY_N_USER_MSGS = 4;
// ─── Storage-safe wrappers ───
function lsGet(key, fallback) {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw);
} catch (e) {
console.warn('[chat-memory] storage read fail', e);
return fallback;
}
}
function lsSet(key, val) {
try {
localStorage.setItem(key, JSON.stringify(val));
return true;
} catch (e) {
console.warn('[chat-memory] storage write fail', e);
return false;
}
}
// ─── ID helper ───
function uid() {
return 'm_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
}
// ─── Memory ops ───
function getAll() {
return lsGet(MEM_KEY, []);
}
function saveAll(list) {
return lsSet(MEM_KEY, list);
}
function prune(list) {
if (list.length <= MAX_MEMORIES) return list;
// sort by importance asc, then timestamp asc (oldest low importance first to drop)
const sorted = [...list].sort(function (a, b) {
if (a.importance !== b.importance) return a.importance - b.importance;
return (a.timestamp || 0) - (b.timestamp || 0);
});
const toDrop = list.length - MAX_MEMORIES;
const dropIds = new Set(sorted.slice(0, toDrop).map(function (m) { return m.id; }));
return list.filter(function (m) { return !dropIds.has(m.id); });
}
// Simple normalized tokens for fuzzy matching
function tokens(str) {
return (str || '')
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.filter(function (t) { return t.length > 2; });
}
// Jaccard similarity of token sets
function similarity(a, b) {
const ta = new Set(tokens(a));
const tb = new Set(tokens(b));
if (ta.size === 0 || tb.size === 0) return 0;
let inter = 0;
ta.forEach(function (t) { if (tb.has(t)) inter++; });
const union = ta.size + tb.size - inter;
return union === 0 ? 0 : inter / union;
}
function add(text, category, importance) {
text = (text || '').trim();
if (!text) return null;
category = category || 'other';
importance = typeof importance === 'number' ? Math.max(0, Math.min(1, importance)) : 0.5;
let list = getAll();
// Dedup: if very similar memory exists, update importance instead of adding
for (let i = 0; i < list.length; i++) {
if (similarity(list[i].text, text) > 0.8) {
list[i].importance = Math.max(list[i].importance || 0, importance);
list[i].timestamp = Date.now();
saveAll(list);
return list[i];
}
}
const mem = {
id: uid(),
text: text,
category: category,
importance: importance,
timestamp: Date.now()
};
list.push(mem);
list = prune(list);
saveAll(list);
return mem;
}
function remove(id) {
const list = getAll().filter(function (m) { return m.id !== id; });
saveAll(list);
}
function clear() {
saveAll([]);
}
// ─── Relevance-ranked search ───
function search(query, limit) {
limit = limit || 5;
const qTokens = tokens(query);
const list = getAll();
if (qTokens.length === 0 || list.length === 0) return [];
const scored = list.map(function (m) {
const mTokens = tokens(m.text);
const mSet = new Set(mTokens);
let overlap = 0;
qTokens.forEach(function (t) { if (mSet.has(t)) overlap++; });
// substring bonus
const lowerText = m.text.toLowerCase();
const lowerQ = (query || '').toLowerCase();
let subBonus = 0;
qTokens.forEach(function (t) {
if (lowerText.indexOf(t) !== -1) subBonus += 0.3;
});
if (lowerQ && lowerText.indexOf(lowerQ) !== -1) subBonus += 1.0;
const score = overlap + subBonus + (m.importance || 0) * 0.5;
return { mem: m, score: score };
});
scored.sort(function (a, b) { return b.score - a.score; });
return scored.filter(function (s) { return s.score > 0; }).slice(0, limit).map(function (s) { return s.mem; });
}
function recent(limit) {
limit = limit || 3;
return [...getAll()].sort(function (a, b) { return (b.timestamp || 0) - (a.timestamp || 0); }).slice(0, limit);
}
// ─── Context block for system prompt ───
function getContextBlock(query) {
const relevant = search(query, 5);
const recentOnes = recent(3);
const seen = new Set(relevant.map(function (m) { return m.id; }));
const combined = relevant.slice();
recentOnes.forEach(function (m) {
if (!seen.has(m.id)) combined.push(m);
});
if (combined.length === 0) return '';
const lines = combined.map(function (m) {
return '- [' + (m.category || 'other') + '] ' + m.text;
});
return '[REMEMBERED FACTS ABOUT THIS USER]\n' + lines.join('\n') + '\n[END REMEMBERED FACTS]';
}
// ─── Chat history persistence ───
function getHistory() {
return lsGet(HIST_KEY, []);
}
function saveHistory(messages) {
const trimmed = messages.slice(-MAX_HISTORY);
return lsSet(HIST_KEY, trimmed);
}
function appendHistory(msg) {
const h = getHistory();
h.push(msg);
saveHistory(h);
}
function clearHistory() {
lsSet(HIST_KEY, []);
}
// ─── Memory extraction via backend ───
let _userMsgCounter = 0;
let _extracting = false;
function incrementUserMsgAndMaybeExtract() {
_userMsgCounter++;
if (_userMsgCounter >= EXTRACT_EVERY_N_USER_MSGS) {
_userMsgCounter = 0;
return extractFromRecentChat();
}
return Promise.resolve(null);
}
function extractFromRecentChat() {
if (_extracting) return Promise.resolve(null);
_extracting = true;
const hist = getHistory().slice(-8);
if (hist.length < 2) {
_extracting = false;
return Promise.resolve(null);
}
return fetch('/api/chat/extract-memories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: hist })
})
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (data) {
const arr = Array.isArray(data) ? data : (data && Array.isArray(data.memories) ? data.memories : []);
const added = [];
arr.forEach(function (item) {
if (item && typeof item.text === 'string' && item.text.trim()) {
const m = add(item.text, item.category || 'other', typeof item.importance === 'number' ? item.importance : 0.5);
if (m) added.push(m);
}
});
_extracting = false;
return added;
})
.catch(function (e) {
console.warn('[chat-memory] extraction failed', e);
_extracting = false;
return null;
});
}
// ─── Export JSON ───
function exportJSON() {
const data = {
version: 1,
exported: new Date().toISOString(),
memories: getAll(),
history: getHistory()
};
return JSON.stringify(data, null, 2);
}
// ─── Public API ───
global.chatMemory = {
add: add,
getAll: getAll,
search: search,
remove: remove,
clear: clear,
getContextBlock: getContextBlock,
extractFromRecentChat: extractFromRecentChat,
incrementUserMsgAndMaybeExtract: incrementUserMsgAndMaybeExtract,
getHistory: getHistory,
saveHistory: saveHistory,
appendHistory: appendHistory,
clearHistory: clearHistory,
exportJSON: exportJSON
};
})(window);