272 lines
8.8 KiB
JavaScript
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);
|