feat: mémoire vectorielle Qdrant (nox-memory.js) + import MEMORY.md
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
# MEMORY.md — Nox 🌑
|
||||||
|
|
||||||
|
## Christophe
|
||||||
|
- Habite Montlieu-La-Garde, Charente-Maritime (17), axe Bordeaux-Angoulême N10
|
||||||
|
- Fuseau horaire : Europe/Paris
|
||||||
|
- Langue : français
|
||||||
|
|
||||||
|
## Home Assistant
|
||||||
|
- URL : http://192.168.1.40:8123
|
||||||
|
- **Lumières** :
|
||||||
|
- `light.dimmer_2` → **Entrée**
|
||||||
|
- `light.bar` → **Bar**
|
||||||
|
- `light.bibliotheque` → **Bibliothèque**
|
||||||
|
- `light.dimmer_salon` → **Salon**
|
||||||
|
- `light.mezzanine` → **Mezzanine**
|
||||||
|
- **Caméras** :
|
||||||
|
- `camera.onvif_ptz` → **Caméra Extérieur** (double vue, couleur, parking/cour)
|
||||||
|
- `camera.fi9821ep` → **Caméra Salon** (Foscam, intérieur)
|
||||||
|
- `camera.camera_ndeg7` → **Caméra n°7** (intérieur, sous-sol/atelier)
|
||||||
|
- `camera.camera_jarnac_rdc_7` → **Caméra Jarnac RDC 7** (intérieur)
|
||||||
|
- `camera.foscam` → **Foscam** (intérieur, entrée/pièce de vie)
|
||||||
|
- `camera.klipper_webcam` → **Ender 3 Webcam** (imprimante 3D)
|
||||||
|
- `camera.predator_predator` → **Predator**
|
||||||
|
- `camera.nono_none` → **Nono**
|
||||||
|
- **Media Players** :
|
||||||
|
- `media_player.shield` → **SHIELD CUISINE**
|
||||||
|
- `media_player.android_tv_cuisine` → **Android TV Cuisine**
|
||||||
|
- `media_player.shield_salon` → **SHIELD SALON**
|
||||||
|
- `media_player.android_tv_salon` → **Android TV Salon**
|
||||||
|
- `media_player.denon_avr_x3400h` → **Denon AVR-X3400H**
|
||||||
|
|
||||||
|
|
||||||
|
## Proxmox
|
||||||
|
- URL : https://192.168.1.250:8006
|
||||||
|
- Token : variable d'env `PVE_TOKEN` (format `root@pam!openclaw=<uuid>`) — déjà dans .env + docker-compose override
|
||||||
|
- Accès API : `curl -sk -H "Authorization: PVEAPIToken=$PVE_TOKEN" "$PVE_URL/api2/json/nodes"`
|
||||||
|
- **Parser JSON avec Node.js** (pas jq — permission denied dans le conteneur)
|
||||||
|
- Nodes : mini-pc, ts-651, pve, z820
|
||||||
|
|
||||||
|
## Proxmox Backup Server (PBS)
|
||||||
|
- URL : https://192.168.1.91:8007
|
||||||
|
- Token : variables d'env `PBS_TOKEN_ID` + `PBS_TOKEN_SECRET` — déjà dans .env + docker-compose override
|
||||||
|
- Accès API : `curl -sk -H "Authorization: PBSAPIToken=$PBS_TOKEN_ID:$PBS_TOKEN_SECRET" "$PBS_URL/api2/json/status/datastore-usage"`
|
||||||
|
- Datastore : `backups_on_ts651` (1.26 TB total)
|
||||||
|
|
||||||
|
## Outils & Préférences
|
||||||
|
- **Génération d'images** : utiliser fal.ai (FAL_KEY), PAS OpenAI
|
||||||
|
- `fal-ai/flux/schnell` — génération rapide, bon pour photos/art
|
||||||
|
- `fal-ai/nano-banana-pro` — Gemini 3 Pro Image, bon pour affiches/texte/édition d'images
|
||||||
|
- **Captures d'écran** : utiliser Playwright Chrome CLI (`/home/node/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome --headless --no-sandbox --disable-gpu --disable-dev-shm-usage --screenshot=...`) car l'outil browser fait des captures blanches (mode `attachOnly`)
|
||||||
|
- **Toujours sauvegarder dans le workspace** (`/home/node/.openclaw/workspace/`) — `/tmp` est bloqué par la sécurité OpenClaw pour l'envoi de fichiers via Telegram
|
||||||
|
- Les erreurs `dbus` en conteneur sont normales et sans impact
|
||||||
|
- Pour **interagir avec une page** (cliquer sur cookies, boutons...) : utiliser **Playwright Node.js** installé dans le workspace (`/home/node/.openclaw/workspace/node_modules/playwright`) avec `executablePath` pointant vers le chrome Playwright
|
||||||
|
- **TTS** : Edge, voix fr-FR-VivienneMultilingualNeural
|
||||||
|
- **Transcription audio** : Groq Whisper
|
||||||
|
|
||||||
|
## Anytype
|
||||||
|
- Instance self-hosted : http://192.168.1.150:31009
|
||||||
|
- Espace principal : **OpenClaw** (id: `bafyreigt3wmpnm2qduzijfubftw5ixrhqfrjrc2yi6hq2e4cpw6yer7hqq.25d1im923toai`)
|
||||||
|
- Skill custom dans `/home/node/.openclaw/workspace/skills/anytype/`
|
||||||
|
- Utiliser cet espace pour tout ce qui concerne Christophe et moi
|
||||||
|
- **Images** : ✅ ÇA MARCHE ! Anytype télécharge et internalise les images depuis une URL externe
|
||||||
|
- Méthode : inclure `` dans le `body` markdown lors d'un **POST** (création d'objet)
|
||||||
|
- Anytype récupère l'image, lui donne un ID interne (`bafyrei...`) et la sert via `http://127.0.0.1:47800/image/<id>`
|
||||||
|
- L'image doit être accessible depuis le serveur Anytype (même réseau local)
|
||||||
|
- **PATCH** : utiliser le champ `"markdown"` (PAS `"body"`) pour modifier le contenu existant !
|
||||||
|
- Le format icon doit être `{"format":"emoji","name":"🧪"}` et non une string simple
|
||||||
|
- **POST** (création) : utiliser `"body"` pour le contenu
|
||||||
|
- **PATCH** (modification) : utiliser `"markdown"` pour le contenu
|
||||||
|
|
||||||
|
## CopyParty (stockage fichiers)
|
||||||
|
- URL : http://192.168.1.150:3923
|
||||||
|
- Upload simple via `curl -X PUT "http://192.168.1.150:3923/<chemin>" --data-binary @fichier`
|
||||||
|
- Pas d'authentification requise
|
||||||
|
- Dossier `/anytype/` créé pour les fichiers liés à Anytype
|
||||||
|
- Utilisable pour héberger images, schémas, etc. avec lien direct
|
||||||
|
|
||||||
|
## Podcasts & Vidéos — Transcription
|
||||||
|
- Je peux **récupérer et transcrire** des podcasts/vidéos en ligne
|
||||||
|
- **Méthode :**
|
||||||
|
1. Utiliser **Playwright** pour intercepter les requêtes réseau et trouver l'URL du fichier audio (.mp3)
|
||||||
|
2. Télécharger le MP3 avec `curl -sL -A "Mozilla/5.0..." -H "Referer: <site>" <url> -o fichier.mp3`
|
||||||
|
3. Transcrire avec **Groq Whisper** : `curl -X POST https://api.groq.com/openai/v1/audio/transcriptions -H "Authorization: Bearer $GROQ_API_KEY" -F "file=@fichier.mp3" -F "model=whisper-large-v3-turbo" -F "language=fr" -F "response_format=text"`
|
||||||
|
- Testé avec succès sur BFM Business / Simplecast (podcasts hébergés sur simplecastaudio.com)
|
||||||
|
- Sauvegarder les fichiers dans le workspace, pas dans /tmp
|
||||||
|
- **YouTube** : utiliser **yt-dlp** (binaire dans `/home/node/.openclaw/workspace/yt-dlp`)
|
||||||
|
- Transcript via API YouTube (sous-titres auto) : `yt-dlp --write-auto-sub --skip-download --sub-lang fr -o workspace/transcript <url>`
|
||||||
|
- Audio pour Whisper : `yt-dlp -x --audio-format mp3 -o workspace/audio.mp3 <url>`
|
||||||
|
|
||||||
|
## Mémoire Vectorielle (Qdrant)
|
||||||
|
- **Collection** : `nox-memory` sur Qdrant (`http://192.168.1.150:6333`)
|
||||||
|
- **Script** : `/home/node/.openclaw/workspace/nox-memory.js`
|
||||||
|
- **Modèle** : `text-embedding-3-small` (OpenAI, 1536 dims, Cosine)
|
||||||
|
- **Accès** : via Playwright (curl refusé depuis le LXC par iptables Docker)
|
||||||
|
- **Usage** :
|
||||||
|
```bash
|
||||||
|
node nox-memory.js add "texte" --type fact|semantic|preference|episodic --tags "t1,t2" --importance 1-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 stats
|
||||||
|
node nox-memory.js import-md MEMORY.md
|
||||||
|
```
|
||||||
|
- MEMORY.md déjà importé (9 chunks, 2026-02-22)
|
||||||
|
- **Utiliser en priorité** pour les recherches contextuelles (memory_search reste utile pour le démarrage de session)
|
||||||
|
|
||||||
|
## Leçons apprises
|
||||||
|
- **Toujours prendre un snapshot du LXC 145** (`mini-pc`) avant toute modification de config ou installation qui pourrait casser OpenClaw
|
||||||
|
- Commande : `curl -sk -X POST -H "Authorization: PVEAPIToken=$PVE_TOKEN" -H "Content-Type: application/json" -d '{"snapname":"<nom>","description":"<desc>"}' "$PVE_URL/api2/json/nodes/mini-pc/lxc/145/snapshot"`
|
||||||
|
- Toujours vérifier les `friendly_name` dans HA pour trouver les entités
|
||||||
|
- Les skills custom sont dans `/home/node/.openclaw/workspace/skills/`
|
||||||
+272
@@ -0,0 +1,272 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* nox-memory.js — Mémoire vectorielle de Nox via Qdrant
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node nox-memory.js add "texte à mémoriser" --type fact --tags "ha,lumiere" --importance 3
|
||||||
|
* node nox-memory.js search "question en langage naturel" --limit 5
|
||||||
|
* node nox-memory.js list [--type fact] [--limit 20]
|
||||||
|
* node nox-memory.js delete <id>
|
||||||
|
* node nox-memory.js import-md <fichier.md> ← importe MEMORY.md
|
||||||
|
* node nox-memory.js stats
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { chromium } = require('./node_modules/playwright');
|
||||||
|
|
||||||
|
const QDRANT_URL = 'http://192.168.1.150:6333';
|
||||||
|
const COLLECTION = 'nox-memory';
|
||||||
|
const OPENAI_URL = 'https://api.openai.com/v1/embeddings';
|
||||||
|
const EMBED_MODEL = 'text-embedding-3-small';
|
||||||
|
const EMBED_DIM = 1536;
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getRequestContext() {
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath: '/home/node/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome',
|
||||||
|
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
|
||||||
|
});
|
||||||
|
const context = await browser.newContext();
|
||||||
|
return { browser, req: context.request };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function embed(text) {
|
||||||
|
const res = await fetch(OPENAI_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ model: EMBED_MODEL, input: text })
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json.data) throw new Error('Embedding failed: ' + JSON.stringify(json));
|
||||||
|
return json.data[0].embedding;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
// UUID v4 simplifié
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Commandes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function cmdAdd(text, opts) {
|
||||||
|
const { browser, req } = await getRequestContext();
|
||||||
|
try {
|
||||||
|
const vector = await embed(text);
|
||||||
|
const id = genId();
|
||||||
|
const payload = {
|
||||||
|
text,
|
||||||
|
type: opts.type || 'fact',
|
||||||
|
tags: opts.tags ? opts.tags.split(',').map(t => t.trim()) : [],
|
||||||
|
importance: parseInt(opts.importance || '3'),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
date: new Date().toISOString()
|
||||||
|
};
|
||||||
|
const r = await req.put(`${QDRANT_URL}/collections/${COLLECTION}/points`, {
|
||||||
|
data: { points: [{ id, vector, payload }] }
|
||||||
|
});
|
||||||
|
const body = await r.json();
|
||||||
|
if (body.status === 'ok') {
|
||||||
|
console.log(`✅ Mémorisé [${id}]`);
|
||||||
|
console.log(` type: ${payload.type} | importance: ${payload.importance} | tags: ${payload.tags.join(', ') || 'aucun'}`);
|
||||||
|
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) {
|
||||||
|
const { browser, req } = await getRequestContext();
|
||||||
|
try {
|
||||||
|
const vector = await embed(query);
|
||||||
|
const limit = parseInt(opts.limit || '5');
|
||||||
|
const body = {
|
||||||
|
vector,
|
||||||
|
limit,
|
||||||
|
with_payload: true,
|
||||||
|
score_threshold: 0.3
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdList(opts) {
|
||||||
|
const { browser, req } = await getRequestContext();
|
||||||
|
try {
|
||||||
|
const limit = parseInt(opts.limit || '20');
|
||||||
|
const body = { limit, with_payload: true };
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdDelete(id) {
|
||||||
|
const { browser, req } = await getRequestContext();
|
||||||
|
try {
|
||||||
|
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() {
|
||||||
|
const { browser, req } = await getRequestContext();
|
||||||
|
try {
|
||||||
|
const r = await req.get(`${QDRANT_URL}/collections/${COLLECTION}`);
|
||||||
|
const body = await r.json();
|
||||||
|
const info = body.result;
|
||||||
|
console.log('📊 Collection nox-memory :');
|
||||||
|
console.log(` Points : ${info.points_count}`);
|
||||||
|
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) {
|
||||||
|
const fs = require('fs');
|
||||||
|
const text = fs.readFileSync(file, 'utf8');
|
||||||
|
// Découper en sections par ## ou ### ou lignes non vides significatives
|
||||||
|
const chunks = [];
|
||||||
|
let current = '';
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
if ((line.startsWith('## ') || line.startsWith('### ')) && current.trim()) {
|
||||||
|
chunks.push(current.trim());
|
||||||
|
current = line;
|
||||||
|
} else {
|
||||||
|
current += '\n' + line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current.trim()) chunks.push(current.trim());
|
||||||
|
|
||||||
|
const valid = chunks.filter(c => c.length > 20);
|
||||||
|
console.log(`📥 Import de ${valid.length} chunks depuis ${file}...`);
|
||||||
|
|
||||||
|
for (let i = 0; i < valid.length; i++) {
|
||||||
|
const chunk = valid[i];
|
||||||
|
process.stdout.write(` [${i+1}/${valid.length}] `);
|
||||||
|
try {
|
||||||
|
await cmdAdd(chunk, { type: 'semantic', importance: '4', tags: 'memory.md,import' });
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Erreur:', e.message);
|
||||||
|
}
|
||||||
|
// Pause pour éviter rate limit OpenAI
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
}
|
||||||
|
console.log('\n✅ Import terminé !');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const cmd = args[0];
|
||||||
|
|
||||||
|
// Parser les options --key value
|
||||||
|
const opts = {};
|
||||||
|
const positional = [];
|
||||||
|
for (let i = 1; i < args.length; i++) {
|
||||||
|
if (args[i].startsWith('--')) {
|
||||||
|
opts[args[i].slice(2)] = args[i+1];
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
positional.push(args[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (cmd) {
|
||||||
|
case 'add':
|
||||||
|
if (!positional[0]) { console.error('Usage: add "texte"'); process.exit(1); }
|
||||||
|
await cmdAdd(positional[0], opts);
|
||||||
|
break;
|
||||||
|
case 'search':
|
||||||
|
if (!positional[0]) { console.error('Usage: search "query"'); process.exit(1); }
|
||||||
|
await cmdSearch(positional[0], opts);
|
||||||
|
break;
|
||||||
|
case 'list':
|
||||||
|
await cmdList(opts);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
if (!positional[0]) { console.error('Usage: delete <id>'); process.exit(1); }
|
||||||
|
await cmdDelete(positional[0]);
|
||||||
|
break;
|
||||||
|
case 'stats':
|
||||||
|
await cmdStats();
|
||||||
|
break;
|
||||||
|
case 'import-md':
|
||||||
|
if (!positional[0]) { console.error('Usage: import-md <fichier.md>'); process.exit(1); }
|
||||||
|
await cmdImportMd(positional[0]);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(`
|
||||||
|
nox-memory — Mémoire vectorielle (Qdrant + OpenAI embeddings)
|
||||||
|
|
||||||
|
Commandes :
|
||||||
|
add "texte" Ajouter un souvenir
|
||||||
|
--type fact | semantic | preference | episodic (défaut: fact)
|
||||||
|
--tags "tag1,tag2"
|
||||||
|
--importance 1-5 (défaut: 3)
|
||||||
|
|
||||||
|
search "query" Recherche sémantique
|
||||||
|
--limit Nombre de résultats (défaut: 5)
|
||||||
|
--type Filtrer par type
|
||||||
|
|
||||||
|
list Lister les souvenirs
|
||||||
|
--limit Nombre (défaut: 20)
|
||||||
|
--type Filtrer par type
|
||||||
|
|
||||||
|
delete <id> Supprimer un point par ID
|
||||||
|
stats Stats de la collection
|
||||||
|
import-md <file> Importer un fichier Markdown
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });
|
||||||
Reference in New Issue
Block a user