commit b8ef4c771bd7f0289dd44779f9e2f4c24cba25ac Author: Nox Date: Sun Feb 22 17:14:27 2026 +0000 feat: mémoire vectorielle Qdrant (nox-memory.js) + import MEMORY.md diff --git a/MEMORY.md b/MEMORY.md new file mode 100644 index 0000000..1b01ec3 --- /dev/null +++ b/MEMORY.md @@ -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=`) — 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 `![alt](http://192.168.1.150:3923/chemin/image.png)` 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/` + - 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/" --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: " -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 ` + - Audio pour Whisper : `yt-dlp -x --audio-format mp3 -o workspace/audio.mp3 ` + +## 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":"","description":""}' "$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/` diff --git a/nox-memory.js b/nox-memory.js new file mode 100644 index 0000000..504b349 --- /dev/null +++ b/nox-memory.js @@ -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 + * node nox-memory.js import-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 '); process.exit(1); } + await cmdDelete(positional[0]); + break; + case 'stats': + await cmdStats(); + break; + case 'import-md': + if (!positional[0]) { console.error('Usage: import-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 Supprimer un point par ID + stats Stats de la collection + import-md Importer un fichier Markdown +`); + } +} + +main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });