perf: réécriture nox-memory.js avec node fetch natif (sans Playwright)

This commit is contained in:
Nox
2026-02-22 17:34:51 +00:00
parent b8ef4c771b
commit 1f09f40032
+89 -131
View File
@@ -1,19 +1,18 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* nox-memory.js — Mémoire vectorielle de Nox via Qdrant * nox-memory.js — Mémoire vectorielle de Nox via Qdrant
* * Utilise node fetch (Node 22+) + OpenAI embeddings
*
* Usage: * Usage:
* node nox-memory.js add "texte à mémoriser" --type fact --tags "ha,lumiere" --importance 3 * node nox-memory.js add "texte" --type fact --tags "ha,lumiere" --importance 3
* node nox-memory.js search "question en langage naturel" --limit 5 * node nox-memory.js search "question naturelle" [--limit 5] [--type fact]
* node nox-memory.js list [--type fact] [--limit 20] * node nox-memory.js list [--type fact] [--limit 20]
* node nox-memory.js delete <id> * node nox-memory.js delete <id>
* node nox-memory.js import-md <fichier.md> ← importe MEMORY.md * node nox-memory.js import-md <fichier.md>
* node nox-memory.js stats * node nox-memory.js stats
*/ */
const { chromium } = require('./node_modules/playwright'); const QDRANT_URL = 'http://192.168.1.150:6333';
const QDRANT_URL = 'http://192.168.1.150:6333';
const COLLECTION = 'nox-memory'; const COLLECTION = 'nox-memory';
const OPENAI_URL = 'https://api.openai.com/v1/embeddings'; const OPENAI_URL = 'https://api.openai.com/v1/embeddings';
const EMBED_MODEL = 'text-embedding-3-small'; const EMBED_MODEL = 'text-embedding-3-small';
@@ -21,17 +20,18 @@ const EMBED_DIM = 1536;
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
async function getRequestContext() { async function qdrant(method, path, body) {
const browser = await chromium.launch({ const opts = {
executablePath: '/home/node/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome', method,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] headers: { 'Content-Type': 'application/json' }
}); };
const context = await browser.newContext(); if (body) opts.body = JSON.stringify(body);
return { browser, req: context.request }; const r = await fetch(`${QDRANT_URL}${path}`, opts);
return r.json();
} }
async function embed(text) { async function embed(text) {
const res = await fetch(OPENAI_URL, { const r = await fetch(OPENAI_URL, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -39,13 +39,12 @@ async function embed(text) {
}, },
body: JSON.stringify({ model: EMBED_MODEL, input: text }) body: JSON.stringify({ model: EMBED_MODEL, input: text })
}); });
const json = await res.json(); const json = await r.json();
if (!json.data) throw new Error('Embedding failed: ' + JSON.stringify(json)); if (!json.data) throw new Error('Embedding failed: ' + JSON.stringify(json));
return json.data[0].embedding; return json.data[0].embedding;
} }
function genId() { function genId() {
// UUID v4 simplifié
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0; const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
@@ -55,125 +54,91 @@ function genId() {
// ─── Commandes ──────────────────────────────────────────────────────────────── // ─── Commandes ────────────────────────────────────────────────────────────────
async function cmdAdd(text, opts) { async function cmdAdd(text, opts) {
const { browser, req } = await getRequestContext(); const vector = await embed(text);
try { const id = genId();
const vector = await embed(text); const payload = {
const id = genId(); text,
const payload = { type: opts.type || 'fact',
text, tags: opts.tags ? opts.tags.split(',').map(t => t.trim()) : [],
type: opts.type || 'fact', importance: parseInt(opts.importance || '3'),
tags: opts.tags ? opts.tags.split(',').map(t => t.trim()) : [], timestamp: Date.now(),
importance: parseInt(opts.importance || '3'), date: new Date().toISOString()
timestamp: Date.now(), };
date: new Date().toISOString() const res = await qdrant('PUT', `/collections/${COLLECTION}/points`, {
}; points: [{ id, vector, payload }]
const r = await req.put(`${QDRANT_URL}/collections/${COLLECTION}/points`, { });
data: { points: [{ id, vector, payload }] } if (res.status === 'ok') {
}); console.log(`✅ Mémorisé [${id}]`);
const body = await r.json(); console.log(` type: ${payload.type} | importance: ${payload.importance} | tags: ${payload.tags.join(', ') || 'aucun'}`);
if (body.status === 'ok') { console.log(` "${text.slice(0, 80)}${text.length > 80 ? '...' : ''}"`);
console.log(`✅ Mémorisé [${id}]`); } else {
console.log(` type: ${payload.type} | importance: ${payload.importance} | tags: ${payload.tags.join(', ') || 'aucun'}`); console.error('❌ Erreur:', JSON.stringify(res));
console.log(` "${text.slice(0, 80)}${text.length > 80 ? '...' : ''}"`);
} else {
console.error('❌ Erreur:', JSON.stringify(body));
}
} finally {
await browser.close();
} }
} }
async function cmdSearch(query, opts) { async function cmdSearch(query, opts) {
const { browser, req } = await getRequestContext(); const vector = await embed(query);
try { const limit = parseInt(opts.limit || '5');
const vector = await embed(query); const body = {
const limit = parseInt(opts.limit || '5'); vector,
const body = { limit,
vector, with_payload: true,
limit, score_threshold: 0.3
with_payload: true, };
score_threshold: 0.3 if (opts.type) {
}; body.filter = { must: [{ key: 'type', match: { value: opts.type } }] };
if (opts.type) {
body.filter = { must: [{ key: 'type', match: { value: opts.type } }] };
}
const r = await req.post(`${QDRANT_URL}/collections/${COLLECTION}/points/search`, { data: body });
const res = await r.json();
const results = res.result || [];
if (results.length === 0) {
console.log('🔍 Aucun résultat pertinent trouvé.');
return;
}
console.log(`🔍 ${results.length} résultat(s) pour : "${query}"\n`);
results.forEach((p, i) => {
const pl = p.payload;
const score = (p.score * 100).toFixed(1);
console.log(`[${i+1}] ${score}% — ${pl.type} | imp:${pl.importance} | ${pl.date?.slice(0,10) || '?'}`);
if (pl.tags?.length) console.log(` Tags: ${pl.tags.join(', ')}`);
console.log(` ${pl.text}`);
console.log(` ID: ${p.id}\n`);
});
} finally {
await browser.close();
} }
const res = await qdrant('POST', `/collections/${COLLECTION}/points/search`, body);
const results = res.result || [];
if (results.length === 0) {
console.log('🔍 Aucun résultat pertinent trouvé.');
return;
}
console.log(`🔍 ${results.length} résultat(s) pour : "${query}"\n`);
results.forEach((p, i) => {
const pl = p.payload;
const score = (p.score * 100).toFixed(1);
console.log(`[${i+1}] ${score}% — ${pl.type} | imp:${pl.importance} | ${pl.date?.slice(0,10) || '?'}`);
if (pl.tags?.length) console.log(` Tags: ${pl.tags.join(', ')}`);
console.log(` ${pl.text}`);
console.log(` ID: ${p.id}\n`);
});
} }
async function cmdList(opts) { async function cmdList(opts) {
const { browser, req } = await getRequestContext(); const limit = parseInt(opts.limit || '20');
try { const body = { limit, with_payload: true };
const limit = parseInt(opts.limit || '20'); if (opts.type) {
const body = { limit, with_payload: true }; body.filter = { must: [{ key: 'type', match: { value: opts.type } }] };
if (opts.type) {
body.filter = { must: [{ key: 'type', match: { value: opts.type } }] };
}
const r = await req.post(`${QDRANT_URL}/collections/${COLLECTION}/points/scroll`, { data: body });
const res = await r.json();
const points = res.result?.points || [];
if (points.length === 0) { console.log('📭 Collection vide.'); return; }
console.log(`📋 ${points.length} souvenir(s) :\n`);
points.forEach(p => {
const pl = p.payload;
console.log(`• [${pl.type}|${pl.importance}] ${pl.date?.slice(0,10)}${pl.text?.slice(0,100)}${pl.text?.length>100?'...':''}`);
});
} finally {
await browser.close();
} }
const res = await qdrant('POST', `/collections/${COLLECTION}/points/scroll`, body);
const points = res.result?.points || [];
if (points.length === 0) { console.log('📭 Collection vide.'); return; }
console.log(`📋 ${points.length} souvenir(s) :\n`);
points.forEach(p => {
const pl = p.payload;
console.log(`• [${pl.type}|${pl.importance}] ${pl.date?.slice(0,10)}${pl.text?.slice(0,100)}${pl.text?.length>100?'...':''}`);
});
} }
async function cmdDelete(id) { async function cmdDelete(id) {
const { browser, req } = await getRequestContext(); const res = await qdrant('POST', `/collections/${COLLECTION}/points/delete`, { points: [id] });
try { console.log(res.status === 'ok' ? `🗑️ Point ${id} supprimé.` : `❌ Erreur: ${JSON.stringify(res)}`);
const r = await req.post(`${QDRANT_URL}/collections/${COLLECTION}/points/delete`, {
data: { points: [id] }
});
const body = await r.json();
console.log(body.status === 'ok' ? `🗑️ Point ${id} supprimé.` : `❌ Erreur: ${JSON.stringify(body)}`);
} finally {
await browser.close();
}
} }
async function cmdStats() { async function cmdStats() {
const { browser, req } = await getRequestContext(); const res = await qdrant('GET', `/collections/${COLLECTION}`);
try { const info = res.result;
const r = await req.get(`${QDRANT_URL}/collections/${COLLECTION}`); console.log('📊 Collection nox-memory :');
const body = await r.json(); console.log(` Points : ${info.points_count}`);
const info = body.result; console.log(` Segments : ${info.segments_count}`);
console.log('📊 Collection nox-memory :'); console.log(` Statut : ${info.status}`);
console.log(` Points : ${info.points_count}`); console.log(` Modèle : ${EMBED_MODEL} (${EMBED_DIM} dims)`);
console.log(` Vecteurs : ${info.vectors_count}`);
console.log(` Segments : ${info.segments_count}`);
console.log(` Statut : ${info.status}`);
console.log(` Dim. : ${EMBED_DIM} (${EMBED_MODEL})`);
} finally {
await browser.close();
}
} }
async function cmdImportMd(file) { async function cmdImportMd(file) {
const fs = require('fs'); const fs = require('fs');
const text = fs.readFileSync(file, 'utf8'); const text = fs.readFileSync(file, 'utf8');
// Découper en sections par ## ou ### ou lignes non vides significatives
const chunks = []; const chunks = [];
let current = ''; let current = '';
for (const line of text.split('\n')) { for (const line of text.split('\n')) {
@@ -185,19 +150,15 @@ async function cmdImportMd(file) {
} }
} }
if (current.trim()) chunks.push(current.trim()); if (current.trim()) chunks.push(current.trim());
const valid = chunks.filter(c => c.length > 20); const valid = chunks.filter(c => c.length > 20);
console.log(`📥 Import de ${valid.length} chunks depuis ${file}...`); console.log(`📥 Import de ${valid.length} chunks depuis ${file}...`);
for (let i = 0; i < valid.length; i++) { for (let i = 0; i < valid.length; i++) {
const chunk = valid[i];
process.stdout.write(` [${i+1}/${valid.length}] `); process.stdout.write(` [${i+1}/${valid.length}] `);
try { try {
await cmdAdd(chunk, { type: 'semantic', importance: '4', tags: 'memory.md,import' }); await cmdAdd(valid[i], { type: 'semantic', importance: '4', tags: 'memory.md,import' });
} catch(e) { } catch(e) {
console.error('Erreur:', e.message); console.error('Erreur:', e.message);
} }
// Pause pour éviter rate limit OpenAI
await new Promise(r => setTimeout(r, 300)); await new Promise(r => setTimeout(r, 300));
} }
console.log('\n✅ Import terminé !'); console.log('\n✅ Import terminé !');
@@ -208,8 +169,6 @@ async function cmdImportMd(file) {
async function main() { async function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const cmd = args[0]; const cmd = args[0];
// Parser les options --key value
const opts = {}; const opts = {};
const positional = []; const positional = [];
for (let i = 1; i < args.length; i++) { for (let i = 1; i < args.length; i++) {
@@ -248,21 +207,20 @@ async function main() {
console.log(` console.log(`
nox-memory — Mémoire vectorielle (Qdrant + OpenAI embeddings) nox-memory — Mémoire vectorielle (Qdrant + OpenAI embeddings)
Commandes :
add "texte" Ajouter un souvenir add "texte" Ajouter un souvenir
--type fact | semantic | preference | episodic (défaut: fact) --type fact | semantic | preference | episodic (défaut: fact)
--tags "tag1,tag2" --tags "tag1,tag2"
--importance 1-5 (défaut: 3) --importance 1-5 (défaut: 3)
search "query" Recherche sémantique search "query" Recherche sémantique
--limit Nombre de résultats (défaut: 5) --limit Résultats (défaut: 5)
--type Filtrer par type --type Filtrer par type
list Lister les souvenirs list Lister les souvenirs
--limit Nombre (défaut: 20) --limit Nombre (défaut: 20)
--type Filtrer par type --type Filtrer par type
delete <id> Supprimer un point par ID delete <id> Supprimer un point
stats Stats de la collection stats Stats de la collection
import-md <file> Importer un fichier Markdown import-md <file> Importer un fichier Markdown
`); `);