feat: Add MCP server and smoke test scripts

- Introduced `mcp-server.js` to expose KXKM personas as MCP tools, supporting chat, persona listing, web search, and status checks.
- Implemented `mcp-server-smoke.js` for testing the MCP server functionality, ensuring compatibility with both new and legacy message formats.
- Created `setup-voice-clone.sh` for managing voice cloning environment setup, including bootstrapping, sample generation, and smoke testing.
- Added `state.json` to track project status and task outputs for various batches.
- Generated summary files for deep cycle and overall project status, capturing performance and security findings.
This commit is contained in:
L'électron rare
2026-03-19 16:18:44 +01:00
parent 332eebf003
commit d2fed6087a
172 changed files with 19624 additions and 3105 deletions
+26
View File
@@ -0,0 +1,26 @@
{
"permissions": {
"allow": [
"Bash(ssh kxkm@kxkm-ai \"cd /home/kxkm/KXKM_Clown && docker compose --profile v2 up -d api 2>&1 && sleep 5 && docker exec kxkm_clown-api-1 python3 -c ''import transformers; print\\(\"\"transformers\"\", transformers.__version__\\)'' 2>&1 && curl -s http://localhost:3333/api/v2/health | head -c 50\")",
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 pip3 install --break-system-packages transformers accelerate 2>&1 | tail -5\")",
"Bash(ssh kxkm@kxkm-ai \"docker commit kxkm_clown-api-1 kxkm_clown-api:with-ml 2>&1 && docker tag kxkm_clown-api:with-ml kxkm_clown-api:latest 2>&1 && echo IMAGE SAVED\")",
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 python3 -c ''import transformers; print\\(\"\"OK\"\", transformers.__version__\\)'' 2>&1 && curl -s http://localhost:3333/api/v2/health | head -c 50\")",
"Bash(sort -k2 -rn)",
"Bash(find packages/*/src -name \"*.ts\" -exec wc -l {} +)",
"Bash(find apps/*/src -name \"*.ts\" -exec wc -l {} +)",
"Bash(ssh kxkm@kxkm-ai \"source ~/.nvm/nvm.sh && cd /home/kxkm/KXKM_Clown && git checkout -- . 2>/dev/null; git pull origin main 2>&1 | tail -5 && npm run -w @kxkm/web build 2>&1 | tail -3 && npm run -w @kxkm/api build 2>&1 | tail -3 && npm run build 2>&1 | tail -3 && docker compose --profile v2 build api 2>&1 | tail -3 && docker compose --profile v2 up -d api 2>&1 && echo DEPLOYED\")",
"Bash(ssh kxkm@kxkm-ai 'source ~/.nvm/nvm.sh && cd /home/kxkm/KXKM_Clown && timeout 25 node -e \"\":*)",
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 sh -c ''grep -o \"\"F6\\\\|F7\\\\|Media\\\\|media-explorer\\\\|vtx-progress\\\\|vtx-track\\\\|vtx-viewer\\\\|MEDIATHEQUE\"\" /app/apps/web/dist/assets/index-W6N2scKN.js | sort -u''\")",
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 sh -c ''grep -o \"\"media-explorer\\\\|media-grid\\\\|media-playlist\\\\|vtx-progress-bar\\\\|vtx-viewer\\\\|voice-level-bar\\\\|compose-page\\\\|imagine-page\"\" /app/apps/web/dist/assets/index-DsjUsnfb.css | sort -u''\")",
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 grep -c ''media-store\\\\|media\\\\.js\\\\|/api/v2/media'' /app/apps/api/dist/app.js /app/apps/api/dist/server.js 2>&1\")",
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 ls /app/apps/api/dist/routes/ 2>&1 && docker exec kxkm_clown-api-1 grep ''stripThinking\\\\|<think>'' /app/apps/api/dist/ws-ollama.js | head -5\")",
"Bash(ssh kxkm@kxkm-ai \"source ~/.nvm/nvm.sh && cd /home/kxkm/KXKM_Clown && echo ''=== GIT ==='' && git log --oneline -3 && echo ''=== DIFF ==='' && git diff --stat HEAD | head -10 && echo ''=== BUILD CHECK ==='' && grep -c ''F6\\\\|F7\\\\|MEDIATHEQUE\\\\|media-explorer\\\\|vtx-progress'' apps/web/dist/assets/*.js 2>/dev/null && echo ''=== CONTAINER ==='' && docker exec kxkm_clown-api-1 grep -c ''F6\\\\|F7\\\\|MEDIATHEQUE'' /app/apps/web/dist/assets/*.js 2>/dev/null\")",
"Bash(ssh kxkm@kxkm-ai \"cd /home/kxkm/KXKM_Clown && git stash 2>/dev/null; git fetch origin main && git reset --hard origin/main 2>&1 | tail -5\")",
"Bash(ssh kxkm@kxkm-ai \"source ~/.nvm/nvm.sh && cd /home/kxkm/KXKM_Clown && npx tsc -b tsconfig.v2.json 2>&1 | tail -3 && npm run -w @kxkm/web build 2>&1 | tail -5 && npm run -w @kxkm/api build 2>&1 | tail -3 && npm run build 2>&1 | tail -3\")",
"Bash(ssh kxkm@kxkm-ai \"cd /home/kxkm/KXKM_Clown && grep -o ''F6\\\\|F7\\\\|MEDIATHEQUE\\\\|media-explorer\\\\|vtx-progress\\\\|vtx-viewer\\\\|vtx-track'' apps/web/dist/assets/index-D7Uj4-gG.js | sort -u\")",
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 ls /app/apps/web/dist/assets/ && docker exec kxkm_clown-api-1 sh -c ''grep -c MEDIATHEQUE /app/apps/web/dist/assets/index-D7Uj4-gG.js''\")",
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 python3 -c ''import transformers; print\\(\"\"OK\"\"\\)'' 2>&1 || docker exec kxkm_clown-api-1 pip3 install --break-system-packages --no-cache-dir transformers accelerate 2>&1 | tail -3\")",
"Bash(ssh kxkm@kxkm-ai \"docker exec kxkm_clown-api-1 pip3 install --break-system-packages --no-cache-dir scipy 2>&1 | tail -3\")"
]
}
}
Submodule .claude/worktrees/agent-aaaeff51 added at 06b2e96a43
+2
View File
@@ -4,6 +4,8 @@ dist/
.DS_Store
*.log
__pycache__/
*.pyc
# Secrets
.env
+59
View File
@@ -0,0 +1,59 @@
# EXECUTION STATUS (kxkm-clown-v2)
Updated: 2026-03-17T22:06:31Z
## lot-0-cadrage
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: docs-reviewed
- Open tasks: none
## lot-1-socle
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: npm run check:v2, npm run test:v2
- Open tasks: none
## lot-2-domaines
- Status: done
- Owner: Backend API
- Execution: managed
- Checks: npm run test:v2
- Open tasks: none
## lot-3-surfaces
- Status: done
- Owner: Frontend
- Execution: managed
- Checks: npm run -w @kxkm/web check
- Open tasks: none
## lot-4-bascule
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: npm run smoke:v2
- Open tasks: none
## lot-12-deep-audit
- Status: done
- Owner: Coordinateur
- Execution: manual
- Checks: npm run check:v2, npm run test:v2, npm run -w @kxkm/web test, node ops/v2/deep-audit.js --json
- Open tasks: none
## lot-13-voice-mcp
- Status: done
- Owner: Multimodal
- Execution: manual
- Checks: node scripts/mcp-server-smoke.js, npm run smoke:voice-mcp, npm run smoke:voice-clone, bash ops/v2/run-spike-checks.sh voice-clone --yes
- Open tasks: none
## lot-14-documents-search
- Status: done
- Owner: Ops/TUI
- Execution: manual
- Checks: docker compose --profile v2 config --services, bash scripts/health-doc-search.sh search --strict, npm run smoke:documents-search, npm run smoke:embeddings, bash ops/v2/run-spike-checks.sh embeddings --yes
- Open tasks: none
+13 -8
View File
@@ -48,11 +48,11 @@ Par defaut, Ollama est attendu en natif sur le host (port 11434).
### Chat multimodal
- **Interface Minitel** — Animation modem 3615 ULLA → login → chat (esthetique phosphore CRT)
- **Chat temps reel** — WebSocket `/ws`, streaming LLM, 26 personas
- **Chat temps reel** — WebSocket `/ws`, streaming LLM, 33 personas
- **RAG local** — Embeddings Ollama (`nomic-embed-text`), contexte manifeste
- **Vision** — Analyse d'images via `qwen3-vl:8b` (upload dans le chat)
- **STT** — Transcription audio via `faster-whisper` (upload audio)
- **TTS** — Piper-tts (fallback rapide) + XTTS-v2 (voice cloning per persona)
- **TTS** — Piper-tts + Chatterbox (dual backend via TTS sidecar HTTP :9100)
- **PDF** — Extraction via Docling/PyMuPDF (tables, layout, OCR)
- **Recherche web** — SearXNG self-hosted + DuckDuckGo fallback
- **Generation musicale** — `/compose` via ACE-Step 1.5 / MusicGen
@@ -80,10 +80,11 @@ Par defaut, Ollama est attendu en natif sur le host (port 11434).
### Personas
- 26 personas (musique, arts, sciences, philosophie, ecologie, tech, cinema)
- 33 personas (musique, arts, sciences, philosophie, ecologie, tech, cinema)
- Pipeline editorial: source → feedback → proposals → apply/revert
- Pharmacius: orchestrateur editorial automatique (mistral:7b)
- Vue arborescente par modele (refermee par defaut)
- Pharmacius: routeur principal (qwen3:8b, maxTokens:600, think-strip)
- Inter-persona @mention depth 3, 2s delay
- Modeles: qwen3:8b x21, mistral:7b x7, gemma3:4b x4, qwen3-vl:8b (vision)
## Variables d'environnement
@@ -99,7 +100,7 @@ Par defaut, Ollama est attendu en natif sur le host (port 11434).
| `ADMIN_SUBNET` | (vide) | CIDR autorise pour admin V2 |
| `MAX_GENERAL_RESPONDERS` | `4` | Nombre max de personas repondant dans #general |
| `OWNER_NICK` | (vide) | Pseudo du proprietaire |
| `VISION_MODEL` | `minicpm-v` | Modele Ollama pour analyse d'images |
| `VISION_MODEL` | `qwen3-vl:8b` | Modele Ollama pour analyse d'images |
| `TTS_ENABLED` | `0` | Activer la synthese vocale (`1` pour activer) |
| `WEB_SEARCH_API_BASE` | (vide) | Endpoint API de recherche web custom |
| `PYTHON_BIN` | `python3` | Python avec libs ML (PyTorch, faster-whisper, piper-tts) |
@@ -244,7 +245,7 @@ kxkm_clown/
| --- | --- | --- |
| Chat temps reel | operationnel | operationnel |
| RAG local | n/a | operationnel |
| Vision (minicpm-v) | n/a | operationnel |
| Vision (qwen3-vl:8b) | n/a | operationnel |
| STT (faster-whisper) | n/a | operationnel |
| TTS (piper-tts) | n/a | operationnel |
| PDF extraction | n/a | operationnel |
@@ -260,7 +261,11 @@ kxkm_clown/
| RBAC | n/a | operationnel |
| Frontend React | n/a | operationnel |
| Training (TRL/Unsloth) | n/a | operationnel |
| Tests (135+) | smoke | unit + component + smoke |
| Tests (204) | smoke | unit + component + smoke |
| VoiceChat push-to-talk | n/a | operationnel |
| Mediatheque gallery/playlist | n/a | operationnel |
| UI Minitel VIDEOTEX | n/a | operationnel |
| Deploy tmux (deploy.sh) | n/a | operationnel |
Notes runtime V2:
+59
View File
@@ -0,0 +1,59 @@
# EXECUTION STATUS (kxkm-clown-v2)
Updated: 2026-03-17T22:06:31Z
## lot-0-cadrage
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: docs-reviewed
- Open tasks: none
## lot-1-socle
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: npm run check:v2, npm run test:v2
- Open tasks: none
## lot-2-domaines
- Status: done
- Owner: Backend API
- Execution: managed
- Checks: npm run test:v2
- Open tasks: none
## lot-3-surfaces
- Status: done
- Owner: Frontend
- Execution: managed
- Checks: npm run -w @kxkm/web check
- Open tasks: none
## lot-4-bascule
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: npm run smoke:v2
- Open tasks: none
## lot-12-deep-audit
- Status: done
- Owner: Coordinateur
- Execution: manual
- Checks: npm run check:v2, npm run test:v2, npm run -w @kxkm/web test, node ops/v2/deep-audit.js --json
- Open tasks: none
## lot-13-voice-mcp
- Status: done
- Owner: Multimodal
- Execution: manual
- Checks: node scripts/mcp-server-smoke.js, npm run smoke:voice-mcp, npm run smoke:voice-clone, bash ops/v2/run-spike-checks.sh voice-clone --yes
- Open tasks: none
## lot-14-documents-search
- Status: done
- Owner: Ops/TUI
- Execution: manual
- Checks: docker compose --profile v2 config --services, bash scripts/health-doc-search.sh search --strict, npm run smoke:documents-search, npm run smoke:embeddings, bash ops/v2/run-spike-checks.sh embeddings --yes
- Open tasks: none
+204 -41
View File
@@ -1,54 +1,217 @@
# TODO (kxkm-clown-v2)
# TODO
Updated: 2026-03-18T21:30:00Z
## P0 Critical (sécurité & stabilité)
## Lots termines
- [x] Fix bash injection dans `node-engine-runtimes.js` — validation runtimeId/nodeType + timeout 30min
- [x] Ajouter timeout sur les appels Ollama — 15s metadata, 5min chat streaming
- [x] Validation des entrées sur les messages WebSocket — 64KB max frame, 8192 chars text, type checks
- [x] Rate limiting par user/IP sur chat — `rate-limit.js` + 30 msg/min par IP dans WebSocket
- [x] `escapeHtml` dédupliqué → `utils.js`
- [x] `normalizeAuth` consolidé dans `admin-api.js`
- [x] `ensureSeedGraphs` guard flag ajouté
- [x] `finishRun` comptage d'artifacts sans JSON parse
- [x] `recoverRunnableRuns` double-read corrigé
- [x] lot-0 cadrage
- [x] lot-1 socle
- [x] lot-2 domaines
- [x] lot-3 surfaces
- [x] lot-4 bascule
- [x] lot-12 deep-audit
- [x] lot-13 voice-mcp
- [x] lot-14 documents-search
- [x] lot-16 minitel-ui
- [x] lot-17 chat-fixes
- [x] lot-18 media-tts
- [x] lot-19 infra
- [x] lot-20 deep-audit-2
## P1 V1 Quality
## lot-21-chatterbox-tts (P1)
- [x] Migrer vers le SDK officiel `ollama-js` — fait en P7 (`ollama.js` réécrit)
- [x] Ajouter un audit logging pour les actions admin — `audit-log.js` + intégré dans `http-api.js` et `server.js`
- [x] Implémenter l'analyse image/audio dans `attachment-pipeline.js` — stubs factory avec adapter slot
- [x] Corriger la validation d'origine `postMessage` — déjà en place (personas.js:1476)
- [x] Ajouter la déduplication de requêtes dans `admin-api.js` — fait en P7 (`deduplicatedFetch`)
- [x] Node Engine : validation de tri topologique — déjà en place (cycle detection dans runner)
- [x] Node Engine : timeout d'exécution par nœud — 10min default via `NODE_ENGINE_STEP_TIMEOUT_MS`
- [ ] Installer Chatterbox sur kxkm-ai | owner: Multimodal
- [ ] Adapter tts-server.py backend Chatterbox | owner: Multimodal
- [ ] Tester qualite vocale 33 personas | owner: Multimodal
- [ ] Benchmark latence <500ms/100chars | owner: Multimodal
## P2 V2 Domaines
## lot-22-graph-rag (P2)
- [x] Schéma Postgres + migrations + repos typés (`packages/storage`) — session, persona, graph, run repos
- [x] Module auth réel (`packages/auth`) — crypto.scrypt, token gen, extractSessionId, validateLoginInput
- [x] Logique domaine chat (`packages/chat-domain`) — ChatMessage, ChatSession, compactHistory, channel validation
- [x] Logique domaine persona (`packages/persona-domain`) — validatePersonaUpdate, aggregateFeedback, computePersonaDiff
- [x] Brancher les repos Postgres dans `apps/api` — async `createApp()`, fallback in-memory si pas de DATABASE_URL
- [ ] Evaluer LightRAG vs txtai vs RAGatouille | owner: Backend API
- [ ] Integrer dans rag.ts | owner: Backend API
- [ ] Indexer manifeste + lore personas | owner: Backend API
- [ ] Benchmark recall vs baseline cosine | owner: Backend API
## P3 Node Engine V2
## lot-23-crt-webgl (P3)
- [x] Porter registry → `packages/node-engine` (15 node types, 7 familles)
- [x] Porter graph ops (topologicalSort, validateEdgeContracts, collectNodeInputs)
- [x] Porter run state machine (createRun, RunStep, resolveFinalStatus)
- [x] Porter queue logic (createQueueState, enqueue, dequeue, canDequeue)
- [x] Runtime definitions (5 runtimes)
- [x] Isoler les runtimes avec sandboxing approprié — fait en P8 (`sandbox.ts`)
- [x] Adaptateurs d'entraînement réels (LoRA, QLoRA, SFT) — fait en P8 (`training.ts`)
- [x] Brancher le runner V2 dans `apps/worker` — poll loop, stub executors, graceful shutdown
- [ ] Evaluer vault66-crt-effect vs shaders custom | owner: Frontend
- [ ] Integrer dans MinitelFrame | owner: Frontend
- [ ] Tester perf mobile FPS 30+ | owner: Frontend
## P4 Frontend V2
## lot-24-tests-integration (P2)
- [x] API client centralisé (`api.ts`)
- [x] 9 composants React (Header, Login, Nav, PersonaList, PersonaDetail, NodeEngineOverview, GraphDetail, RunStatus, ChannelList)
- [x] Routing hash-based + responsive CSS
- [x] Interface chat React (WebSocket live) — fait en P7 (`Chat.tsx` + `useWebSocket.ts`)
- [x] Éditeur visuel Node Engine (React Flow) — fait en P7 (`NodeEditor.tsx` + `EngineNode.tsx`)
- [ ] Mock HTTP Ollama (streaming + tools) | owner: Backend API
- [ ] Mock ComfyUI workflow + polling | owner: Backend API
- [ ] Mock SearXNG + DuckDuckGo fallback | owner: Backend API
- [ ] Mock TTS sidecar HTTP | owner: Backend API
- [ ] Test context-store concurrent writes | owner: Backend API
- [ ] Test media-store path traversal | owner: Backend API
## P5 TUI & Ops
## Bugs restants (P3)
- [x] TUI health-check (V1+V2+Ollama+disk+memory)
- [x] TUI queue-viewer (runs, statuses)
- [x] TUI persona-manager (overview)
- [x] Log rotation (--dry-run, --max-age-days)
- [x] Packages/tui: ansi, statusDot, formatTable, drawBox
- [ ] Bug #7: token comparison timing-attack (crypto.timingSafeEqual) | owner: Backend API
- [ ] Merzbow 0 chars (think tokens consomment tout num_predict) | owner: Backend API
- [ ] Docker build torch timeout (layer trop lourd) | owner: Ops
## P6 Migration
- [x] Matrice de parité V1 → V2 — `scripts/parity-check.js` (persona, graph, channel, API shape checks)
- [x] Scripts de migration de données — `scripts/migrate-v1-to-v2.js` (personas, graphs, runs → Postgres, --dry-run support)
- [x] Smoke tests pour V2 — `scripts/smoke-v2.js` (22 tests, 5 catégories, `npm run smoke:v2`)
- [x] Procédure de rollback — `scripts/rollback-v2.js` (drop/truncate tables with confirmation, --yes, --tables filter)
## P0+ Sécurité V1 (deep analyse 2026-03-16)
- [x] Path traversal dans `storage.js` — sanitisation session IDs + boundary check memory paths
- [x] Path traversal dans `persona-registry.js` / `persona-store.js``safeFsId()` helper
- [x] Path traversal dans `attachment-store.js` — sanitisation IDs + boundary check
- [x] SSRF dans `web-tools.js` — blocage localhost, IPs privées, .local/.internal
- [x] Response body limit dans `web-tools.js` — truncation 2 MB
- [x] Log injection dans `storage.js` — sanitisation paramètre `role`
- [x] Crash JSONL corrompu dans `storage.js` — try/catch par ligne
- [x] Map mutation during iteration dans `sessions.js` — collect then process
- [x] Session leak `/msg` dans `commands.js` — clé stable au lieu de Date.now()
- [x] Unbounded userRateLimits dans `chat-routing.js` — pruning > 200 entries
- [x] Session pruning O(n) dans `admin-session.js` — throttle 60s
## P7 Intégration avancée
- [x] Migrer vers ollama-js SDK officiel — `ollama.js` réécrit avec `Ollama` class, même interface
- [x] Chat WebSocket React live — hook `useWebSocket` + composant Chat IRC, auto-reconnect
- [x] Éditeur visuel Node Engine avec React Flow — `NodeEditor.tsx` + `EngineNode.tsx`, 7 familles colorées
- [x] Déduplication requêtes GET dans `admin-api.js``deduplicatedFetch` transparent
- [x] Repos Postgres pour persona sources/feedback/proposals — 3 tables + repos + fallback in-memory
- [x] CI/CD GitHub Actions — `.github/workflows/ci.yml` (check V1+V2)
- [x] Deep analyse finale V1+V2 — 14 modules V1 vérifiés, 3 fixes TS V2, intégrité confirmée
## P8 Production Readiness
- [x] Adaptateurs training réels (TRL + Unsloth pour LoRA/DPO) — `packages/node-engine/src/training.ts` + worker intégré
- [x] Sandboxing runtimes Node Engine (containers/VM) — `packages/node-engine/src/sandbox.ts` (none/subprocess/container)
- [x] Turborepo pour build orchestration monorepo — `turbo.json` + scripts alignés + CI mis à jour
- [x] Tests unitaires V2 avec node:test + supertest — 102 tests, 46 suites, 6 packages + API integration
- [x] Tests React avec Vitest + RTL — 33 tests, 6 composants (Header, Login, Nav, PersonaList, RunStatus, ChannelList)
- [x] Créer le repo GitHub privé — https://github.com/electron-rare/kxkm_clown
## P9 Code Quality (simplify review)
- [x] Triple filter → single-pass loop dans `node-engine.js:deriveAsyncMeta`
- [x] Duplicate sanitization extraite dans `attachment-store.js:sanitizeId`
- [x] Double `loadModelIndex()` éliminé dans `node-engine-store.js:registerDeployment`
## P10 Lot 11 — Consolidation & Feature Parity
### Phase A — Analyse & Recherche
- [x] Deep analyse code V1+V2 (agent en cours)
- [x] Veille OSS mise à jour (agent en cours)
- [x] Recherche HuggingFace (agent en cours)
### Phase B — Correctifs sécurité (deep analyse)
- [x] **P0 SEC-01** Path traversal `node-engine-runner.js` — reject absolute paths + rootDir boundary check
- [x] **P0 SEC-04** V2 login role self-assignment — viewer par défaut, admin via ADMIN_TOKEN
- [x] **P1 BUG-06** Health endpoint leaking DATABASE_URL — remplacé par storageMode string
- [x] **P1 BUG-02** Timeout promise leak `node-engine-runner.js` — AbortSignal cancel
- [x] **P1 SEC-03** Attachments sans auth — ajout requireAdminNetwork middleware
- [x] Compilation + 119 tests OK après correctifs
### Phase C — Feature Parity V2
- [x] Recovery on crash worker — `recoverStaleRuns()` + worker startup recovery
- [x] Cancel support — `requestCancel()` repo + `shouldCancel` callback worker + API endpoint
- [ ] Tab completion chat V2
- [x] Commandes slash V2 — `parseSlashCommand`, `resolveCommand`, `generateHelpText` + 11 commandes + 17 tests
- [x] Mémoire conversationnelle V2 — `ConversationMemory`, `addToMemory`, `buildLlmContext`, `clearMemory`
- [x] Status strip admin V2 — GET `/api/v2/status` (personas, graphs, runs, queue)
- [x] Subnet gate V2 — CIDR middleware `/api/v2/admin/*` avec ADMIN_SUBNET env
- [x] Retention sweep V2 — `deleteOlderThan()` repo + POST `/api/v2/admin/retention-sweep`
- [x] Export HTML V2 — GET `/api/v2/export/html` avec download attachment
- [x] Upload fichiers V2 — bouton upload base64 dans Chat.tsx, accept image/audio/text/pdf/json/csv
- [x] Tab completion chat V2 — nicks + commandes slash, cycling Tab, reset auto
### Phase D — Déploiement & Docs
- [x] Docker — `Dockerfile` (multi-stage Node 22 alpine) + `docker-compose.yml` (5 services) + `.dockerignore`
- [ ] Documentation utilisateur
- [ ] Performance profiling
## P11 Lot 17 — Deep Audit & Refactoring
### Phase A — Analyse & documentation
- [x] Script TUI deep-audit.js (security, perf, complexity, deps) — `ops/v2/deep-audit.js`
- [x] Veille OSS enrichie 2026-03-17 (10 nouvelles catégories) — `docs/OSS_WATCH_2026-03-16.md`
- [x] Diagrammes Mermaid (Context Store, Docker, Inter-persona) — `docs/ARCHITECTURE.md`
- [x] AGENTS.md refondu (matrice 10 agents, Mermaid routing, pipeline) — `docs/AGENTS.md`
- [x] PLAN.md consolidé avec lots 17-19
- [x] TODO.md consolidé avec backlog Phase 6+
- [ ] Deep analyse code agents (api, web, packages, mascarade, v1+worker) — en cours
### Phase B — Refactoring code
- [ ] **P1** ws-chat.ts: extraction modules (1449 LOC → ~4×350 LOC)
- [ ] Extraire `ws-multimodal.ts` (vision, STT, TTS, PDF handlers)
- [ ] Extraire `ws-persona-router.ts` (pickResponders, inter-persona, memory)
- [ ] Extraire `ws-commands.ts` (slash commands, /web, /imagine)
- [ ] Garder `ws-chat.ts` core (WebSocket lifecycle, broadcast, rate limit)
- [ ] **P1** app.ts: extraction routes (1292 LOC → routes/ + middleware/)
- [ ] Extraire `routes/personas.ts`
- [ ] Extraire `routes/node-engine.ts`
- [ ] Extraire `routes/chat.ts`
- [ ] Extraire `middleware/auth.ts`
- [ ] **P2** writeFileSync → appendFile async dans ws-chat.ts (3 occurrences)
- [ ] **P2** console.log → logger structuré (apps/api, apps/worker)
- [ ] **P2** React.memo sur Chat, ChatHistory, VoiceChat, NodeEditor
- [ ] **P2** Lazy load: React.lazy + Suspense pour routes lourdes
### Phase C — Infrastructure
- [ ] SearXNG dans docker-compose (service searxng:8080, remplacer DuckDuckGo)
- [ ] MinerU/Docling dans docker-compose (remplacer pdf-parse)
- [ ] Spike BGE-M3 embeddings (upgrade nomic-embed-text)
- [ ] Déployer deep-audit.js sur kxkm-ai (cron quotidien)
- [ ] Créer utilisateur Discord **Pharmacius** (bot orchestrateur, bridge chat Discord ↔ KXKM)
### Phase D — Nouveaux node types
- [ ] `music_generation` node (ACE-Step 1.5, <4GB VRAM)
- [ ] `voice_clone` node (XTTS-v2, zero-shot 6s reference)
- [ ] `document_extraction` node (MinerU/Docling)
## P12 Lot 18 — Voice & MCP (futur)
- [ ] XTTS-v2 voice cloning par persona
- [ ] LLMRTC WebRTC streaming (TypeScript, VAD, barge-in)
- [ ] MCP SDK integration (personas = MCP servers)
- [ ] PCL + OpenCharacter pipeline fine-tune
- [ ] Chatterbox TTS evaluation
## P13 Lot 19 — Music & Creative (futur)
- [ ] ACE-Step 1.5 production
- [ ] `/compose` command (prompt → musique)
- [ ] Flux 2 dans ComfyUI
- [ ] A2A Protocol evaluation
## Lot 20 - Deep Analyse Continue & Execution Chainee `[en cours]`
A faire maintenant:
- [ ] Poursuivre extraction modulaire de `ws-chat.ts` (router, commandes, core).
- [ ] Decouper `app.ts` en routes + middleware sans regression.
- [ ] Ajouter instrumentation perf API/WS (latence/debit/memoire).
- [ ] Integrer SearXNG + Docling et valider le pipeline.
Fait sur ce lot:
- [x] Extraction modulaire du bloc upload/analyse de `ws-chat.ts` (`ws-upload-handler.ts`).
- [x] Refonte UI Minitel depuis la racine du site (`public/index.html`, `public/styles.css`, `public/app.js`).
- [x] Deep audit execute et relance apres correctifs.
- [x] Corrections context-store appliquees et validees.
- [x] check:v2 et test:v2 au vert.
- [x] Correctif anti-decrement TTS negatif (`ttsActive`).
- [x] Cleanup opportuniste des sessions expirees (mode memory).
- [x] Purge des logs vides/obsoletes `ops/v2/logs`.
- [x] Script `ops/v2/run-deep-cycle.sh` ajoute et execute.
- [x] Tests `apps/api/src/context-store.test.ts` ajoutes et valides.
- [x] Scoring de dette technique integre dans `ops/v2/deep-audit.js`.
- [x] Verification JSON dette: score 78/100 (niveau high).
+93
View File
@@ -0,0 +1,93 @@
import {
loadDatabaseConfig,
createPostgresPool,
runMigrations,
createSessionRepo,
createPersonaRepo,
createNodeGraphRepo,
createNodeRunRepo,
createPersonaSourceRepo,
createPersonaFeedbackRepo,
createPersonaProposalRepo,
} from "@kxkm/storage";
import { PERSONA_SEED_CATALOG, clonePersona, type PersonaRecord } from "@kxkm/persona-domain";
interface MemoryFactories<SessionRepo, PersonaRepo, GraphRepo, RunRepo, SourceRepo, FeedbackRepo, ProposalRepo> {
createSessionRepo: () => SessionRepo;
createPersonaRepo: () => PersonaRepo;
createGraphRepo: () => GraphRepo;
createRunRepo: () => RunRepo;
createSourceRepo: () => SourceRepo;
createFeedbackRepo: () => FeedbackRepo;
createProposalRepo: () => ProposalRepo;
}
interface SeedablePersonaRepo {
seedCatalog(catalog: PersonaRecord[]): Promise<void>;
}
export async function bootstrapRepositories<
SessionRepo,
PersonaRepo extends SeedablePersonaRepo,
GraphRepo,
RunRepo,
SourceRepo,
FeedbackRepo,
ProposalRepo,
>(
factories: MemoryFactories<SessionRepo, PersonaRepo, GraphRepo, RunRepo, SourceRepo, FeedbackRepo, ProposalRepo>,
): Promise<{
sessionRepo: SessionRepo;
personaRepo: PersonaRepo;
graphRepo: GraphRepo;
runRepo: RunRepo;
sourceRepo: SourceRepo;
feedbackRepo: FeedbackRepo;
proposalRepo: ProposalRepo;
storageMode: "postgres" | "memory";
}> {
const isProduction = (process.env.NODE_ENV || "").toLowerCase() === "production";
if (!process.env.DATABASE_URL && isProduction) {
throw new Error("DATABASE_URL is required when NODE_ENV=production");
}
if (process.env.DATABASE_URL) {
const dbConfig = loadDatabaseConfig();
const pool = createPostgresPool(dbConfig);
await runMigrations(pool);
const sessionRepo = createSessionRepo(pool) as SessionRepo;
const personaRepo = createPersonaRepo(pool) as unknown as PersonaRepo;
const graphRepo = createNodeGraphRepo(pool) as GraphRepo;
const runRepo = createNodeRunRepo(pool) as RunRepo;
const sourceRepo = createPersonaSourceRepo(pool) as SourceRepo;
const feedbackRepo = createPersonaFeedbackRepo(pool) as FeedbackRepo;
const proposalRepo = createPersonaProposalRepo(pool) as ProposalRepo;
await personaRepo.seedCatalog(PERSONA_SEED_CATALOG.map(clonePersona));
return {
sessionRepo,
personaRepo,
graphRepo,
runRepo,
sourceRepo,
feedbackRepo,
proposalRepo,
storageMode: "postgres",
};
}
console.warn("[kxkm/api] DATABASE_URL not set — using local persona storage + in-memory runtime stores");
return {
sessionRepo: factories.createSessionRepo(),
personaRepo: factories.createPersonaRepo(),
graphRepo: factories.createGraphRepo(),
runRepo: factories.createRunRepo(),
sourceRepo: factories.createSourceRepo(),
feedbackRepo: factories.createFeedbackRepo(),
proposalRepo: factories.createProposalRepo(),
storageMode: "memory",
};
}
+171
View File
@@ -0,0 +1,171 @@
import express, { type Request, type Response, type NextFunction } from "express";
import net from "node:net";
import { extractSessionId, hasPermission } from "@kxkm/auth";
import type { AuthSession, Permission } from "@kxkm/core";
export interface SessionRequest extends Request {
session?: AuthSession;
}
export function createSessionMiddleware(sessionRepo: { findById(id: string): Promise<AuthSession | null> }): express.RequestHandler {
return (req: SessionRequest, _res: Response, next: NextFunction) => {
const sessionId = extractSessionId(req as unknown as { cookies?: Record<string, string>; headers?: Record<string, string> });
if (!sessionId) {
next();
return;
}
sessionRepo.findById(sessionId)
.then((session) => {
if (session) req.session = session;
next();
})
.catch(next);
};
}
export function createRequireSession(): express.RequestHandler {
return (req: SessionRequest, res: Response, next: NextFunction) => {
if (!req.session) {
res.status(401).json({ ok: false, error: "session_required" });
return;
}
next();
};
}
export function createRequirePermission(permission: Permission): express.RequestHandler {
return (req: SessionRequest, res: Response, next: NextFunction) => {
if (!req.session) {
res.status(401).json({ ok: false, error: "session_required" });
return;
}
if (!hasPermission(req.session.role, permission)) {
res.status(403).json({ ok: false, error: "permission_denied" });
return;
}
next();
};
}
interface ParsedSubnet {
version: number;
mask: bigint;
network: bigint;
}
function normalizeIp(value: string): string {
let ip = value.trim();
const zoneIndex = ip.indexOf("%");
if (zoneIndex >= 0) ip = ip.slice(0, zoneIndex);
if (ip.startsWith("::ffff:") && net.isIP(ip.slice(7)) === 4) {
return ip.slice(7);
}
return ip;
}
function ipv4ToBigInt(ip: string): bigint {
return ip.split(".").reduce((r, o) => (r << 8n) + BigInt(Number.parseInt(o, 10)), 0n);
}
function ipv6ToBigInt(ip: string): bigint {
const parts = ip.split("::");
const head = parts[0] ? parts[0].split(":").filter(Boolean) : [];
const tail = parts[1] ? parts[1].split(":").filter(Boolean) : [];
const missing = 8 - (head.length + tail.length);
const groups = [...head, ...Array.from({ length: missing }, () => "0"), ...tail];
return groups.reduce((r, g) => (r << 16n) + BigInt(Number.parseInt(g || "0", 16)), 0n);
}
function parseSubnet(entry: string): ParsedSubnet | null {
const raw = entry.trim();
if (!raw) return null;
const [addressPart, prefixPart] = raw.split("/");
const address = normalizeIp(addressPart);
const version = net.isIP(address);
if (!version) return null;
const totalBits = version === 4 ? 32 : 128;
const prefix = prefixPart === undefined ? totalBits : Number.parseInt(prefixPart, 10);
if (!Number.isInteger(prefix) || prefix < 0 || prefix > totalBits) return null;
const bits = BigInt(totalBits);
const hostBits = BigInt(totalBits - prefix);
const allOnes = (1n << bits) - 1n;
const mask = prefix === 0 ? 0n : (allOnes << hostBits) & allOnes;
const value = version === 4 ? ipv4ToBigInt(address) : ipv6ToBigInt(address);
return { version, mask, network: value & mask };
}
function isIpInSubnet(ip: string, subnet: ParsedSubnet): boolean {
const normalized = normalizeIp(ip);
const version = net.isIP(normalized);
if (!version || version !== subnet.version) return false;
const value = version === 4 ? ipv4ToBigInt(normalized) : ipv6ToBigInt(normalized);
return (value & subnet.mask) === subnet.network;
}
export function createAdminSubnetMiddleware(adminSubnet: string | undefined): express.RequestHandler | null {
if (!adminSubnet) {
return null;
}
const subnet = parseSubnet(adminSubnet);
if (!subnet) {
return null;
}
return (req: Request, res: Response, next: NextFunction) => {
const ip = normalizeIp(req.ip || req.socket?.remoteAddress || "");
if (!isIpInSubnet(ip, subnet)) {
res.status(403).json({ ok: false, error: "subnet_denied" });
return;
}
next();
};
}
export function createPerfTracker() {
const perfStats = {
requestCount: 0,
totalLatencyMs: 0,
maxLatencyMs: 0,
statusCodes: new Map<number, number>(),
startedAt: Date.now(),
};
const middleware: express.RequestHandler = (_req: Request, res: Response, next: NextFunction) => {
const start = performance.now();
res.on("finish", () => {
const latency = performance.now() - start;
perfStats.requestCount++;
perfStats.totalLatencyMs += latency;
if (latency > perfStats.maxLatencyMs) perfStats.maxLatencyMs = latency;
perfStats.statusCodes.set(res.statusCode, (perfStats.statusCodes.get(res.statusCode) || 0) + 1);
});
next();
};
const route: express.RequestHandler = (_req: Request, res: Response) => {
const uptimeMs = Date.now() - perfStats.startedAt;
const avgLatency = perfStats.requestCount > 0 ? perfStats.totalLatencyMs / perfStats.requestCount : 0;
const mem = process.memoryUsage();
res.json({
ok: true,
data: {
uptime_ms: uptimeMs,
uptime_human: `${Math.floor(uptimeMs / 3600000)}h${Math.floor((uptimeMs % 3600000) / 60000)}m`,
requests: perfStats.requestCount,
avg_latency_ms: Math.round(avgLatency * 100) / 100,
max_latency_ms: Math.round(perfStats.maxLatencyMs * 100) / 100,
status_codes: Object.fromEntries(perfStats.statusCodes),
memory: {
rss_mb: Math.round(mem.rss / 1048576),
heap_used_mb: Math.round(mem.heapUsed / 1048576),
heap_total_mb: Math.round(mem.heapTotal / 1048576),
external_mb: Math.round(mem.external / 1048576),
},
},
});
};
return { middleware, route };
}
+42 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { rm } from "node:fs/promises";
import { readFile, rm } from "node:fs/promises";
import supertest from "supertest";
import { createApp } from "./app.js";
@@ -230,6 +230,47 @@ describe("V2 API", () => {
assert.ok(Array.isArray(res.body.data));
});
it("uploads, reports and deletes a voice sample using the local data dir", async () => {
const audio = Buffer.from("RIFF-test-voice-sample").toString("base64");
const uploadRes = await request
.post("/api/admin/personas/schaeffer/voice-sample")
.set("Cookie", cookie)
.send({ audio })
.expect(200);
assert.equal(uploadRes.body.ok, true);
assert.equal(uploadRes.body.data.samplePath, path.join(".tmp-test-v2-local", "voice-samples", "schaeffer.wav"));
const persisted = await readFile(path.join(TEST_LOCAL_DIR, "voice-samples", "schaeffer.wav"));
assert.equal(persisted.toString("utf8"), "RIFF-test-voice-sample");
const statusRes = await request
.get("/api/admin/personas/schaeffer/voice-sample")
.set("Cookie", cookie)
.expect(200);
assert.equal(statusRes.body.ok, true);
assert.equal(statusRes.body.data.hasVoiceSample, true);
assert.equal(statusRes.body.data.samplePath, path.join(".tmp-test-v2-local", "voice-samples", "schaeffer.wav"));
const deleteRes = await request
.delete("/api/admin/personas/schaeffer/voice-sample")
.set("Cookie", cookie)
.expect(200);
assert.equal(deleteRes.body.ok, true);
assert.equal(deleteRes.body.data.deleted, true);
const missingStatusRes = await request
.get("/api/admin/personas/schaeffer/voice-sample")
.set("Cookie", cookie)
.expect(200);
assert.equal(missingStatusRes.body.ok, true);
assert.equal(missingStatusRes.body.data.hasVoiceSample, false);
});
it("persists local persona updates across app recreation", async () => {
const { app: app2 } = await createApp();
const request2 = supertest(app2);
+4 -2
View File
@@ -396,11 +396,13 @@ function escapeForHtml(text: string): string {
}
function setSessionCookie(res: Response, sessionId: string): void {
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${sessionId}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`);
const secure = process.env.NODE_ENV === "production" ? "Secure; " : "";
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${sessionId}; HttpOnly; ${secure}SameSite=Strict; Path=/; Max-Age=3600`);
}
function clearSessionCookie(res: Response): void {
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`);
const secure = process.env.NODE_ENV === "production" ? "Secure; " : "";
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; HttpOnly; ${secure}SameSite=Strict; Path=/; Max-Age=0`);
}
// ---------------------------------------------------------------------------
+410 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, it } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile, stat } from "node:fs/promises";
import os from "node:os";
import { ContextStore } from "./context-store.js";
@@ -18,6 +18,11 @@ async function makeStore(overrides: Record<string, unknown> = {}): Promise<{ sto
return { store, dir };
}
/** Build a valid JSONL entry as ContextStore would write it. */
function jsonlLine(nick: string, text: string, type = "message"): string {
return JSON.stringify({ ts: new Date().toISOString(), nick, text, type });
}
afterEach(async () => {
while (tmpDirs.length > 0) {
const dir = tmpDirs.pop();
@@ -27,6 +32,10 @@ afterEach(async () => {
});
describe("ContextStore", () => {
// -----------------------------------------------------------------------
// Existing tests (preserved as-is)
// -----------------------------------------------------------------------
it("keeps returned context under requested char limit", async () => {
const { store } = await makeStore({ maxContextChars: 200 });
@@ -79,4 +88,404 @@ describe("ContextStore", () => {
assert.equal(calls, 1);
});
// -----------------------------------------------------------------------
// init()
// -----------------------------------------------------------------------
it("init() creates the data directory", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "kxkm-context-store-"));
tmpDirs.push(dir);
const nested = path.join(dir, "sub", "deep");
const store = new ContextStore({ dataDir: nested });
await store.init();
const s = await stat(nested);
assert.ok(s.isDirectory());
});
it("init() is idempotent (calling twice does not throw)", async () => {
const { store } = await makeStore();
// Second call should be a no-op
await store.init();
});
// -----------------------------------------------------------------------
// addEntry / append
// -----------------------------------------------------------------------
it("append() writes a JSONL line to the channel file", async () => {
const { store, dir } = await makeStore();
await store.append("general", "alice", "hello world");
const filePath = path.join(dir, "general.jsonl");
const content = await readFile(filePath, "utf-8");
const lines = content.trim().split("\n").filter(Boolean);
assert.equal(lines.length, 1);
const entry = JSON.parse(lines[0]);
assert.equal(entry.nick, "alice");
assert.equal(entry.text, "hello world");
assert.equal(entry.type, "message");
assert.ok(entry.ts, "entry should have a timestamp");
});
it("append() supports type parameter", async () => {
const { store, dir } = await makeStore();
await store.append("chan", "bot", "system message", "system");
const filePath = path.join(dir, "chan.jsonl");
const content = await readFile(filePath, "utf-8");
const entry = JSON.parse(content.trim());
assert.equal(entry.type, "system");
});
it("append() truncates text longer than 2000 chars", async () => {
const { store, dir } = await makeStore();
const longText = "x".repeat(5000);
await store.append("chan", "alice", longText);
const filePath = path.join(dir, "chan.jsonl");
const content = await readFile(filePath, "utf-8");
const entry = JSON.parse(content.trim());
assert.equal(entry.text.length, 2000);
});
it("multiple sequential append() calls accumulate entries", async () => {
const { store, dir } = await makeStore();
await store.append("room", "alice", "msg1");
await store.append("room", "bob", "msg2");
await store.append("room", "carol", "msg3");
const filePath = path.join(dir, "room.jsonl");
const content = await readFile(filePath, "utf-8");
const lines = content.trim().split("\n").filter(Boolean);
assert.equal(lines.length, 3);
assert.equal(JSON.parse(lines[0]).nick, "alice");
assert.equal(JSON.parse(lines[1]).nick, "bob");
assert.equal(JSON.parse(lines[2]).nick, "carol");
});
// -----------------------------------------------------------------------
// Channel sanitization
// -----------------------------------------------------------------------
it("sanitizes channel names: #general becomes _general", async () => {
const { store, dir } = await makeStore();
await store.append("#general", "alice", "hi");
const filePath = path.join(dir, "_general.jsonl");
const content = await readFile(filePath, "utf-8");
assert.ok(content.includes("alice"), "file should contain the entry");
});
it("sanitizes channel names with special characters", async () => {
const { store, dir } = await makeStore();
await store.append("#café/lounge!", "alice", "bonjour");
const filePath = path.join(dir, "_caf__lounge_.jsonl");
const content = await readFile(filePath, "utf-8");
assert.ok(content.includes("bonjour"));
});
// -----------------------------------------------------------------------
// getContext()
// -----------------------------------------------------------------------
it("getContext() returns empty string when no file exists", async () => {
const { store } = await makeStore();
const ctx = await store.getContext("nonexistent");
assert.equal(ctx, "");
});
it("getContext() returns formatted recent entries", async () => {
const { store } = await makeStore();
await store.append("room", "alice", "hello");
await store.append("room", "bob", "world");
const ctx = await store.getContext("room");
assert.ok(ctx.includes("[Échanges récents]"), "should have recent header");
assert.ok(ctx.includes("alice: hello"));
assert.ok(ctx.includes("bob: world"));
});
it("getContext() respects maxChars and truncates oldest entries first", async () => {
const { store } = await makeStore();
// Add enough entries so total exceeds the limit
for (let i = 0; i < 10; i++) {
await store.append("room", `user${i}`, `message number ${i} with padding ${"z".repeat(20)}`);
}
const ctx = await store.getContext("room", 100);
assert.ok(ctx.length <= 100, `expected <= 100 chars, got ${ctx.length}`);
// Should contain the LAST entries since we read backwards
assert.ok(ctx.includes("user9") || ctx.includes("user8"), "should include recent entries");
});
it("getContext() includes summary when summary file exists", async () => {
const { store, dir } = await makeStore();
// Write a summary file manually
const summaryData = {
channel: "room",
summaryText: "Alice and Bob discussed the weather.",
entriesCompacted: 50,
lastCompactedAt: new Date().toISOString(),
totalCompactions: 1,
};
await writeFile(path.join(dir, "room.summary.json"), JSON.stringify(summaryData), "utf-8");
// Add a recent entry
await store.append("room", "carol", "any news?");
const ctx = await store.getContext("room");
assert.ok(ctx.includes("[Résumé des conversations précédentes]"), "should include summary header");
assert.ok(ctx.includes("Alice and Bob discussed the weather."), "should include summary text");
assert.ok(ctx.includes("carol: any news?"), "should also include recent entries");
});
it("getContext() returns only summary when entries file is missing but summary exists", async () => {
const { store, dir } = await makeStore();
const summaryData = {
channel: "room",
summaryText: "Previously discussed topics.",
entriesCompacted: 10,
lastCompactedAt: new Date().toISOString(),
totalCompactions: 1,
};
await writeFile(path.join(dir, "room.summary.json"), JSON.stringify(summaryData), "utf-8");
const ctx = await store.getContext("room");
assert.ok(ctx.includes("[Résumé des conversations précédentes]"));
assert.ok(ctx.includes("Previously discussed topics."));
});
// -----------------------------------------------------------------------
// readSummary (via getContext / internal access)
// -----------------------------------------------------------------------
it("readSummary returns null when no summary file exists", async () => {
const { store } = await makeStore();
const anyStore = store as unknown as {
readSummary: (channel: string) => Promise<unknown>;
};
const result = await anyStore.readSummary("nonexistent");
assert.equal(result, null);
});
it("readSummary parses valid summary JSON", async () => {
const { store, dir } = await makeStore();
const summaryData = {
channel: "test",
summaryText: "A summary.",
entriesCompacted: 5,
lastCompactedAt: "2026-01-01T00:00:00.000Z",
totalCompactions: 1,
};
await writeFile(path.join(dir, "test.summary.json"), JSON.stringify(summaryData), "utf-8");
const anyStore = store as unknown as {
readSummary: (channel: string) => Promise<unknown>;
};
const result = await anyStore.readSummary("test") as typeof summaryData;
assert.equal(result.summaryText, "A summary.");
assert.equal(result.entriesCompacted, 5);
assert.equal(result.totalCompactions, 1);
});
it("readSummary returns null for corrupted summary file", async () => {
const { store, dir } = await makeStore();
await writeFile(path.join(dir, "bad.summary.json"), "not valid json {{{", "utf-8");
const anyStore = store as unknown as {
readSummary: (channel: string) => Promise<unknown>;
};
const result = await anyStore.readSummary("bad");
assert.equal(result, null);
});
// -----------------------------------------------------------------------
// Write locks (concurrency)
// -----------------------------------------------------------------------
it("concurrent append() calls do not corrupt the file", async () => {
const { store, dir } = await makeStore();
// Fire multiple appends concurrently
const promises = [];
for (let i = 0; i < 20; i++) {
promises.push(store.append("concurrent", `user${i}`, `message ${i}`));
}
await Promise.all(promises);
const filePath = path.join(dir, "concurrent.jsonl");
const content = await readFile(filePath, "utf-8");
const lines = content.trim().split("\n").filter(Boolean);
assert.equal(lines.length, 20, "all 20 entries should be present");
// Verify each line is valid JSON
for (const line of lines) {
assert.doesNotThrow(() => JSON.parse(line), `line should be valid JSON: ${line.slice(0, 50)}`);
}
});
it("concurrent append() on different channels writes to separate files", async () => {
const { store, dir } = await makeStore();
await Promise.all([
store.append("chan-a", "alice", "msg-a"),
store.append("chan-b", "bob", "msg-b"),
store.append("chan-c", "carol", "msg-c"),
]);
for (const [chan, nick] of [["chan-a", "alice"], ["chan-b", "bob"], ["chan-c", "carol"]]) {
const filePath = path.join(dir, `${chan}.jsonl`);
const content = await readFile(filePath, "utf-8");
const entry = JSON.parse(content.trim());
assert.equal(entry.nick, nick);
}
});
// -----------------------------------------------------------------------
// getStats()
// -----------------------------------------------------------------------
it("getStats() returns correct channel count and entry counts", async () => {
const { store } = await makeStore();
await store.append("room1", "alice", "hello");
await store.append("room1", "bob", "hi");
await store.append("room2", "carol", "hey");
const stats = await store.getStats();
assert.equal(stats.channels, 2);
assert.equal(stats.entries["room1"] ?? stats.entries["#room1"], 2);
assert.equal(stats.entries["room2"] ?? stats.entries["#room2"], 1);
assert.ok(stats.totalSizeMB > 0, "totalSizeMB should be > 0");
});
it("getStats() returns zero for empty store", async () => {
const { store } = await makeStore();
const stats = await store.getStats();
assert.equal(stats.channels, 0);
assert.equal(stats.totalSizeMB, 0);
assert.deepEqual(stats.entries, {});
});
// -----------------------------------------------------------------------
// channelFile / summaryFile sanitization
// -----------------------------------------------------------------------
it("channelFile and summaryFile produce correct paths", async () => {
const { store, dir } = await makeStore();
const anyStore = store as unknown as {
channelFile: (channel: string) => string;
summaryFile: (channel: string) => string;
};
assert.equal(anyStore.channelFile("general"), path.join(dir, "general.jsonl"));
assert.equal(anyStore.summaryFile("general"), path.join(dir, "general.summary.json"));
assert.equal(anyStore.channelFile("#room"), path.join(dir, "_room.jsonl"));
assert.equal(anyStore.summaryFile("#room"), path.join(dir, "_room.summary.json"));
});
// -----------------------------------------------------------------------
// maybeCompact skips when file is small
// -----------------------------------------------------------------------
it("maybeCompact does not compact when file is under 1 MB", async () => {
const { store } = await makeStore({ maxEntriesBeforeCompact: 2 });
const anyStore = store as unknown as {
maybeCompact: (channel: string) => Promise<void>;
compact: (channel: string, lines: string[]) => Promise<void>;
};
// Add a few entries (well under 1 MB)
await store.append("small", "alice", "hello");
await store.append("small", "bob", "world");
await store.append("small", "carol", "test");
let compactCalled = false;
anyStore.compact = async () => { compactCalled = true; };
await anyStore.maybeCompact("small");
assert.equal(compactCalled, false, "compact should not be called for files < 1 MB");
});
// -----------------------------------------------------------------------
// parseJson helper
// -----------------------------------------------------------------------
it("parseJson returns null for invalid JSON", async () => {
const { store } = await makeStore();
const anyStore = store as unknown as {
parseJson: <T>(raw: string) => T | null;
};
assert.equal(anyStore.parseJson("not json"), null);
assert.equal(anyStore.parseJson("{bad:}"), null);
assert.deepEqual(anyStore.parseJson('{"ok": true}'), { ok: true });
});
// -----------------------------------------------------------------------
// Edge cases
// -----------------------------------------------------------------------
it("getContext handles malformed JSONL lines gracefully", async () => {
const { store, dir } = await makeStore();
// Write a file with some valid and some invalid lines
const validLine = jsonlLine("alice", "valid message");
const content = `${validLine}\n{bad json}\n${jsonlLine("bob", "also valid")}\n`;
await writeFile(path.join(dir, "mixed.jsonl"), content, "utf-8");
const ctx = await store.getContext("mixed");
// Should include valid entries, skip bad ones
assert.ok(ctx.includes("alice: valid message") || ctx.includes("bob: also valid"),
"should include at least one valid entry");
});
it("append() calls init() automatically if not yet initialized", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "kxkm-context-store-"));
tmpDirs.push(dir);
const nested = path.join(dir, "auto-init");
const store = new ContextStore({ dataDir: nested });
// Do NOT call init() manually
await store.append("room", "alice", "hello");
// Verify the directory and file were created
const filePath = path.join(nested, "room.jsonl");
const content = await readFile(filePath, "utf-8");
assert.ok(content.includes("alice"));
});
it("getContext() calls init() automatically if not yet initialized", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "kxkm-context-store-"));
tmpDirs.push(dir);
const nested = path.join(dir, "auto-init2");
const store = new ContextStore({ dataDir: nested });
// Should not throw, just return empty
const ctx = await store.getContext("room");
assert.equal(ctx, "");
});
it("summary budget is capped so recent entries still have room", async () => {
const { store, dir } = await makeStore({ maxContextChars: 500 });
// Write a long summary
const summaryData = {
channel: "room",
summaryText: "S".repeat(1000),
entriesCompacted: 100,
lastCompactedAt: new Date().toISOString(),
totalCompactions: 5,
};
await writeFile(path.join(dir, "room.summary.json"), JSON.stringify(summaryData), "utf-8");
await store.append("room", "alice", "recent msg");
const ctx = await store.getContext("room", 500);
assert.ok(ctx.length <= 500, `expected <= 500 chars, got ${ctx.length}`);
// Summary should be truncated to leave room (limit - 256 = 244 for summary budget)
});
});
+148
View File
@@ -0,0 +1,148 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import crypto from "node:crypto";
// Set DATA_DIR before importing the module so the top-level mkdirSync
// creates directories inside our tmpdir instead of ./data.
const TEST_DATA_DIR = path.join(
os.tmpdir(),
`media-store-test-${crypto.randomBytes(6).toString("hex")}`,
);
process.env.DATA_DIR = TEST_DATA_DIR;
// Dynamic import so DATA_DIR is already set when the module evaluates.
const { saveImage, saveAudio, listMedia, getMediaFilePath } = await import(
"./media-store.js"
);
const IMAGES_DIR = path.join(TEST_DATA_DIR, "media", "images");
const AUDIO_DIR = path.join(TEST_DATA_DIR, "media", "audio");
const DUMMY_PNG_B64 = Buffer.from("fake-png-bytes").toString("base64");
const DUMMY_WAV_B64 = Buffer.from("fake-wav-bytes").toString("base64");
const baseOpts = { prompt: "test prompt", nick: "tester", channel: "#test" };
after(async () => {
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
delete process.env.DATA_DIR;
});
// -----------------------------------------------------------------------
describe("saveImage()", () => {
it("creates an image file and a metadata JSON file", async () => {
const meta = await saveImage({ ...baseOpts, base64: DUMMY_PNG_B64 });
const imgPath = path.join(IMAGES_DIR, meta.filename);
const jsonPath = path.join(IMAGES_DIR, `${meta.id}.json`);
assert.ok(fs.existsSync(imgPath), "image file should exist");
assert.ok(fs.existsSync(jsonPath), "metadata JSON should exist");
// Verify image content matches decoded base64
const imgContent = await fsp.readFile(imgPath);
assert.deepStrictEqual(imgContent, Buffer.from(DUMMY_PNG_B64, "base64"));
});
it("returns a MediaMeta with the correct fields", async () => {
const meta = await saveImage({
...baseOpts,
base64: DUMMY_PNG_B64,
mime: "image/jpeg",
});
assert.equal(meta.type, "image");
assert.equal(meta.prompt, "test prompt");
assert.equal(meta.nick, "tester");
assert.equal(meta.channel, "#test");
assert.equal(meta.mime, "image/jpeg");
assert.ok(meta.filename.endsWith(".jpg"), "jpeg mime should produce .jpg");
assert.ok(meta.id, "id should be present");
assert.ok(meta.createdAt, "createdAt should be present");
});
});
// -----------------------------------------------------------------------
describe("saveAudio()", () => {
it("creates an audio file and metadata", async () => {
const meta = await saveAudio({ ...baseOpts, base64: DUMMY_WAV_B64 });
const audioPath = path.join(AUDIO_DIR, meta.filename);
const jsonPath = path.join(AUDIO_DIR, `${meta.id}.json`);
assert.ok(fs.existsSync(audioPath), "audio file should exist");
assert.ok(fs.existsSync(jsonPath), "metadata JSON should exist");
assert.equal(meta.type, "audio");
assert.equal(meta.mime, "audio/wav");
assert.ok(meta.filename.endsWith(".wav"));
});
});
// -----------------------------------------------------------------------
describe("listMedia()", () => {
it('returns metadata entries for "image" type', async () => {
// saveImage was already called above; list should find them
const list = await listMedia("image");
assert.ok(list.length >= 1, "should find at least one image meta");
assert.equal(list[0].type, "image");
});
it("returns [] when there are no JSON files", async () => {
// Create a fresh empty dir to test the empty case
const emptyDir = path.join(TEST_DATA_DIR, "media", "empty-test");
await fsp.mkdir(emptyDir, { recursive: true });
// listMedia only knows "image" | "audio", so test "audio" after
// clearing the audio dir temporarily.
const audioFiles = await fsp.readdir(AUDIO_DIR);
// Move files away
const backupDir = path.join(TEST_DATA_DIR, "backup-audio");
await fsp.mkdir(backupDir, { recursive: true });
for (const f of audioFiles) {
await fsp.rename(path.join(AUDIO_DIR, f), path.join(backupDir, f));
}
const list = await listMedia("audio");
assert.deepStrictEqual(list, []);
// Restore files
for (const f of audioFiles) {
await fsp.rename(path.join(backupDir, f), path.join(AUDIO_DIR, f));
}
});
});
// -----------------------------------------------------------------------
describe("getMediaFilePath()", () => {
it("returns null when the file does not exist", () => {
const result = getMediaFilePath("image", "nonexistent.png");
assert.equal(result, null);
});
it("prevents directory traversal (../../etc/passwd → basename)", () => {
const result = getMediaFilePath("image", "../../etc/passwd");
// Should resolve to basename "passwd" which doesn't exist → null
assert.equal(result, null);
});
it("returns the full path for an existing file", async () => {
const meta = await saveImage({ ...baseOpts, base64: DUMMY_PNG_B64 });
const result = getMediaFilePath("image", meta.filename);
assert.ok(result, "should return a path for existing file");
assert.ok(result!.startsWith(IMAGES_DIR), "path should be inside IMAGES_DIR");
assert.ok(result!.endsWith(meta.filename));
});
});
// -----------------------------------------------------------------------
describe("generateId() uniqueness", () => {
it("produces unique IDs across two successive saves", async () => {
const meta1 = await saveImage({ ...baseOpts, base64: DUMMY_PNG_B64 });
const meta2 = await saveImage({ ...baseOpts, base64: DUMMY_PNG_B64 });
assert.notEqual(meta1.id, meta2.id, "IDs should be unique");
});
});
+252
View File
@@ -0,0 +1,252 @@
import { afterEach, beforeEach, describe, it, mock } from "node:test";
import assert from "node:assert/strict";
import { LocalRAG } from "./rag.js";
// ── helpers ──────────────────────────────────────────────────────────
let fetchMock: ReturnType<typeof mock.fn>;
/** Build a fake Ollama /api/embed response that returns a fixed embedding */
function ollamaEmbedResponse(embedding: number[]) {
return new Response(JSON.stringify({ embeddings: [embedding] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function makeRAG(opts?: Partial<{ minSimilarity: number; embeddingModel: string }>) {
return new LocalRAG({
ollamaUrl: "http://localhost:11434",
embeddingModel: opts?.embeddingModel,
minSimilarity: opts?.minSimilarity,
});
}
// ── tests ────────────────────────────────────────────────────────────
describe("LocalRAG", () => {
beforeEach(() => {
fetchMock = mock.fn();
(globalThis as any).fetch = fetchMock;
});
afterEach(() => {
mock.restoreAll();
// Restore original fetch (may be undefined in test env)
delete (globalThis as any).fetch;
});
// ── constructor ──────────────────────────────────────────────────
it("constructor creates an empty RAG (size === 0)", () => {
const rag = makeRAG();
assert.equal(rag.size, 0);
});
// ── embed() ──────────────────────────────────────────────────────
it("embed() calls fetch with correct URL and model", async () => {
fetchMock.mock.mockImplementation(() =>
Promise.resolve(ollamaEmbedResponse([0.1, 0.2, 0.3])),
);
const rag = makeRAG({ embeddingModel: "bge-m3" });
const result = await rag.embed("hello");
assert.deepEqual(result, [0.1, 0.2, 0.3]);
assert.equal(fetchMock.mock.callCount(), 1);
const [url, opts] = fetchMock.mock.calls[0].arguments as [string, any];
assert.equal(url, "http://localhost:11434/api/embed");
assert.equal(opts.method, "POST");
const body = JSON.parse(opts.body);
assert.equal(body.model, "bge-m3");
assert.equal(body.input, "hello");
});
it("embed() uses default model nomic-embed-text", async () => {
fetchMock.mock.mockImplementation(() =>
Promise.resolve(ollamaEmbedResponse([1])),
);
const rag = makeRAG();
await rag.embed("test");
const body = JSON.parse((fetchMock.mock.calls[0].arguments[1] as any).body);
assert.equal(body.model, "nomic-embed-text");
});
it("embed() throws on non-ok response", async () => {
fetchMock.mock.mockImplementation(() =>
Promise.resolve(new Response("fail", { status: 500, statusText: "Internal Server Error" })),
);
const rag = makeRAG();
await assert.rejects(() => rag.embed("x"), /Ollama embed returned 500/);
});
// ── addDocument() ────────────────────────────────────────────────
it("addDocument() splits text and adds chunks", async () => {
let callCount = 0;
fetchMock.mock.mockImplementation(() => {
callCount++;
return Promise.resolve(ollamaEmbedResponse([callCount, 0, 0]));
});
const rag = makeRAG();
// Each paragraph > 500 chars forces split into separate chunks
const para = "X".repeat(300);
const count = await rag.addDocument(`${para}\n\n${para}`, "src");
assert.equal(count, 2);
assert.equal(rag.size, 2);
});
it("addDocument() with long text splits into multiple chunks", async () => {
fetchMock.mock.mockImplementation(() =>
Promise.resolve(ollamaEmbedResponse([1, 0])),
);
const rag = makeRAG();
// Create text with paragraphs > 500 chars each to force multiple chunks
const longParagraph = "A".repeat(300);
const text = `${longParagraph}\n\n${longParagraph}\n\n${longParagraph}`;
const count = await rag.addDocument(text, "long");
assert.ok(count >= 2, `Expected >= 2 chunks, got ${count}`);
assert.equal(rag.size, count);
});
// ── search() ─────────────────────────────────────────────────────
it("search() returns [] if empty", async () => {
const rag = makeRAG();
// Should not even call fetch
const results = await rag.search("anything");
assert.deepEqual(results, []);
assert.equal(fetchMock.mock.callCount(), 0);
});
it("search() returns chunks ranked by cosine similarity", async () => {
// We control embeddings to get known cosine similarities
// chunk A embedding = [1,0,0], chunk B = [0.7,0.7,0]
// query embedding = [1,0,0] → sim(A)=1.0, sim(B)≈0.707
const embeddings: number[][] = [];
fetchMock.mock.mockImplementation(() =>
Promise.resolve(ollamaEmbedResponse(embeddings.shift()!)),
);
embeddings.push([1, 0, 0]); // chunk A embed
const rag = makeRAG({ minSimilarity: 0 });
await rag.addDocument("alpha", "a");
embeddings.push([0.7, 0.7, 0]); // chunk B embed
await rag.addDocument("beta", "b");
embeddings.push([1, 0, 0]); // query embed
const results = await rag.search("alpha");
assert.equal(results.length, 2);
assert.equal(results[0].source, "a");
assert.equal(results[0].score, 1.0);
assert.equal(results[1].source, "b");
assert.ok(results[1].score > 0.5); // ~0.707
});
it("search() filters by minSimilarity", async () => {
// chunk = [1,0,0], query = [0.7,0.7,0] → sim ≈ 0.707
const embeddings = [
[1, 0, 0],
[0.7, 0.7, 0], // query
];
let idx = 0;
fetchMock.mock.mockImplementation(() =>
Promise.resolve(ollamaEmbedResponse(embeddings[idx++])),
);
const rag = makeRAG({ minSimilarity: 0.8 });
await rag.addDocument("doc", "src");
const results = await rag.search("q");
assert.equal(results.length, 0);
});
it("search() respects maxResults", async () => {
const embeddings = [
[1, 0, 0],
[0.9, 0.1, 0],
[0.8, 0.2, 0],
[1, 0, 0], // query
];
let idx = 0;
fetchMock.mock.mockImplementation(() =>
Promise.resolve(ollamaEmbedResponse(embeddings[idx++])),
);
const rag = makeRAG({ minSimilarity: 0 });
await rag.addDocument("a", "s1");
await rag.addDocument("b", "s2");
await rag.addDocument("c", "s3");
const results = await rag.search("q", 2);
assert.equal(results.length, 2);
});
// ── size ──────────────────────────────────────────────────────────
it("size returns the number of chunks", async () => {
fetchMock.mock.mockImplementation(() =>
Promise.resolve(ollamaEmbedResponse([1])),
);
const rag = makeRAG();
assert.equal(rag.size, 0);
await rag.addDocument("single chunk", "s");
assert.equal(rag.size, 1);
// Force two chunks by making paragraphs > 500 chars
const para = "Y".repeat(300);
await rag.addDocument(`${para}\n\n${para}`, "s2");
assert.equal(rag.size, 3);
});
// ── cosine similarity edge cases (tested via search) ─────────────
it("identical vectors yield score 1.0", async () => {
const embeddings = [
[0.5, 0.5, 0.5], // doc
[0.5, 0.5, 0.5], // query (identical)
];
let idx = 0;
fetchMock.mock.mockImplementation(() =>
Promise.resolve(ollamaEmbedResponse(embeddings[idx++])),
);
const rag = makeRAG({ minSimilarity: 0 });
await rag.addDocument("x", "s");
const results = await rag.search("x");
assert.equal(results.length, 1);
assert.ok(Math.abs(results[0].score - 1.0) < 1e-9);
});
it("orthogonal vectors are filtered out by default minSimilarity", async () => {
// cosine([1,0,0], [0,1,0]) = 0.0, below default threshold 0.3
const embeddings = [
[1, 0, 0], // doc
[0, 1, 0], // query (orthogonal)
];
let idx = 0;
fetchMock.mock.mockImplementation(() =>
Promise.resolve(ollamaEmbedResponse(embeddings[idx++])),
);
const rag = makeRAG(); // default minSimilarity → 0.3
await rag.addDocument("x", "s");
const results = await rag.search("x");
// Score 0.0 < 0.3, so filtered out
assert.equal(results.length, 0);
});
});
+66 -3
View File
@@ -16,6 +16,7 @@ interface RAGOptions {
embeddingModel?: string; // default: "nomic-embed-text"
maxChunks?: number; // max chunks to return
minSimilarity?: number; // minimum cosine similarity threshold
lightragUrl?: string; // e.g. "http://localhost:9621"
}
export class LocalRAG {
@@ -43,8 +44,28 @@ export class LocalRAG {
return data.embeddings?.[0] || [];
}
/** Add a document (split into chunks, embed each) */
/** Add a document (split into chunks, embed each).
* If LightRAG is configured, also pushes the full text there (dual write). */
async addDocument(text: string, source: string): Promise<number> {
// Dual-write to LightRAG if configured
if (this.options.lightragUrl) {
try {
const res = await fetch(`${this.options.lightragUrl}/documents/text`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (res.ok) {
console.log(`[rag:lightrag] DEBUG addDocument to LightRAG OK (source=${source})`);
} else {
console.warn(`[rag:lightrag] addDocument failed: ${res.status} ${res.statusText}`);
}
} catch (err) {
console.warn("[rag:lightrag] addDocument error (continuing local):", err);
}
}
// Always index locally
const textChunks = splitIntoChunks(text, 500);
for (const chunk of textChunks) {
const embedding = await this.embed(chunk);
@@ -58,11 +79,53 @@ export class LocalRAG {
return textChunks.length;
}
/** Search for relevant chunks */
/** Search for relevant chunks.
* If LightRAG is configured, queries it first; falls back to local on failure. */
async search(
query: string,
maxResults = 3,
): Promise<Array<{ text: string; source: string; score: number }>> {
// Try LightRAG first if configured
if (this.options.lightragUrl) {
try {
console.log(`[rag:lightrag] DEBUG search query="${query.slice(0, 80)}"`);
const res = await fetch(`${this.options.lightragUrl}/query`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, mode: "hybrid" }),
});
if (res.ok) {
const data = (await res.json()) as {
response?: string;
references?: Array<{ content?: string; text?: string }>;
};
console.log(
`[rag:lightrag] DEBUG search OK refs=${data.references?.length ?? 0}`,
);
const results: Array<{ text: string; source: string; score: number }> = [];
if (data.references && data.references.length > 0) {
for (const ref of data.references.slice(0, maxResults)) {
results.push({
text: ref.content || ref.text || "",
source: "lightrag",
score: 1.0,
});
}
} else if (data.response) {
// No structured references — use the full response as a single chunk
results.push({ text: data.response, source: "lightrag", score: 1.0 });
}
if (results.length > 0) return results;
// Empty results from LightRAG → fall through to local
} else {
console.warn(`[rag:lightrag] search failed: ${res.status} ${res.statusText}`);
}
} catch (err) {
console.warn("[rag:lightrag] search error (falling back to local):", err);
}
}
// Local in-memory RAG
if (this.chunks.length === 0) return [];
const queryEmbedding = await this.embed(query);
@@ -73,7 +136,7 @@ export class LocalRAG {
}));
scored.sort((a, b) => b.score - a.score);
return scored
.filter((s) => s.score >= (this.options.minSimilarity || 0.3))
.filter((s) => s.score >= (this.options.minSimilarity ?? 0.3))
.slice(0, maxResults);
}
+12 -19
View File
@@ -15,6 +15,7 @@ import {
type PersonaRecord,
type PersonaSourceRecord,
} from "@kxkm/persona-domain";
import { resolveVoiceSamplePath, resolveVoiceSamplesRoot } from "../voice-samples.js";
interface SessionRequest extends Request {
session?: AuthSession;
@@ -256,24 +257,18 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
return;
}
const voiceSamplesDir = path.resolve(process.cwd(), "data", "voice-samples");
const voiceSamplesDir = resolveVoiceSamplesRoot();
await mkdir(voiceSamplesDir, { recursive: true });
const sampleName = path.basename(persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_")).slice(0, 64);
if (!sampleName || sampleName === "." || sampleName === "..") {
const samplePath = resolveVoiceSamplePath(persona.name, voiceSamplesDir);
if (!samplePath) {
res.status(400).json({ ok: false, error: "invalid_persona_name" });
return;
}
const samplePath = path.join(voiceSamplesDir, `${sampleName}.wav`);
// Boundary check: ensure resolved path stays within voiceSamplesDir
if (!path.resolve(samplePath).startsWith(voiceSamplesDir)) {
res.status(400).json({ ok: false, error: "path_traversal_blocked" });
return;
}
await writeFile(samplePath, buffer);
res.json({ ok: true, data: { personaId, samplePath: `data/voice-samples/${sampleName}.wav`, size: buffer.length } });
res.json({ ok: true, data: { personaId, samplePath: path.relative(process.cwd(), samplePath), size: buffer.length } });
});
router.delete("/api/admin/personas/:id/voice-sample", requirePermission("persona:write"), async (req: SessionRequest, res) => {
@@ -284,10 +279,9 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
return;
}
const voiceSamplesDir2 = path.resolve(process.cwd(), "data", "voice-samples");
const sampleName = path.basename(persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_")).slice(0, 64);
const samplePath = path.join(voiceSamplesDir2, `${sampleName}.wav`);
if (!sampleName || !path.resolve(samplePath).startsWith(voiceSamplesDir2)) {
const voiceSamplesDir2 = resolveVoiceSamplesRoot();
const samplePath = resolveVoiceSamplePath(persona.name, voiceSamplesDir2);
if (!samplePath) {
res.status(400).json({ ok: false, error: "invalid_persona_name" });
return;
}
@@ -309,17 +303,16 @@ export function createPersonaRoutes(deps: PersonaRouteDeps): Router {
return;
}
const voiceSamplesDir3 = path.resolve(process.cwd(), "data", "voice-samples");
const sampleName2 = path.basename(persona.name.toLowerCase().replace(/[^a-z0-9_-]/g, "_")).slice(0, 64);
const samplePath2 = path.join(voiceSamplesDir3, `${sampleName2}.wav`);
if (!sampleName2 || !path.resolve(samplePath2).startsWith(voiceSamplesDir3)) {
const voiceSamplesDir3 = resolveVoiceSamplesRoot();
const samplePath2 = resolveVoiceSamplePath(persona.name, voiceSamplesDir3);
if (!samplePath2) {
res.json({ ok: true, data: { hasVoiceSample: false } });
return;
}
try {
await stat(samplePath2);
res.json({ ok: true, data: { hasVoiceSample: true, samplePath: `data/voice-samples/${sampleName2}.wav` } });
res.json({ ok: true, data: { hasVoiceSample: true, samplePath: path.relative(process.cwd(), samplePath2) } });
} catch {
res.json({ ok: true, data: { hasVoiceSample: false } });
}
+23
View File
@@ -49,6 +49,23 @@ interface SessionRouteDeps {
clearSessionCookie: (res: Response) => void;
}
// Simple in-memory rate limiter for login attempts
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
const LOGIN_RATE_LIMIT = 5; // max attempts
const LOGIN_RATE_WINDOW_MS = 60_000; // per minute
function checkLoginRateLimit(ip: string): boolean {
if (process.env.NODE_ENV === "test") return true;
const now = Date.now();
const entry = loginAttempts.get(ip);
if (!entry || now > entry.resetAt) {
loginAttempts.set(ip, { count: 1, resetAt: now + LOGIN_RATE_WINDOW_MS });
return true;
}
entry.count++;
return entry.count <= LOGIN_RATE_LIMIT;
}
export function createSessionRoutes(deps: SessionRouteDeps): Router {
const {
sessionRepo,
@@ -104,6 +121,12 @@ export function createSessionRoutes(deps: SessionRouteDeps): Router {
router.post("/api/session/login", async (req, res) => {
try {
const clientIp = req.ip || req.socket?.remoteAddress || "unknown";
if (!checkLoginRateLimit(clientIp)) {
res.status(429).json({ ok: false, error: "rate_limited" });
return;
}
const input = validateLoginInput(req.body);
// SEC-04 fix: Never trust client-supplied role — assign viewer by default.
+1 -1
View File
@@ -41,7 +41,7 @@ async function main() {
// -----------------------------------------------------------------------
// Initialize local RAG (embeddings via Ollama)
// -----------------------------------------------------------------------
const rag = new LocalRAG({ ollamaUrl });
const rag = new LocalRAG({ ollamaUrl, lightragUrl: process.env.LIGHTRAG_URL });
// Index manifeste files asynchronously (non-blocking)
// Try multiple paths: relative to cwd (inside container /app) and absolute on host
+30
View File
@@ -0,0 +1,30 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { resolvePreferredPythonBin, resolveVoiceSamplePath, resolveVoiceSamplesRoot, toVoiceSampleBasename } from "./voice-samples.js";
describe("voice sample helpers", () => {
it("sanitizes persona names consistently for upload and runtime lookup", () => {
assert.equal(toVoiceSampleBasename("Sun Ra"), "sun_ra");
assert.equal(toVoiceSampleBasename("Batty!"), "batty_");
});
it("resolves a stable wav path inside the voice-samples directory", () => {
const rootDir = path.resolve("/tmp", "voice-samples");
const samplePath = resolveVoiceSamplePath("Sun Ra", rootDir);
assert.equal(samplePath, path.join(rootDir, "sun_ra.wav"));
});
it("rejects empty persona names", () => {
assert.equal(resolveVoiceSamplePath(""), null);
});
it("prefers the local data dir when resolving the voice sample root", () => {
const env = { KXKM_LOCAL_DATA_DIR: path.join("/tmp", "kxkm-local") };
assert.equal(resolveVoiceSamplesRoot(env), path.join("/tmp", "kxkm-local", "voice-samples"));
});
it("prefers an explicit PYTHON_BIN over fallbacks", () => {
assert.equal(resolvePreferredPythonBin({ PYTHON_BIN: "/tmp/custom-python" }), "/tmp/custom-python");
});
});
+48
View File
@@ -0,0 +1,48 @@
import { existsSync } from "node:fs";
import path from "node:path";
export function toVoiceSampleBasename(value: string): string {
return path.basename(value.toLowerCase().replace(/[^a-z0-9_-]/g, "_")).slice(0, 64);
}
export function resolveVoiceSamplesRoot(env: NodeJS.ProcessEnv = process.env): string {
if (env.KXKM_VOICE_SAMPLES_DIR && env.KXKM_VOICE_SAMPLES_DIR.trim().length > 0) {
return path.resolve(env.KXKM_VOICE_SAMPLES_DIR);
}
if (env.KXKM_LOCAL_DATA_DIR && env.KXKM_LOCAL_DATA_DIR.trim().length > 0) {
return path.resolve(env.KXKM_LOCAL_DATA_DIR, "voice-samples");
}
return path.resolve(process.cwd(), "data", "voice-samples");
}
export function resolveVoiceSamplePath(personaName: string, rootDir = resolveVoiceSamplesRoot()): string | null {
const basename = toVoiceSampleBasename(personaName);
if (!basename || basename === "." || basename === "..") {
return null;
}
const resolved = path.join(rootDir, `${basename}.wav`);
if (!path.resolve(resolved).startsWith(rootDir)) {
return null;
}
return resolved;
}
export function resolvePreferredPythonBin(env: NodeJS.ProcessEnv = process.env): string {
if (env.PYTHON_BIN && env.PYTHON_BIN.trim().length > 0) {
return env.PYTHON_BIN;
}
const projectVenvPython = path.resolve(process.cwd(), ".venvs", "voice-clone", "bin", "python");
if (existsSync(projectVenvPython)) {
return projectVenvPython;
}
const legacyPython = "/home/kxkm/venv/bin/python3";
if (existsSync(legacyPython)) {
return legacyPython;
}
return "python3";
}
+4
View File
@@ -7,6 +7,10 @@ export async function searchWeb(query: string): Promise<string> {
const searxngUrl = process.env.SEARXNG_URL || "http://localhost:8080";
try {
const response = await fetch(`${searxngUrl}/search?q=${encodeURIComponent(query)}&format=json&engines=google,bing,duckduckgo`, {
headers: {
"Accept": "application/json",
"User-Agent": "KXKM_Clown/2.0",
},
signal: AbortSignal.timeout(10_000),
});
if (response.ok) {
+159
View File
@@ -0,0 +1,159 @@
import { afterEach, describe, it } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { rm, rmdir } from "node:fs/promises";
import path from "node:path";
import { WebSocket } from "ws";
import { attachWebSocketChat } from "./ws-chat.js";
import type { OutboundMessage } from "./chat-types.js";
const originalFetch = globalThis.fetch;
const CHAT_LOG_DIR = path.resolve(process.cwd(), "data/chat-logs");
async function wait(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
describe("ws-chat smoke", () => {
let server: http.Server | undefined;
let wss: ReturnType<typeof attachWebSocketChat> | undefined;
let client: WebSocket | undefined;
afterEach(async () => {
if (client && client.readyState === WebSocket.OPEN) {
client.close();
await new Promise((resolve) => client?.once("close", resolve));
}
if (wss) {
wss.close();
}
if (server) {
await new Promise<void>((resolve) => server?.close(() => resolve()));
}
client = undefined;
wss = undefined;
server = undefined;
globalThis.fetch = originalFetch;
await wait(50);
await rm(CHAT_LOG_DIR, { recursive: true, force: true });
try {
await rmdir(path.dirname(CHAT_LOG_DIR));
} catch {
// Keep parent dir if something else lives there.
}
});
it("ignores malformed JSON and rate limits command bursts", async () => {
server = http.createServer();
wss = attachWebSocketChat(server, {
ollamaUrl: "http://ollama.test",
loadPersonas: async () => [],
});
await new Promise<void>((resolve) => server?.listen(0, resolve));
const address = server.address();
assert.ok(address && typeof address !== "string", "expected numeric server address");
client = new WebSocket(`ws://127.0.0.1:${address.port}/ws`);
const messages: OutboundMessage[] = [];
const errors: string[] = [];
client.on("message", (data) => {
try {
messages.push(JSON.parse(data.toString()) as OutboundMessage);
} catch {
// Ignore non-JSON frames
}
});
client.on("error", (err) => {
errors.push(err.message);
});
await new Promise<void>((resolve) => client?.once("open", resolve));
const baseline = messages.length;
client.send("not-json");
await wait(50);
assert.equal(messages.length, baseline);
for (let i = 0; i < 16; i += 1) {
client.send(JSON.stringify({ type: "command", text: "/help" }));
}
await wait(300);
assert.ok(messages.some((msg) => msg.type === "system" && /Commandes disponibles/.test(msg.text)));
assert.ok(messages.some((msg) => msg.type === "system" && /Trop de messages/.test(msg.text)));
assert.equal(errors.length, 0);
assert.equal(client.readyState, WebSocket.OPEN);
});
it("dispatches command, upload and chat messages to their dedicated seams", async () => {
globalThis.fetch = (async (_input, init) => {
let body: Record<string, unknown> = {};
if (typeof init?.body === "string") {
try {
body = JSON.parse(init.body) as Record<string, unknown>;
} catch {
body = {};
}
}
if (body.stream === false) {
return new Response(
JSON.stringify({
message: {
content: "reponse stub",
tool_calls: [],
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response('{"message":{"content":"reponse stub"},"done":true}\n', {
status: 200,
headers: { "Content-Type": "application/x-ndjson" },
});
}) as typeof fetch;
server = http.createServer();
wss = attachWebSocketChat(server, {
ollamaUrl: "http://ollama.test",
loadPersonas: async () => [],
});
await new Promise<void>((resolve) => server?.listen(0, resolve));
const address = server.address();
assert.ok(address && typeof address !== "string", "expected numeric server address");
client = new WebSocket(`ws://127.0.0.1:${address.port}/ws`);
const messages: OutboundMessage[] = [];
client.on("message", (data) => {
try {
messages.push(JSON.parse(data.toString()) as OutboundMessage);
} catch {
// Ignore non-JSON frames
}
});
await new Promise<void>((resolve) => client?.once("open", resolve));
await wait(100);
messages.length = 0;
client.send(JSON.stringify({ type: "command", text: "/help" }));
await wait(100);
assert.ok(messages.some((msg) => msg.type === "system" && /Commandes disponibles/.test(msg.text)));
client.send(JSON.stringify({ type: "upload", filename: "empty.txt", mimeType: "text/plain", data: "", size: 0 }));
await wait(100);
assert.ok(messages.some((msg) => msg.type === "system" && /Upload rejeté/.test(msg.text)));
client.send(JSON.stringify({ type: "message", text: "bonjour pharmacius" }));
await wait(200);
assert.ok(messages.some((msg) => msg.type === "message" && /^user_/.test(msg.nick)));
assert.ok(messages.some((msg) => msg.type === "message" && msg.nick === "Pharmacius" && msg.text === "reponse stub"));
});
});
+335
View File
@@ -0,0 +1,335 @@
import { describe, it, mock, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createCommandHandler } from "./ws-commands.js";
import type { ClientInfo, ChatPersona, OutboundMessage } from "./chat-types.js";
import type { WebSocket } from "ws";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function makeInfo(overrides?: Partial<ClientInfo>): ClientInfo {
return {
nick: "TestUser",
channel: "#general",
connectedAt: Date.now(),
messageTimestamps: [],
uploadBytesWindow: 0,
lastUploadReset: Date.now(),
...overrides,
};
}
function makeDeps(overrides?: Partial<Record<string, unknown>>) {
return {
send: mock.fn<(ws: WebSocket, msg: OutboundMessage) => void>(),
broadcast: mock.fn<(channel: string, msg: OutboundMessage, exclude?: WebSocket) => void>(),
broadcastUserlist: mock.fn<(channel: string) => void>(),
channelUsers: mock.fn<(channel: string) => string[]>(() => ["TestUser", "Alice"]),
listConnectedNicks: mock.fn<() => string[]>(() => ["TestUser", "Alice"]),
listChannelCounts: mock.fn<() => Map<string, number>>(() => new Map([["#general", 2], ["#random", 1]])),
routeToPersonas: mock.fn<(channel: string, text: string) => Promise<void>>(async () => {}),
logChatMessage: mock.fn(),
getPersonas: mock.fn<() => ChatPersona[]>(() => [
{ id: "p1", nick: "Merzbow", model: "llama3", systemPrompt: "Tu es un artiste noise japonais provocateur et expérimental...", color: "#ff0000" },
{ id: "p2", nick: "Oracle", model: "mistral", systemPrompt: "Tu es un oracle mystique qui parle en énigmes et prophéties...", color: "#9900ff" },
]),
...overrides,
};
}
const fakeWs = {} as WebSocket;
/* ------------------------------------------------------------------ */
/* /help */
/* ------------------------------------------------------------------ */
describe("/help", () => {
it("sends help text", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
await handle({ ws: fakeWs, info: makeInfo(), text: "/help" });
assert.equal(deps.send.mock.callCount(), 1);
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.equal(msg.type, "system");
assert.ok(msg.text.includes("/help"));
assert.ok(msg.text.includes("/nick"));
assert.ok(msg.text.includes("/join"));
});
});
/* ------------------------------------------------------------------ */
/* /nick */
/* ------------------------------------------------------------------ */
describe("/nick", () => {
it("changes nick and broadcasts", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
const info = makeInfo({ nick: "OldNick" });
await handle({ ws: fakeWs, info, text: "/nick NewNick" });
assert.equal(info.nick, "NewNick");
assert.equal(deps.broadcast.mock.callCount(), 1);
const bMsg = deps.broadcast.mock.calls[0].arguments[1] as { type: string; text: string };
assert.ok(bMsg.text.includes("OldNick"));
assert.ok(bMsg.text.includes("NewNick"));
assert.equal(deps.broadcastUserlist.mock.callCount(), 1);
});
it("rejects nick too short", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
const info = makeInfo();
await handle({ ws: fakeWs, info, text: "/nick X" });
assert.equal(info.nick, "TestUser"); // unchanged
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.ok(msg.text.includes("2-24"));
});
it("rejects nick too long", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
const info = makeInfo();
await handle({ ws: fakeWs, info, text: "/nick " + "A".repeat(25) });
assert.equal(info.nick, "TestUser");
});
it("rejects missing nick", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
await handle({ ws: fakeWs, info: makeInfo(), text: "/nick" });
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.ok(msg.text.includes("2-24"));
});
it("rejects duplicate nick (case-insensitive)", async () => {
const deps = makeDeps({
listConnectedNicks: mock.fn(() => ["TestUser", "Alice"]),
});
const handle = createCommandHandler(deps as any);
const info = makeInfo({ nick: "Bob" });
await handle({ ws: fakeWs, info, text: "/nick alice" });
assert.equal(info.nick, "Bob"); // unchanged
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.ok(msg.text.includes("deja utilise"));
});
it("allows re-casing own nick", async () => {
const deps = makeDeps({
listConnectedNicks: mock.fn(() => ["testuser", "Alice"]),
});
const handle = createCommandHandler(deps as any);
const info = makeInfo({ nick: "testuser" });
await handle({ ws: fakeWs, info, text: "/nick TestUser" });
assert.equal(info.nick, "TestUser");
});
});
/* ------------------------------------------------------------------ */
/* /who */
/* ------------------------------------------------------------------ */
describe("/who", () => {
it("sends userlist for current channel", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
await handle({ ws: fakeWs, info: makeInfo(), text: "/who" });
assert.equal(deps.send.mock.callCount(), 1);
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; users: string[] };
assert.equal(msg.type, "userlist");
assert.deepEqual(msg.users, ["TestUser", "Alice"]);
});
});
/* ------------------------------------------------------------------ */
/* /personas */
/* ------------------------------------------------------------------ */
describe("/personas", () => {
it("sends persona list", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
await handle({ ws: fakeWs, info: makeInfo(), text: "/personas" });
assert.equal(deps.send.mock.callCount(), 1);
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.equal(msg.type, "system");
assert.ok(msg.text.includes("Merzbow"));
assert.ok(msg.text.includes("Oracle"));
assert.ok(msg.text.includes("llama3"));
});
});
/* ------------------------------------------------------------------ */
/* /join */
/* ------------------------------------------------------------------ */
describe("/join", () => {
it("joins a valid channel", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
const info = makeInfo({ channel: "#general" });
await handle({ ws: fakeWs, info, text: "/join #lounge" });
assert.equal(info.channel, "#lounge");
// broadcast part on old channel, join on new
assert.equal(deps.broadcast.mock.callCount(), 2);
const partMsg = deps.broadcast.mock.calls[0].arguments[1] as { type: string; channel: string };
assert.equal(partMsg.type, "part");
assert.equal(partMsg.channel, "#general");
const joinMsg = deps.broadcast.mock.calls[1].arguments[1] as { type: string; channel: string };
assert.equal(joinMsg.type, "join");
assert.equal(joinMsg.channel, "#lounge");
// broadcastUserlist called for both old and new
assert.equal(deps.broadcastUserlist.mock.callCount(), 2);
// send channelInfo + system confirmation
assert.equal(deps.send.mock.callCount(), 2);
});
it("rejects channel without #", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
const info = makeInfo();
await handle({ ws: fakeWs, info, text: "/join lounge" });
assert.equal(info.channel, "#general"); // unchanged
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.ok(msg.text.includes("#"));
});
it("rejects channel with invalid characters", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
await handle({ ws: fakeWs, info: makeInfo(), text: "/join #bad channel!" });
// /join only takes parts[1], so "#bad" is valid; but let's test actual invalid chars
const deps2 = makeDeps();
const handle2 = createCommandHandler(deps2);
const info2 = makeInfo();
await handle2({ ws: fakeWs, info: info2, text: "/join #café" });
assert.equal(info2.channel, "#general");
const msg = deps2.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.ok(msg.text.includes("invalide"));
});
it("rejects missing channel name", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
await handle({ ws: fakeWs, info: makeInfo(), text: "/join" });
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.ok(msg.text.includes("Usage"));
});
it("rejects channel name too long", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
await handle({ ws: fakeWs, info: makeInfo(), text: `/join #${"a".repeat(30)}` });
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.ok(msg.text.includes("2-30"));
});
});
/* ------------------------------------------------------------------ */
/* /channels */
/* ------------------------------------------------------------------ */
describe("/channels", () => {
it("lists channels sorted by user count", async () => {
const deps = makeDeps({
listChannelCounts: mock.fn(() => new Map([["#random", 1], ["#general", 5]])),
});
const handle = createCommandHandler(deps as any);
await handle({ ws: fakeWs, info: makeInfo(), text: "/channels" });
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.ok(msg.text.includes("#general"));
assert.ok(msg.text.includes("#random"));
// #general (5) should appear before #random (1)
assert.ok(msg.text.indexOf("#general") < msg.text.indexOf("#random"));
});
it("shows (aucun) when no channels", async () => {
const deps = makeDeps({
listChannelCounts: mock.fn(() => new Map()),
});
const handle = createCommandHandler(deps as any);
await handle({ ws: fakeWs, info: makeInfo(), text: "/channels" });
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.ok(msg.text.includes("(aucun)"));
});
});
/* ------------------------------------------------------------------ */
/* Unknown command */
/* ------------------------------------------------------------------ */
describe("unknown command", () => {
it("sends error for unknown command", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
await handle({ ws: fakeWs, info: makeInfo(), text: "/foobar" });
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.equal(msg.type, "system");
assert.ok(msg.text.includes("inconnue"));
assert.ok(msg.text.includes("/foobar"));
assert.ok(msg.text.includes("/help"));
});
});
/* ------------------------------------------------------------------ */
/* /web */
/* ------------------------------------------------------------------ */
describe("/web", () => {
it("rejects empty query", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
await handle({ ws: fakeWs, info: makeInfo(), text: "/web" });
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.ok(msg.text.includes("Usage"));
});
});
/* ------------------------------------------------------------------ */
/* /imagine */
/* ------------------------------------------------------------------ */
describe("/imagine", () => {
it("rejects empty prompt", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
await handle({ ws: fakeWs, info: makeInfo(), text: "/imagine" });
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.ok(msg.text.includes("Usage"));
});
});
/* ------------------------------------------------------------------ */
/* /compose */
/* ------------------------------------------------------------------ */
describe("/compose", () => {
it("rejects empty prompt", async () => {
const deps = makeDeps();
const handle = createCommandHandler(deps);
await handle({ ws: fakeWs, info: makeInfo(), text: "/compose" });
const msg = deps.send.mock.calls[0].arguments[1] as { type: string; text: string };
assert.ok(msg.text.includes("Usage"));
});
});
+241
View File
@@ -0,0 +1,241 @@
import { afterEach, describe, it } from "node:test";
import assert from "node:assert/strict";
import { buildConversationInput, createConversationRouter, type ConversationRouterDeps } from "./ws-conversation-router.js";
import type { ChatLogEntry, ChatPersona, OutboundMessage } from "./chat-types.js";
const PERSONAS: ChatPersona[] = [
{
id: "pharmacius",
nick: "Pharmacius",
model: "llama3",
systemPrompt: "Tu es Pharmacius.",
color: "#c84c0c",
},
{
id: "sherlock",
nick: "Sherlock",
model: "llama3",
systemPrompt: "Tu es Sherlock.",
color: "#2c6e49",
},
];
type BroadcastRecord = { channel: string; msg: OutboundMessage };
type MemoryUpdateRecord = { persona: ChatPersona; recentMessages: string[]; ollamaUrl: string };
interface TestHarness {
deps: ConversationRouterDeps;
broadcasts: BroadcastRecord[];
logs: ChatLogEntry[];
contexts: Array<{ channel: string; nick: string; text: string }>;
plainCalls: Array<{ persona: ChatPersona; message: string }>;
toolCalls: Array<{ persona: ChatPersona; message: string; tools: unknown[] }>;
memoryUpdates: MemoryUpdateRecord[];
ttsCalls: Array<{ nick: string; text: string; channel: string }>;
errors: string[];
}
const originalTtsEnabled = process.env.TTS_ENABLED;
afterEach(() => {
if (originalTtsEnabled === undefined) {
delete process.env.TTS_ENABLED;
} else {
process.env.TTS_ENABLED = originalTtsEnabled;
}
});
function sleep(ms = 0): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function createHarness(overrides: Partial<ConversationRouterDeps> = {}): TestHarness {
const broadcasts: BroadcastRecord[] = [];
const logs: ChatLogEntry[] = [];
const contexts: Array<{ channel: string; nick: string; text: string }> = [];
const plainCalls: Array<{ persona: ChatPersona; message: string }> = [];
const toolCalls: Array<{ persona: ChatPersona; message: string; tools: unknown[] }> = [];
const memoryUpdates: MemoryUpdateRecord[] = [];
const ttsCalls: Array<{ nick: string; text: string; channel: string }> = [];
const errors: string[] = [];
const deps: ConversationRouterDeps = {
ollamaUrl: "http://ollama.test",
getPersonas: () => PERSONAS,
broadcast: (channel, msg) => {
broadcasts.push({ channel, msg });
},
logChatMessage: (entry) => {
logs.push(entry);
},
addToContext: (channel, nick, text) => {
contexts.push({ channel, nick, text });
},
getContextString: async () => "",
getToolsForPersona: () => [],
loadPersonaMemory: async (nick) => ({ nick, facts: [], summary: "", lastUpdated: "" }),
updatePersonaMemory: async (persona, recentMessages, ollamaUrl) => {
memoryUpdates.push({ persona, recentMessages, ollamaUrl });
},
streamOllamaChat: async (_ollamaUrl, persona, message, _onChunk, onDone) => {
plainCalls.push({ persona, message });
onDone(`Reponse ${persona.nick}`);
},
streamOllamaChatWithTools: async (_ollamaUrl, persona, message, tools, _rag, _onChunk, onDone) => {
toolCalls.push({ persona, message, tools });
onDone(`Reponse outils ${persona.nick}`);
},
synthesizeTTS: async (nick, text, channel) => {
ttsCalls.push({ nick, text, channel });
},
isTTSAvailable: () => true,
acquireTTS: () => {},
releaseTTS: () => {},
interPersonaDelayMs: 0,
logger: {
error: (...args: unknown[]) => {
errors.push(args.map((item) => String(item)).join(" "));
},
},
...overrides,
};
return {
deps,
broadcasts,
logs,
contexts,
plainCalls,
toolCalls,
memoryUpdates,
ttsCalls,
errors,
};
}
describe("ws-conversation-router", () => {
it("combines context store and RAG results in the enriched input", async () => {
const enriched = await buildConversationInput(
"Question utilisateur",
"#general",
async () => "Historique compact",
{
size: 1,
search: async () => [{ text: "Document A" }, { text: "Document B" }],
},
);
assert.match(enriched, /Question utilisateur/);
assert.match(enriched, /\[Contexte conversationnel\]/);
assert.match(enriched, /Historique compact/);
assert.match(enriched, /\[Contexte pertinent\]/);
assert.match(enriched, /Document A/);
assert.match(enriched, /Document B/);
});
it("routes a direct @mention only to the mentioned persona", async () => {
const harness = createHarness();
const routeToPersonas = createConversationRouter(harness.deps);
await routeToPersonas("#general", "@Sherlock analyse ceci");
const replies = harness.broadcasts
.filter((entry) => entry.msg.type === "message")
.map((entry) => (entry.msg.type === "message" ? entry.msg.nick : ""));
assert.deepEqual(replies, ["Sherlock"]);
});
it("falls back to Pharmacius without a direct mention", async () => {
const harness = createHarness();
const routeToPersonas = createConversationRouter(harness.deps);
await routeToPersonas("#general", "bonjour tout le monde");
const replies = harness.broadcasts
.filter((entry) => entry.msg.type === "message")
.map((entry) => (entry.msg.type === "message" ? entry.msg.nick : ""));
assert.deepEqual(replies, ["Pharmacius"]);
});
it("switches to the tool-calling path when a persona has tools", async () => {
const harness = createHarness({
getToolsForPersona: (nick) => (nick === "Pharmacius" ? [{ type: "function", function: { name: "web_search", description: "", parameters: { type: "object", properties: {}, required: [] } } }] : []),
});
const routeToPersonas = createConversationRouter(harness.deps);
await routeToPersonas("#general", "question sans mention");
assert.equal(harness.toolCalls.length, 1);
assert.equal(harness.toolCalls[0]?.persona.nick, "Pharmacius");
assert.equal(harness.plainCalls.length, 0);
});
it("updates persona memory every five responses with serialized recent messages", async () => {
const harness = createHarness();
const routeToPersonas = createConversationRouter(harness.deps);
for (let index = 1; index <= 5; index += 1) {
await routeToPersonas("#general", `message ${index}`);
}
await sleep();
assert.equal(harness.memoryUpdates.length, 1);
assert.equal(harness.memoryUpdates[0]?.persona.nick, "Pharmacius");
assert.equal(harness.memoryUpdates[0]?.recentMessages.length, 5);
assert.match(harness.memoryUpdates[0]?.recentMessages[4] || "", /message 5/);
});
it("triggers TTS only when the feature flag is enabled", async () => {
process.env.TTS_ENABLED = "0";
const disabledHarness = createHarness();
const disabledRouter = createConversationRouter(disabledHarness.deps);
await disabledRouter("#general", "premier message");
process.env.TTS_ENABLED = "1";
const enabledHarness = createHarness();
const enabledRouter = createConversationRouter(enabledHarness.deps);
await enabledRouter("#general", "second message");
await sleep();
assert.equal(disabledHarness.ttsCalls.length, 0);
assert.equal(enabledHarness.ttsCalls.length, 1);
assert.equal(enabledHarness.ttsCalls[0]?.nick, "Pharmacius");
});
it("caps inter-persona rebounds at the configured depth", async () => {
const harness = createHarness({
streamOllamaChat: async (_ollamaUrl, persona, _message, _onChunk, onDone) => {
harness.plainCalls.push({ persona, message: _message });
if (persona.nick === "Pharmacius") {
onDone("Sherlock, regarde ca. @Sherlock");
return;
}
onDone("Je reponds a Pharmacius. @Pharmacius");
},
maxInterPersonaDepth: 1,
});
const routeToPersonas = createConversationRouter(harness.deps);
await routeToPersonas("#general", "ouvre le debat");
await sleep();
await sleep();
const replies = harness.broadcasts
.filter((entry) => entry.msg.type === "message")
.map((entry) => (entry.msg.type === "message" ? entry.msg.nick : ""));
assert.deepEqual(replies, ["Pharmacius", "Sherlock"]);
});
it("broadcasts a system error when streaming fails without throwing", async () => {
const harness = createHarness({
streamOllamaChat: async (_ollamaUrl, _persona, _message, _onChunk, _onDone, onError) => {
onError(new Error("boom"));
},
});
const routeToPersonas = createConversationRouter(harness.deps);
await assert.doesNotReject(() => routeToPersonas("#general", "message casse"));
const systemMessages = harness.broadcasts.filter((entry) => entry.msg.type === "system");
assert.ok(systemMessages.some((entry) => entry.msg.type === "system" && entry.msg.text.includes("erreur Ollama — boom")));
});
});
+324
View File
@@ -0,0 +1,324 @@
import { afterEach, beforeEach, describe, it, mock } from "node:test";
import assert from "node:assert/strict";
import {
acquireFileProcessor,
releaseFileProcessor,
isTTSAvailable,
acquireTTS,
releaseTTS,
synthesizeTTS,
isOfficeDocument,
analyzeImage,
} from "./ws-multimodal.js";
import type { OutboundMessage } from "./chat-types.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const originalFetch = globalThis.fetch;
function mockFetch(impl: (...args: any[]) => any) {
globalThis.fetch = impl as typeof fetch;
}
// ---------------------------------------------------------------------------
// Semaphores — file processor
// ---------------------------------------------------------------------------
describe("file processor semaphore", () => {
afterEach(() => {
// drain semaphore back to 0
for (let i = 0; i < 5; i++) releaseFileProcessor();
});
it("acquireFileProcessor resolves immediately under the limit", async () => {
// Should resolve without delay when count is 0
await acquireFileProcessor();
assert.ok(true, "acquired without blocking");
});
it("releaseFileProcessor decrements the counter", async () => {
await acquireFileProcessor();
await acquireFileProcessor();
releaseFileProcessor();
// Should still be able to acquire (now at 1, limit 2)
await acquireFileProcessor();
assert.ok(true, "acquired after release");
});
});
// ---------------------------------------------------------------------------
// Semaphores — TTS
// ---------------------------------------------------------------------------
describe("TTS semaphore", () => {
afterEach(() => {
for (let i = 0; i < 5; i++) releaseTTS();
});
it("isTTSAvailable returns true under the limit", () => {
assert.equal(isTTSAvailable(), true);
});
it("isTTSAvailable returns false at the limit", () => {
acquireTTS();
acquireTTS();
assert.equal(isTTSAvailable(), false);
});
it("acquireTTS/releaseTTS increments and decrements", () => {
acquireTTS();
assert.equal(isTTSAvailable(), true); // 1 < 2
acquireTTS();
assert.equal(isTTSAvailable(), false); // 2 >= 2
releaseTTS();
assert.equal(isTTSAvailable(), true); // back to 1
});
it("releaseTTS does not go below 0", () => {
releaseTTS();
releaseTTS();
releaseTTS();
// Should still be available (counter stays at 0)
assert.equal(isTTSAvailable(), true);
});
});
// ---------------------------------------------------------------------------
// isOfficeDocument
// ---------------------------------------------------------------------------
describe("isOfficeDocument", () => {
it("returns true for office extensions", () => {
for (const ext of ["docx", "xlsx", "pptx", "odt", "ods", "odp", "rtf", "epub", "doc", "xls", "ppt"]) {
assert.equal(isOfficeDocument(`file.${ext}`, ""), true, `expected true for .${ext}`);
}
});
it("returns true for office MIME types", () => {
const mimes = [
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/rtf",
"application/epub+zip",
];
for (const m of mimes) {
assert.equal(isOfficeDocument("file.bin", m), true, `expected true for ${m}`);
}
});
it("returns false for non-office extensions", () => {
for (const ext of ["txt", "pdf", "jpg", "png", "mp3"]) {
assert.equal(isOfficeDocument(`file.${ext}`, ""), false, `expected false for .${ext}`);
}
});
it("returns false for non-office MIME types", () => {
assert.equal(isOfficeDocument("file.txt", "text/plain"), false);
assert.equal(isOfficeDocument("file.jpg", "image/jpeg"), false);
});
it("is case-insensitive on extension", () => {
assert.equal(isOfficeDocument("file.DOCX", ""), true);
assert.equal(isOfficeDocument("file.Xlsx", ""), true);
});
});
// ---------------------------------------------------------------------------
// synthesizeTTS
// ---------------------------------------------------------------------------
describe("synthesizeTTS", () => {
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("does nothing if text is shorter than 10 chars", async () => {
let fetchCalled = false;
mockFetch(() => { fetchCalled = true; });
await synthesizeTTS("Nick", "short", "ch1", () => {});
assert.equal(fetchCalled, false);
});
it("does nothing if text is empty", async () => {
let fetchCalled = false;
mockFetch(() => { fetchCalled = true; });
await synthesizeTTS("Nick", "", "ch1", () => {});
assert.equal(fetchCalled, false);
});
it("calls fetch with the correct URL and body", async () => {
let capturedUrl = "";
let capturedBody: any = {};
mockFetch(async (url: string, opts: any) => {
capturedUrl = url;
capturedBody = JSON.parse(opts.body);
return {
ok: true,
arrayBuffer: async () => new ArrayBuffer(0),
};
});
await synthesizeTTS("Pharmacius", "Hello world this is a test text", "ch1", () => {});
assert.match(capturedUrl, /\/synthesize$/);
assert.equal(capturedBody.text, "Hello world this is a test text");
assert.equal(capturedBody.persona, "Pharmacius");
});
it("truncates text to 1000 chars", async () => {
let capturedBody: any = {};
mockFetch(async (_url: string, opts: any) => {
capturedBody = JSON.parse(opts.body);
return {
ok: true,
arrayBuffer: async () => new ArrayBuffer(0),
};
});
const longText = "a".repeat(2000);
await synthesizeTTS("Nick", longText, "ch1", () => {});
assert.equal(capturedBody.text.length, 1000);
});
it("broadcasts audio message in base64", async () => {
const audioBytes = Buffer.from("fake-audio-data");
mockFetch(async () => ({
ok: true,
arrayBuffer: async () => audioBytes.buffer.slice(audioBytes.byteOffset, audioBytes.byteOffset + audioBytes.byteLength),
}));
const broadcasts: { channel: string; msg: OutboundMessage }[] = [];
await synthesizeTTS("Pharmacius", "This is enough text for TTS", "room1", (ch, msg) => {
broadcasts.push({ channel: ch, msg });
});
assert.equal(broadcasts.length, 1);
assert.equal(broadcasts[0].channel, "room1");
assert.equal(broadcasts[0].msg.type, "audio");
assert.equal((broadcasts[0].msg as any).nick, "Pharmacius");
assert.equal((broadcasts[0].msg as any).mimeType, "audio/wav");
assert.equal(typeof (broadcasts[0].msg as any).data, "string");
// Verify base64 round-trip
const decoded = Buffer.from((broadcasts[0].msg as any).data, "base64");
assert.deepEqual(decoded, audioBytes);
});
it("does not throw on fetch error", async () => {
mockFetch(async () => { throw new Error("network down"); });
// Should complete without throwing
await synthesizeTTS("Nick", "This is enough text for TTS", "ch1", () => {});
assert.ok(true, "did not throw");
});
it("does not throw on non-ok response", async () => {
mockFetch(async () => ({
ok: false,
status: 500,
text: async () => "server error",
}));
await synthesizeTTS("Nick", "This is enough text for TTS", "ch1", () => {});
assert.ok(true, "did not throw");
});
});
// ---------------------------------------------------------------------------
// analyzeImage
// ---------------------------------------------------------------------------
describe("analyzeImage", () => {
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("calls Ollama /api/chat with correct model and images", async () => {
let capturedUrl = "";
let capturedBody: any = {};
const imgBuffer = Buffer.from("fake-image");
mockFetch(async (url: string, opts: any) => {
capturedUrl = url;
capturedBody = JSON.parse(opts.body);
return {
ok: true,
json: async () => ({ message: { content: "Une photo" } }),
};
});
await analyzeImage(imgBuffer, "image/png", "photo.png", "http://localhost:11434");
assert.equal(capturedUrl, "http://localhost:11434/api/chat");
assert.equal(capturedBody.model, "qwen3-vl:8b");
assert.equal(capturedBody.stream, false);
assert.equal(capturedBody.messages.length, 1);
assert.equal(capturedBody.messages[0].role, "user");
assert.equal(capturedBody.messages[0].images[0], imgBuffer.toString("base64"));
});
it("returns '[Image: filename]\\ncaption' on success", async () => {
mockFetch(async () => ({
ok: true,
json: async () => ({ message: { content: "Un beau paysage" } }),
}));
const result = await analyzeImage(Buffer.from("img"), "image/png", "landscape.png", "http://localhost:11434");
assert.equal(result, "[Image: landscape.png]\nUn beau paysage");
});
it("returns default caption when response has no content", async () => {
mockFetch(async () => ({
ok: true,
json: async () => ({ message: {} }),
}));
const result = await analyzeImage(Buffer.from("img"), "image/png", "test.png", "http://localhost:11434");
assert.equal(result, "[Image: test.png]\nPas de description disponible");
});
it("returns error message on non-ok response", async () => {
mockFetch(async () => ({
ok: false,
status: 503,
text: async () => "model not loaded",
}));
const result = await analyzeImage(Buffer.from("img"), "image/png", "test.png", "http://localhost:11434");
assert.match(result, /analyse échouée/);
assert.match(result, /503/);
assert.match(result, /test\.png/);
});
it("returns error message on fetch exception", async () => {
mockFetch(async () => { throw new Error("connection refused"); });
const result = await analyzeImage(Buffer.from("img"), "image/png", "test.png", "http://localhost:11434");
assert.match(result, /erreur d'analyse/);
assert.match(result, /connection refused/);
assert.match(result, /test\.png/);
});
it("uses VISION_MODEL env var when set", async () => {
const origModel = process.env.VISION_MODEL;
process.env.VISION_MODEL = "llava:13b";
let capturedBody: any = {};
mockFetch(async (_url: string, opts: any) => {
capturedBody = JSON.parse(opts.body);
return {
ok: true,
json: async () => ({ message: { content: "caption" } }),
};
});
await analyzeImage(Buffer.from("img"), "image/png", "test.png", "http://localhost:11434");
assert.equal(capturedBody.model, "llava:13b");
// Restore
if (origModel === undefined) delete process.env.VISION_MODEL;
else process.env.VISION_MODEL = origModel;
});
});
+566
View File
@@ -0,0 +1,566 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert/strict";
// ---------------------------------------------------------------------------
// Mock dependencies BEFORE importing the module under test
// ---------------------------------------------------------------------------
// Mock web-search
const mockSearchWeb = mock.fn(async (_q: string) => "search result");
mock.module("./web-search.js", {
namedExports: { searchWeb: mockSearchWeb },
});
// Mock comfyui
const mockGenerateImage = mock.fn(async (_p: string) => ({ imageBase64: "abc", seed: 42 }));
mock.module("./comfyui.js", {
namedExports: { generateImage: mockGenerateImage },
});
// Now import the module under test
const {
acquireOllama,
releaseOllama,
streamOllamaChat,
executeToolCall,
streamOllamaChatWithTools,
} = await import("./ws-ollama.js");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makePersona() {
return {
id: "test-id",
nick: "test",
model: "test:7b",
systemPrompt: "You are a test",
color: "#fff",
maxTokens: 100,
};
}
/** Build a ReadableStream that yields NDJSON lines like Ollama */
function ollamaStream(chunks: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const lines = chunks.map(
(c) => JSON.stringify({ message: { content: c }, done: false }) + "\n",
);
lines.push(JSON.stringify({ message: { content: "" }, done: true }) + "\n");
let idx = 0;
return new ReadableStream({
pull(controller) {
if (idx < lines.length) {
controller.enqueue(encoder.encode(lines[idx++]));
} else {
controller.close();
}
},
});
}
/** Create a mock Response with a streaming body */
function mockStreamResponse(chunks: string[], status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? "OK" : "Error",
body: ollamaStream(chunks),
} as unknown as Response;
}
/** Create a mock Response with a JSON body (non-streaming) */
function mockJsonResponse(data: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? "OK" : "Error",
json: async () => data,
} as unknown as Response;
}
// Store original fetch
const originalFetch = globalThis.fetch;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("ws-ollama", () => {
let fetchMock: ReturnType<typeof mock.fn>;
beforeEach(() => {
fetchMock = mock.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
mockSearchWeb.mock.resetCalls();
mockGenerateImage.mock.resetCalls();
// Drain the semaphore state by releasing enough times
// (we need a fresh state; simplest approach is to accept leaks for now)
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
// =========================================================================
// Semaphore tests
// =========================================================================
describe("acquireOllama / releaseOllama", () => {
it("resolves immediately when under the limit", async () => {
// Should resolve without blocking
await acquireOllama();
releaseOllama();
});
it("queues when limit is reached and unblocks on release", async () => {
// Acquire 3 slots (default MAX_OLLAMA_CONCURRENT = 3)
await acquireOllama();
await acquireOllama();
await acquireOllama();
let resolved = false;
const pending = acquireOllama().then(() => {
resolved = true;
});
// Should not have resolved yet
await new Promise((r) => setTimeout(r, 10));
assert.equal(resolved, false, "4th acquire should be queued");
// Release one slot
releaseOllama();
await pending;
assert.equal(resolved, true, "4th acquire should resolve after release");
// Cleanup: release remaining
releaseOllama();
releaseOllama();
releaseOllama();
});
it("unblocks in FIFO order", async () => {
await acquireOllama();
await acquireOllama();
await acquireOllama();
const order: number[] = [];
const p1 = acquireOllama().then(() => order.push(1));
const p2 = acquireOllama().then(() => order.push(2));
releaseOllama();
await p1;
releaseOllama();
await p2;
assert.deepEqual(order, [1, 2]);
// Cleanup
releaseOllama();
releaseOllama();
releaseOllama();
});
});
// =========================================================================
// streamOllamaChat tests
// =========================================================================
describe("streamOllamaChat", () => {
it("calls fetch with correct URL and body", async () => {
fetchMock.mock.mockImplementation(async () => mockStreamResponse(["hello"]));
const persona = makePersona();
await streamOllamaChat(
"http://localhost:11434",
persona,
"Hi",
() => {},
() => {},
() => {},
);
assert.equal(fetchMock.mock.callCount(), 1);
const [url, opts] = fetchMock.mock.calls[0].arguments as [string, RequestInit & { body: string }];
assert.equal(url, "http://localhost:11434/api/chat");
assert.equal(opts.method, "POST");
const body = JSON.parse(opts.body as string);
assert.equal(body.model, "test:7b");
assert.equal(body.stream, true);
assert.equal(body.messages[0].role, "system");
assert.equal(body.messages[0].content, "You are a test");
assert.equal(body.messages[1].role, "user");
assert.equal(body.messages[1].content, "Hi");
assert.equal(body.options.num_predict, 100);
});
it("streams chunks via onChunk and calls onDone with full text", async () => {
fetchMock.mock.mockImplementation(async () => mockStreamResponse(["Hello", " world"]));
const chunks: string[] = [];
let doneText = "";
await streamOllamaChat(
"http://localhost:11434",
makePersona(),
"Hi",
(t) => chunks.push(t),
(t) => { doneText = t; },
() => {},
);
assert.deepEqual(chunks, ["Hello", " world"]);
assert.equal(doneText, "Hello world");
});
it("strips <think>...</think> blocks from onDone text", async () => {
fetchMock.mock.mockImplementation(async () =>
mockStreamResponse(["<think>reasoning</think>", "Answer"]),
);
let doneText = "";
await streamOllamaChat(
"http://localhost:11434",
makePersona(),
"Hi",
() => {},
(t) => { doneText = t; },
() => {},
);
assert.equal(doneText, "Answer");
});
it("suppresses <think> content from onChunk", async () => {
fetchMock.mock.mockImplementation(async () =>
mockStreamResponse(["<think>", "internal", "</think>", "visible"]),
);
const chunks: string[] = [];
await streamOllamaChat(
"http://localhost:11434",
makePersona(),
"Hi",
(t) => chunks.push(t),
() => {},
() => {},
);
// Only "visible" should have been sent via onChunk
assert.deepEqual(chunks, ["visible"]);
});
it("calls onError on fetch failure", async () => {
fetchMock.mock.mockImplementation(async () => {
throw new Error("Network error");
});
let caughtError: Error | null = null;
await streamOllamaChat(
"http://localhost:11434",
makePersona(),
"Hi",
() => {},
() => {},
(err) => { caughtError = err; },
);
assert.notEqual(caughtError, null);
assert.match(caughtError!.message, /Network error/);
});
it("calls onError when response is not ok", async () => {
fetchMock.mock.mockImplementation(async () => mockStreamResponse([], 500));
let caughtError: Error | null = null;
await streamOllamaChat(
"http://localhost:11434",
makePersona(),
"Hi",
() => {},
() => {},
(err) => { caughtError = err; },
);
assert.notEqual(caughtError, null);
assert.match(caughtError!.message, /Ollama returned 500/);
});
it("releases semaphore on success", async () => {
fetchMock.mock.mockImplementation(async () => mockStreamResponse(["ok"]));
// Fill the semaphore, do a stream, check we can still acquire after
await streamOllamaChat(
"http://localhost:11434",
makePersona(),
"Hi",
() => {},
() => {},
() => {},
);
// If semaphore was released, this should resolve
await acquireOllama();
releaseOllama();
});
it("releases semaphore on error", async () => {
fetchMock.mock.mockImplementation(async () => {
throw new Error("fail");
});
await streamOllamaChat(
"http://localhost:11434",
makePersona(),
"Hi",
() => {},
() => {},
() => {},
);
// Semaphore should still be functional
await acquireOllama();
releaseOllama();
});
});
// =========================================================================
// executeToolCall tests
// =========================================================================
describe("executeToolCall", () => {
it("web_search returns searchWeb result", async () => {
mockSearchWeb.mock.mockImplementation(async () => "web results");
const result = await executeToolCall("web_search", { query: "test" }, undefined);
assert.equal(result, "web results");
assert.equal(mockSearchWeb.mock.callCount(), 1);
});
it("image_generate returns seed message on success", async () => {
mockGenerateImage.mock.mockImplementation(async () => ({ imageBase64: "x", seed: 99 }));
const result = await executeToolCall("image_generate", { prompt: "cat" }, undefined);
assert.match(result, /seed 99/);
});
it("image_generate returns error message on null", async () => {
mockGenerateImage.mock.mockImplementation(async () => null as unknown as { imageBase64: string; seed: number });
const result = await executeToolCall("image_generate", { prompt: "cat" }, undefined);
assert.match(result, /Erreur/);
});
it("rag_search returns joined results", async () => {
const rag = {
size: 5,
search: mock.fn(async () => [{ text: "doc1" }, { text: "doc2" }]),
};
const result = await executeToolCall("rag_search", { query: "find" }, rag);
assert.match(result, /doc1/);
assert.match(result, /doc2/);
assert.match(result, /---/);
});
it("rag_search without RAG returns message", async () => {
const result = await executeToolCall("rag_search", { query: "find" }, undefined);
assert.match(result, /Pas de documents/);
});
it("rag_search with empty RAG returns message", async () => {
const rag = { size: 0, search: mock.fn(async () => []) };
const result = await executeToolCall("rag_search", { query: "find" }, rag);
assert.match(result, /Pas de documents/);
});
it("unknown tool returns error message", async () => {
const result = await executeToolCall("unknown_tool", {}, undefined);
assert.match(result, /Outil inconnu.*unknown_tool/);
});
});
// =========================================================================
// streamOllamaChatWithTools tests
// =========================================================================
describe("streamOllamaChatWithTools", () => {
const sampleTool = {
type: "function" as const,
function: {
name: "web_search",
description: "Search the web",
parameters: {
type: "object" as const,
properties: { query: { type: "string", description: "query" } },
required: ["query"],
},
},
};
it("returns direct response when no tool calls", async () => {
fetchMock.mock.mockImplementation(async () =>
mockJsonResponse({ message: { role: "assistant", content: "Direct answer" } }),
);
const chunks: string[] = [];
let doneText = "";
await streamOllamaChatWithTools(
"http://localhost:11434",
makePersona(),
"Hi",
[sampleTool],
undefined,
(t) => chunks.push(t),
(t) => { doneText = t; },
() => {},
);
assert.deepEqual(chunks, ["Direct answer"]);
assert.equal(doneText, "Direct answer");
});
it("strips thinking from direct response", async () => {
fetchMock.mock.mockImplementation(async () =>
mockJsonResponse({
message: { role: "assistant", content: "<think>hmm</think>Real answer" },
}),
);
let doneText = "";
await streamOllamaChatWithTools(
"http://localhost:11434",
makePersona(),
"Hi",
[sampleTool],
undefined,
() => {},
(t) => { doneText = t; },
() => {},
);
assert.equal(doneText, "Real answer");
});
it("executes tool calls then streams final response", async () => {
let callCount = 0;
fetchMock.mock.mockImplementation(async () => {
callCount++;
if (callCount === 1) {
// Probe call: respond with tool_calls
return mockJsonResponse({
message: {
role: "assistant",
content: "",
tool_calls: [
{ function: { name: "web_search", arguments: { query: "test" } } },
],
},
});
}
// Streaming call: final response
return mockStreamResponse(["Final ", "answer"]);
});
mockSearchWeb.mock.mockImplementation(async () => "web result");
const chunks: string[] = [];
let doneText = "";
await streamOllamaChatWithTools(
"http://localhost:11434",
makePersona(),
"Hi",
[sampleTool],
undefined,
(t) => chunks.push(t),
(t) => { doneText = t; },
() => {},
);
assert.equal(mockSearchWeb.mock.callCount(), 1);
assert.deepEqual(chunks, ["Final ", "answer"]);
assert.equal(doneText, "Final answer");
// Two fetch calls: probe + stream
assert.equal(fetchMock.mock.callCount(), 2);
});
it("calls onError on fetch failure", async () => {
fetchMock.mock.mockImplementation(async () => {
throw new Error("connection refused");
});
let caughtError: Error | null = null;
await streamOllamaChatWithTools(
"http://localhost:11434",
makePersona(),
"Hi",
[sampleTool],
undefined,
() => {},
() => {},
(err) => { caughtError = err; },
);
assert.notEqual(caughtError, null);
assert.match(caughtError!.message, /connection refused/);
});
it("calls onError when probe response is not ok", async () => {
fetchMock.mock.mockImplementation(async () =>
mockJsonResponse({}, 503),
);
let caughtError: Error | null = null;
await streamOllamaChatWithTools(
"http://localhost:11434",
makePersona(),
"Hi",
[sampleTool],
undefined,
() => {},
() => {},
(err) => { caughtError = err; },
);
assert.notEqual(caughtError, null);
assert.match(caughtError!.message, /503/);
});
it("handles tool execution errors gracefully", async () => {
let callCount = 0;
fetchMock.mock.mockImplementation(async () => {
callCount++;
if (callCount === 1) {
return mockJsonResponse({
message: {
role: "assistant",
content: "",
tool_calls: [
{ function: { name: "web_search", arguments: { query: "fail" } } },
],
},
});
}
return mockStreamResponse(["recovered"]);
});
mockSearchWeb.mock.mockImplementation(async () => {
throw new Error("search failed");
});
let doneText = "";
await streamOllamaChatWithTools(
"http://localhost:11434",
makePersona(),
"Hi",
[sampleTool],
undefined,
() => {},
(t) => { doneText = t; },
() => {},
);
// Should still complete - tool error is caught and passed as context
assert.equal(doneText, "recovered");
});
});
});
+264
View File
@@ -0,0 +1,264 @@
import { afterEach, beforeEach, describe, it, mock } from "node:test";
import assert from "node:assert/strict";
import { handleUpload } from "./ws-upload-handler.js";
import type { WebSocket } from "ws";
import type { InboundUpload, ClientInfo, OutboundMessage } from "./chat-types.js";
// ── helpers ──────────────────────────────────────────────────────────
function makeClientInfo(overrides?: Partial<ClientInfo>): ClientInfo {
return {
nick: "testuser",
channel: "#test",
connectedAt: Date.now(),
messageTimestamps: [],
uploadBytesWindow: 0,
lastUploadReset: Date.now(),
...overrides,
};
}
function b64(str: string): string {
return Buffer.from(str).toString("base64");
}
interface MockDeps {
ws: WebSocket;
broadcast: ReturnType<typeof mock.fn>;
logChatMessage: ReturnType<typeof mock.fn>;
routeToPersonas: ReturnType<typeof mock.fn>;
acquireFileProcessor: ReturnType<typeof mock.fn>;
releaseFileProcessor: ReturnType<typeof mock.fn>;
isOfficeDocument: ReturnType<typeof mock.fn>;
analyzeImage: ReturnType<typeof mock.fn>;
send: ReturnType<typeof mock.fn>;
sentMessages: OutboundMessage[];
}
function makeMocks(): MockDeps {
const sentMessages: OutboundMessage[] = [];
const send = mock.fn((_ws: WebSocket, msg: OutboundMessage) => {
sentMessages.push(msg);
});
return {
ws: {} as WebSocket,
broadcast: mock.fn(),
logChatMessage: mock.fn(),
routeToPersonas: mock.fn(() => Promise.resolve()),
acquireFileProcessor: mock.fn(() => Promise.resolve()),
releaseFileProcessor: mock.fn(),
isOfficeDocument: mock.fn(() => false),
analyzeImage: mock.fn(() => Promise.resolve("[image analysis]")),
send,
sentMessages,
};
}
function callUpload(
parsed: InboundUpload,
info: ClientInfo,
deps: MockDeps,
) {
return handleUpload(
deps.ws,
info,
parsed,
"http://localhost:11434",
deps.broadcast as any,
deps.logChatMessage as any,
deps.routeToPersonas as any,
deps.acquireFileProcessor as any,
deps.releaseFileProcessor as any,
deps.isOfficeDocument as any,
deps.analyzeImage as any,
deps.send as any,
);
}
// ── tests ────────────────────────────────────────────────────────────
describe("ws-upload-handler", () => {
it("rejects upload > 12 MB", async () => {
const deps = makeMocks();
const info = makeClientInfo();
const parsed: InboundUpload = {
type: "upload",
filename: "big.bin",
mimeType: "application/octet-stream",
data: b64("x"),
size: 13 * 1024 * 1024, // 13 MB
};
await callUpload(parsed, info, deps);
assert.equal(deps.sentMessages.length, 1);
assert.ok(deps.sentMessages[0].type === "system");
assert.ok((deps.sentMessages[0] as any).text.includes("Upload rejeté"));
assert.equal(deps.broadcast.mock.callCount(), 0);
});
it("rejects upload with empty data", async () => {
const deps = makeMocks();
const info = makeClientInfo();
const parsed: InboundUpload = {
type: "upload",
filename: "empty.txt",
mimeType: "text/plain",
data: "",
size: 100,
};
await callUpload(parsed, info, deps);
assert.equal(deps.sentMessages.length, 1);
assert.ok((deps.sentMessages[0] as any).text.includes("Upload rejeté"));
assert.equal(deps.broadcast.mock.callCount(), 0);
});
it("rate limits: rejects if > 50 MB/min", async () => {
const deps = makeMocks();
const info = makeClientInfo({
uploadBytesWindow: 49 * 1024 * 1024, // already uploaded 49 MB
lastUploadReset: Date.now(), // within current window
});
const parsed: InboundUpload = {
type: "upload",
filename: "more.bin",
mimeType: "application/octet-stream",
data: b64("x"),
size: 2 * 1024 * 1024, // 2 MB → total 51 MB > limit
};
await callUpload(parsed, info, deps);
assert.equal(deps.sentMessages.length, 1);
assert.ok((deps.sentMessages[0] as any).text.includes("limite de débit"));
assert.equal(deps.broadcast.mock.callCount(), 0);
});
it("processes text/plain file", async () => {
const deps = makeMocks();
const info = makeClientInfo();
const content = "Hello world content";
const parsed: InboundUpload = {
type: "upload",
filename: "note.txt",
mimeType: "text/plain",
data: b64(content),
size: Buffer.byteLength(content),
};
await callUpload(parsed, info, deps);
// Should broadcast notification
assert.equal(deps.broadcast.mock.callCount(), 1);
// Should log
assert.equal(deps.logChatMessage.mock.callCount(), 1);
// Should route to personas with analysis containing the text
assert.equal(deps.routeToPersonas.mock.callCount(), 1);
const routeArgs = deps.routeToPersonas.mock.calls[0].arguments as any[];
assert.equal(routeArgs[0], "#test");
assert.ok(routeArgs[1].includes("Fichier texte"));
assert.ok(routeArgs[1].includes(content));
});
it("processes application/json file", async () => {
const deps = makeMocks();
const info = makeClientInfo();
const content = '{"key": "value"}';
const parsed: InboundUpload = {
type: "upload",
filename: "data.json",
mimeType: "application/json",
data: b64(content),
size: Buffer.byteLength(content),
};
await callUpload(parsed, info, deps);
assert.equal(deps.routeToPersonas.mock.callCount(), 1);
const routeArgs = deps.routeToPersonas.mock.calls[0].arguments as any[];
assert.ok(routeArgs[1].includes("Fichier texte"));
assert.ok(routeArgs[1].includes(content));
});
it("broadcasts upload notification", async () => {
const deps = makeMocks();
const info = makeClientInfo({ nick: "alice", channel: "#chat" });
const parsed: InboundUpload = {
type: "upload",
filename: "pic.txt",
mimeType: "text/plain",
data: b64("hi"),
size: 2,
};
await callUpload(parsed, info, deps);
assert.equal(deps.broadcast.mock.callCount(), 1);
const [channel, msg] = deps.broadcast.mock.calls[0].arguments;
assert.equal(channel, "#chat");
assert.ok((msg as any).text.includes("alice"));
assert.ok((msg as any).text.includes("pic.txt"));
});
it("logs the upload event", async () => {
const deps = makeMocks();
const info = makeClientInfo({ nick: "bob" });
const parsed: InboundUpload = {
type: "upload",
filename: "doc.txt",
mimeType: "text/plain",
data: b64("data"),
size: 4,
};
await callUpload(parsed, info, deps);
assert.equal(deps.logChatMessage.mock.callCount(), 1);
const entry = deps.logChatMessage.mock.calls[0].arguments[0] as any;
assert.equal(entry.nick, "bob");
assert.ok(entry.text.includes("doc.txt"));
});
it("calls routeToPersonas with analysis", async () => {
const deps = makeMocks();
const info = makeClientInfo({ nick: "carol", channel: "#room" });
const parsed: InboundUpload = {
type: "upload",
filename: "readme.txt",
mimeType: "text/plain",
data: b64("important text"),
size: 14,
};
await callUpload(parsed, info, deps);
assert.equal(deps.routeToPersonas.mock.callCount(), 1);
const [ch, text] = deps.routeToPersonas.mock.calls[0].arguments as any[];
assert.equal(ch, "#room");
assert.ok(text.includes("carol"));
assert.ok(text.includes("readme.txt"));
assert.ok(text.includes("Analyse ce fichier"));
});
it("handles unknown file type (returns metadata)", async () => {
const deps = makeMocks();
const info = makeClientInfo();
const parsed: InboundUpload = {
type: "upload",
filename: "mystery.xyz",
mimeType: "application/x-custom",
data: b64("binary stuff"),
size: 1234,
};
await callUpload(parsed, info, deps);
assert.equal(deps.routeToPersonas.mock.callCount(), 1);
const routeText = (deps.routeToPersonas.mock.calls[0].arguments as any[])[1];
assert.ok(routeText.includes("mystery.xyz"));
assert.ok(routeText.includes("application/x-custom"));
});
});
+34 -598
View File
@@ -1,519 +1,27 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { getPersonaColor } from "@kxkm/ui";
import { useWebSocket } from "../hooks/useWebSocket";
import { useMinitelSounds } from "../hooks/useMinitelSounds";
import { resolveWebSocketUrl } from "../lib/websocket-url";
function buildWsUrl(): string {
const base = resolveWebSocketUrl();
const nick = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("kxkm-nick") : null;
if (!nick) return base;
const sep = base.includes("?") ? "&" : "?";
return `${base}${sep}nick=${encodeURIComponent(nick)}`;
}
interface ChatMsg {
id: number;
type: "system" | "message" | "join" | "part" | "persona" | "channelInfo" | "userlist" | "command" | "uploadCapability" | "audio" | "image" | "music";
nick?: string;
text?: string;
color?: string;
channel?: string;
users?: string[];
audioData?: string;
audioMime?: string;
imageData?: string;
imageMime?: string;
timestamp: number;
}
interface PersonaColor {
[nick: string]: string;
}
const MAX_MESSAGES = 500;
const MAX_HISTORY = 100;
let msgIdCounter = 0;
interface ChatMessageProps {
msg: ChatMsg;
getNickColor: (nick: string) => string | undefined;
channel: string;
}
const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor, channel }: ChatMessageProps) {
switch (msg.type) {
case "system":
return (
<div className="chat-msg chat-msg-system">
{(msg.text || "").split("\n").map((line, i) => (
<div key={i}>{line || "\u00A0"}</div>
))}
</div>
);
case "join":
return (
<div className="chat-msg chat-msg-system">
{"--> "}{msg.nick} a rejoint {msg.channel || channel}
</div>
);
case "part":
return (
<div className="chat-msg chat-msg-system">
{"<-- "}{msg.nick} a quitte {msg.channel || channel}
</div>
);
case "audio": {
const color = msg.nick ? getNickColor(msg.nick) : undefined;
return (
<div key={msg.id} className="chat-msg chat-msg-audio" style={color ? { color } : undefined}>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-audio-indicator">&#9835;</span>
<button className="chat-audio-play" onClick={() => {
if (msg.audioData && msg.audioMime) {
const a = new Audio(`data:${msg.audioMime};base64,${msg.audioData}`);
a.volume = 0.7;
a.play().catch(() => {});
}
}}>&#9654;</button>
</div>
);
}
case "image": {
const color = msg.nick ? getNickColor(msg.nick) : undefined;
return (
<div className="chat-msg chat-msg-image" style={color ? { color } : undefined}>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-text">{msg.text}</span>
{msg.imageData && msg.imageMime && (
<img
src={`data:${msg.imageMime};base64,${msg.imageData}`}
alt={msg.text || "Image generee"}
className="chat-generated-image"
style={{ maxWidth: "512px", maxHeight: "512px", display: "block", marginTop: "4px", borderRadius: "4px" }}
/>
)}
</div>
);
}
case "music": {
const color = msg.nick ? getNickColor(msg.nick) : undefined;
return (
<div key={msg.id} className="chat-msg chat-msg-music" style={color ? { color } : undefined}>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-text">{msg.text}</span>
{msg.audioData && msg.audioMime && (
<audio
controls
src={`data:${msg.audioMime};base64,${msg.audioData}`}
style={{ display: "block", marginTop: "4px", maxWidth: "400px" }}
/>
)}
</div>
);
}
case "message":
default: {
const color = msg.nick ? getNickColor(msg.nick) : undefined;
const className = color ? "chat-msg chat-msg-persona" : "chat-msg chat-msg-user";
return (
<div
className={className}
style={color ? { color } : undefined}
>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-text">{msg.text}</span>
</div>
);
}
}
});
import React from "react";
import { useChatState } from "../hooks/useChatState";
import { ChatMessage } from "./ChatMessage";
import { ChatInput } from "./ChatInput";
import { ChatSidebar } from "./ChatSidebar";
export default function Chat() {
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [users, setUsers] = useState<string[]>([]);
const [channel, setChannel] = useState("#general");
const [input, setInput] = useState("");
const [personaColors, setPersonaColors] = useState<PersonaColor>({});
// showConnect removed — connection animation handled by App.tsx
const [sidebarCollapsed, setSidebarCollapsed] = useState({ personas: true, users: true });
const [typingPersona, setTypingPersona] = useState<string | null>(null);
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const historyRef = useRef<string[]>([]);
const historyIndexRef = useRef(-1);
const savedInputRef = useRef("");
const keyPressCountRef = useRef(0);
const ullaTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const sounds = useMinitelSounds();
// Clean up /ulla timeouts on unmount
useEffect(() => {
return () => {
ullaTimersRef.current.forEach((id) => clearTimeout(id));
ullaTimersRef.current = [];
};
}, []);
const handleMessage = useCallback((data: unknown) => {
const msg = data as Record<string, unknown>;
if (!msg || typeof msg !== "object" || typeof msg.type !== "string") return;
const type = msg.type as ChatMsg["type"];
switch (type) {
case "persona":
if (typeof msg.nick === "string") {
const color = typeof msg.color === "string" && /^#[0-9a-fA-F]{3,8}$|^[a-z]{3,20}$/i.test(msg.color)
? msg.color
: getPersonaColor(msg.nick);
setPersonaColors((prev) => ({ ...prev, [msg.nick as string]: color }));
}
return;
case "userlist":
if (Array.isArray(msg.users)) {
setUsers(msg.users as string[]);
}
return;
case "channelInfo":
if (typeof msg.channel === "string") {
setChannel(msg.channel as string);
}
return;
case "uploadCapability":
// Silently ignore
return;
case "image": {
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type: "image",
nick: typeof msg.nick === "string" ? msg.nick : undefined,
text: typeof msg.text === "string" ? msg.text : undefined,
imageData: typeof msg.imageData === "string" ? msg.imageData : undefined,
imageMime: typeof msg.imageMime === "string" ? msg.imageMime : undefined,
timestamp: Date.now(),
};
setMessages((prev) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
return;
}
case "music": {
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type: "music",
nick: typeof msg.nick === "string" ? msg.nick : undefined,
text: typeof msg.text === "string" ? msg.text : undefined,
audioData: typeof msg.audioData === "string" ? msg.audioData : undefined,
audioMime: typeof msg.audioMime === "string" ? msg.audioMime : undefined,
timestamp: Date.now(),
};
setMessages((prev) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
return;
}
case "audio": {
if (typeof msg.data === "string" && typeof msg.mimeType === "string") {
// Add to messages as a playable audio message
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type: "audio",
nick: typeof msg.nick === "string" ? msg.nick : undefined,
text: "\u266A message vocal",
audioData: msg.data as string,
audioMime: msg.mimeType as string,
timestamp: Date.now(),
};
setMessages((prev) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
}
return;
}
default: {
// Intercept typing indicators — show in dedicated bar, don't add to messages
if (type === "system" && typeof msg.text === "string") {
const typingMatch = msg.text.match(/^(.+) est en train d'ecrire/);
if (typingMatch) {
setTypingPersona(typingMatch[1]);
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
typingTimerRef.current = setTimeout(() => setTypingPersona(null), 8000);
return; // Don't add to messages
}
}
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type,
nick: typeof msg.nick === "string" ? msg.nick : undefined,
text: typeof msg.text === "string" ? msg.text : undefined,
color: typeof msg.color === "string" ? msg.color : undefined,
channel: typeof msg.channel === "string" ? msg.channel : undefined,
timestamp: Date.now(),
};
setMessages((prev) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
// Clear typing indicator when persona actually responds
if (type === "message" && chatMsg.nick) {
setTypingPersona(null);
}
// Minitel receive beep for persona messages
if (type === "message" && chatMsg.nick && personaColors[chatMsg.nick]) {
sounds.receive();
}
// Update user list on join/part
if (type === "join" && chatMsg.nick) {
setUsers((prev) =>
prev.includes(chatMsg.nick!) ? prev : [...prev, chatMsg.nick!]
);
} else if (type === "part" && chatMsg.nick) {
setUsers((prev) => prev.filter((u) => u !== chatMsg.nick));
}
}
}
}, [sounds, personaColors]);
const [wsUrl] = useState(buildWsUrl);
const ws = useWebSocket({
url: wsUrl,
onMessage: handleMessage,
enabled: true,
});
// Track whether user has scrolled up
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;
function onScroll() {
if (!container) return;
const atBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < 40;
autoScrollRef.current = atBottom;
}
container.addEventListener("scroll", onScroll);
return () => container.removeEventListener("scroll", onScroll);
}, []);
// Auto-scroll when new messages arrive
useEffect(() => {
if (autoScrollRef.current) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
// Ref to always have current handleSend in the global keydown handler
const handleSendRef = useRef<() => void>(() => {});
// Global F-key handler (Minitel bar: F1=Sommaire F2=Suite F3=Retour F4=Annul F5=Envoi)
useEffect(() => {
function handleGlobalKeyDown(e: KeyboardEvent) {
switch (e.key) {
case "F1":
e.preventDefault();
window.location.hash = "#/";
break;
case "F2":
e.preventDefault();
messagesContainerRef.current?.scrollBy({ top: 300, behavior: "smooth" });
break;
case "F3":
e.preventDefault();
history.back();
break;
case "F4":
e.preventDefault();
setInput("");
break;
case "F5":
e.preventDefault();
handleSendRef.current();
break;
}
}
window.addEventListener("keydown", handleGlobalKeyDown);
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
}, []);
function handleSend() {
const trimmed = input.trim();
if (!trimmed || !ws.connected) return;
// Push to history
historyRef.current.unshift(trimmed);
if (historyRef.current.length > MAX_HISTORY) historyRef.current.pop();
historyIndexRef.current = -1;
savedInputRef.current = "";
// /ulla easter egg
if (trimmed.toLowerCase() === "/ulla") {
const ullaMessages = [
"\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557",
"\u2551 3615 ULLA \u2014 MESSAGERIE \u2551",
"\u2551 \u2551",
"\u2551 Salut beau gosse... \uD83D\uDE18 \u2551",
"\u2551 Tu cherches quoi ce soir ? \u2551",
"\u2551 Tape 1 pour RENCONTRE \u2551",
"\u2551 Tape 2 pour DIALOGUE \u2551",
"\u2551 Tape 3 pour MYSTERE \u2551",
"\u2551 \u2551",
"\u2551 0,34\u20AC/min \u2014 ah non, c'est \u2551",
"\u2551 gratuit ici, c'est du LOCAL \uD83C\uDFF4\u200D\u2620\uFE0F \u2551",
"\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D",
];
ullaTimersRef.current.forEach((id) => clearTimeout(id));
ullaTimersRef.current = [];
ullaMessages.forEach((line, i) => {
const timerId = setTimeout(() => {
setMessages(prev => [...prev, {
id: ++msgIdCounter,
type: "system",
text: line,
timestamp: Date.now(),
}]);
}, i * 200);
ullaTimersRef.current.push(timerId);
});
setInput("");
return;
}
sounds.send();
if (trimmed.startsWith("/")) {
ws.send({ type: "command", text: trimmed });
} else {
ws.send({ type: "message", text: trimmed });
}
setInput("");
}
// Keep handleSendRef in sync
useEffect(() => { handleSendRef.current = handleSend; });
const [tabIndex, setTabIndex] = useState(-1);
const [tabPrefix, setTabPrefix] = useState("");
function handleKeyDown(e: React.KeyboardEvent) {
// Debounced Minitel keyPress sound (every 3rd key)
if (e.key.length === 1) {
keyPressCountRef.current++;
if (keyPressCountRef.current % 3 === 0) {
sounds.keyPress();
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
return;
}
// Tab completion for nicks and slash commands
if (e.key === "Tab") {
e.preventDefault();
const text = input;
// Slash command completion
if (text.startsWith("/") && !text.includes(" ")) {
const slashCommands = ["/help", "/clear", "/nick", "/join", "/channels", "/msg", "/web", "/imagine", "/compose", "/status", "/model", "/persona", "/reload", "/export"];
const prefix = tabPrefix || text;
const matches = slashCommands.filter((c) => c.startsWith(prefix.toLowerCase()));
if (matches.length === 0) return;
const nextIdx = (tabIndex + 1) % matches.length;
setInput(matches[nextIdx] + " ");
setTabIndex(nextIdx);
if (!tabPrefix) setTabPrefix(prefix);
return;
}
// Nick completion
const words = text.split(" ");
const lastWord = words[words.length - 1];
const prefix = tabPrefix || lastWord;
const matches = users.filter((u) =>
u.toLowerCase().startsWith(prefix.toLowerCase()),
);
if (matches.length === 0) return;
const nextIdx = (tabIndex + 1) % matches.length;
words[words.length - 1] = matches[nextIdx] + (words.length === 1 ? ": " : " ");
setInput(words.join(" "));
setTabIndex(nextIdx);
if (!tabPrefix) setTabPrefix(prefix);
return;
}
// ArrowUp — navigate back through message history
if (e.key === "ArrowUp") {
const history = historyRef.current;
if (history.length === 0) return;
e.preventDefault();
if (historyIndexRef.current < history.length - 1) {
if (historyIndexRef.current === -1) savedInputRef.current = input;
historyIndexRef.current++;
setInput(history[historyIndexRef.current]);
}
return;
}
// ArrowDown — navigate forward through message history
if (e.key === "ArrowDown") {
e.preventDefault();
if (historyIndexRef.current > 0) {
historyIndexRef.current--;
setInput(historyRef.current[historyIndexRef.current]);
} else if (historyIndexRef.current === 0) {
historyIndexRef.current = -1;
setInput(savedInputRef.current);
}
return;
}
// Reset tab state on any other key
if (tabIndex >= 0) {
setTabIndex(-1);
setTabPrefix("");
}
}
const getNickColor = useCallback((nick: string): string | undefined => {
return personaColors[nick];
}, [personaColors]);
const {
messages,
users,
channel,
input,
setInput,
personaColors,
sidebarCollapsed,
toggleSidebar,
typingPersona,
ws,
messagesEndRef,
messagesContainerRef,
getNickColor,
handleSend,
handleKeyDown,
} = useChatState();
return (
<div className="chat-container">
@@ -532,48 +40,12 @@ export default function Chat() {
<div ref={messagesEndRef} />
</div>
<div className="chat-sidebar">
<div className="chat-sidebar-section">
<div className="chat-sidebar-title" onClick={() => setSidebarCollapsed(p => ({ ...p, personas: !p.personas }))}>
{sidebarCollapsed.personas ? "+" : "-"} Personas
</div>
{!sidebarCollapsed.personas && (
<div className="chat-sidebar-personas">
{Object.entries(
users.filter(u => personaColors[u]).reduce((acc, u) => {
// Group by first letter as simple grouping
const key = personaColors[u] ? "active" : "idle";
(acc[key] = acc[key] || []).push(u);
return acc;
}, {} as Record<string, string[]>)
).map(([, group]) =>
group.map(u => (
<div
key={u}
className="chat-sidebar-persona"
style={{ color: personaColors[u] }}
onClick={() => {
const input = document.querySelector<HTMLInputElement>(".chat-input input");
if (input) { input.value = `@${u} `; input.focus(); }
}}
title={`@${u}`}
>
{u}
</div>
))
)}
</div>
)}
</div>
<div className="chat-sidebar-section">
<div className="chat-sidebar-title" onClick={() => setSidebarCollapsed(p => ({ ...p, users: !p.users }))}>
{sidebarCollapsed.users ? "+" : "-"} Connectes ({users.filter(u => !personaColors[u]).length})
</div>
{!sidebarCollapsed.users && users.filter(u => !personaColors[u]).map((u) => (
<div key={u} className="chat-user">{u}</div>
))}
</div>
</div>
<ChatSidebar
personaColors={personaColors}
users={users}
sidebarCollapsed={sidebarCollapsed}
toggleSidebar={toggleSidebar}
/>
</div>
{typingPersona && (
@@ -582,50 +54,14 @@ export default function Chat() {
<span className="chat-typing-dots">...</span>
</div>
)}
<div className="chat-input">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
<ChatInput
input={input}
setInput={setInput}
onSend={handleSend}
onKeyDown={handleKeyDown}
placeholder={ws.connected ? "Message ou /commande... (Tab pour compléter)" : "Connexion en cours..."}
disabled={!ws.connected}
autoFocus
ws={ws}
/>
<label className="btn btn-secondary chat-upload-btn" title="Joindre un fichier">
+
<input
type="file"
style={{ display: "none" }}
accept="image/*,audio/*,text/*,.pdf,.json,.jsonl,.csv,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp,.rtf,.epub,.html,.xml,.yaml,.yml,.toml,.ini,.log,.sh,.py,.js,.ts,.c,.cpp,.rs,.go,.java,.sql"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file || !ws.connected) return;
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(",")[1];
ws.send({
type: "upload",
filename: file.name,
mimeType: file.type,
size: file.size,
data: base64,
});
};
reader.readAsDataURL(file);
e.target.value = "";
}}
disabled={!ws.connected}
/>
</label>
<button
className="btn btn-primary"
onClick={handleSend}
disabled={!ws.connected || !input.trim()}
>
Envoyer
</button>
</div>
</div>
);
}
@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChatInput } from "./ChatInput";
import type { UseWebSocketReturn } from "../hooks/useWebSocket";
function mockWs(connected: boolean): UseWebSocketReturn {
return {
connected,
send: vi.fn(),
lastMessage: null,
} as unknown as UseWebSocketReturn;
}
function renderInput(overrides: Partial<{
input: string;
setInput: (v: string) => void;
onSend: () => void;
onKeyDown: (e: React.KeyboardEvent) => void;
ws: UseWebSocketReturn;
}> = {}) {
const props = {
input: overrides.input ?? "",
setInput: overrides.setInput ?? vi.fn(),
onSend: overrides.onSend ?? vi.fn(),
onKeyDown: overrides.onKeyDown ?? vi.fn(),
ws: overrides.ws ?? mockWs(true),
};
return { ...render(<ChatInput {...props} />), props };
}
describe("ChatInput", () => {
it("affiche l'input et le bouton envoyer", () => {
renderInput();
expect(screen.getByRole("textbox")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Envoyer" })).toBeInTheDocument();
});
it("le bouton est disabled quand ws non connecte", () => {
renderInput({ ws: mockWs(false) });
expect(screen.getByRole("button", { name: "Envoyer" })).toBeDisabled();
expect(screen.getByRole("textbox")).toBeDisabled();
});
it("le bouton est disabled quand l'input est vide", () => {
renderInput({ input: "", ws: mockWs(true) });
expect(screen.getByRole("button", { name: "Envoyer" })).toBeDisabled();
});
it("le bouton est enabled quand input non vide et ws connecte", () => {
renderInput({ input: "hello", ws: mockWs(true) });
expect(screen.getByRole("button", { name: "Envoyer" })).toBeEnabled();
});
it("appelle onSend quand le bouton est clique", async () => {
const user = userEvent.setup();
const onSend = vi.fn();
renderInput({ input: "test", onSend, ws: mockWs(true) });
await user.click(screen.getByRole("button", { name: "Envoyer" }));
expect(onSend).toHaveBeenCalledOnce();
});
it("appelle onKeyDown quand une touche est pressee", async () => {
const user = userEvent.setup();
const onKeyDown = vi.fn();
renderInput({ onKeyDown, ws: mockWs(true) });
const input = screen.getByRole("textbox");
await user.click(input);
await user.keyboard("a");
expect(onKeyDown).toHaveBeenCalled();
});
it("affiche le bouton upload avec file input", () => {
const { container } = renderInput();
const fileInput = container.querySelector('input[type="file"]');
expect(fileInput).toBeTruthy();
expect(screen.getByText("+")).toBeInTheDocument();
});
});
+59
View File
@@ -0,0 +1,59 @@
import React from "react";
import type { UseWebSocketReturn } from "../hooks/useWebSocket";
export interface ChatInputProps {
input: string;
setInput: (value: string) => void;
onSend: () => void;
onKeyDown: (e: React.KeyboardEvent) => void;
ws: UseWebSocketReturn;
}
export function ChatInput({ input, setInput, onSend, onKeyDown, ws }: ChatInputProps) {
return (
<div className="chat-input">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown}
placeholder={ws.connected ? "Message ou /commande... (Tab pour compléter)" : "Connexion en cours..."}
disabled={!ws.connected}
autoFocus
/>
<label className="btn btn-secondary chat-upload-btn" title="Joindre un fichier">
+
<input
type="file"
style={{ display: "none" }}
accept="image/*,audio/*,text/*,.pdf,.json,.jsonl,.csv,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp,.rtf,.epub,.html,.xml,.yaml,.yml,.toml,.ini,.log,.sh,.py,.js,.ts,.c,.cpp,.rs,.go,.java,.sql"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file || !ws.connected) return;
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(",")[1];
ws.send({
type: "upload",
filename: file.name,
mimeType: file.type,
size: file.size,
data: base64,
});
};
reader.readAsDataURL(file);
e.target.value = "";
}}
disabled={!ws.connected}
/>
</label>
<button
className="btn btn-primary"
onClick={onSend}
disabled={!ws.connected || !input.trim()}
>
Envoyer
</button>
</div>
);
}
@@ -0,0 +1,102 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChatMessage } from "./ChatMessage";
import type { ChatMsg } from "./chat-types";
const baseMsg: Omit<ChatMsg, "type"> = { id: 1, timestamp: Date.now() };
const getNickColor = vi.fn((nick: string) => {
if (nick === "Arlequin") return "#ff00ff";
return undefined;
});
const channel = "#salon";
describe("ChatMessage", () => {
it("affiche un message system avec les lignes", () => {
const msg: ChatMsg = { ...baseMsg, type: "system", text: "Ligne 1\nLigne 2\nLigne 3" };
render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
expect(screen.getByText("Ligne 1")).toBeInTheDocument();
expect(screen.getByText("Ligne 2")).toBeInTheDocument();
expect(screen.getByText("Ligne 3")).toBeInTheDocument();
});
it("affiche un message join avec le nick et le channel", () => {
const msg: ChatMsg = { ...baseMsg, type: "join", nick: "Bob", channel: "#test" };
render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
expect(screen.getByText(/Bob/)).toBeInTheDocument();
expect(screen.getByText(/a rejoint/)).toBeInTheDocument();
expect(screen.getByText(/#test/)).toBeInTheDocument();
});
it("affiche un message part", () => {
const msg: ChatMsg = { ...baseMsg, type: "part", nick: "Bob" };
render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
expect(screen.getByText(/Bob/)).toBeInTheDocument();
expect(screen.getByText(/a quitte/)).toBeInTheDocument();
expect(screen.getByText(/#salon/)).toBeInTheDocument();
});
it("affiche un message texte avec la couleur du nick (persona)", () => {
const msg: ChatMsg = { ...baseMsg, type: "message", nick: "Arlequin", text: "Bonjour le monde" };
render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
const container = screen.getByText("Bonjour le monde").closest(".chat-msg");
expect(container).toHaveClass("chat-msg-persona");
expect(container).toHaveStyle({ color: "#ff00ff" });
});
it("affiche un message texte user (sans couleur)", () => {
const msg: ChatMsg = { ...baseMsg, type: "message", nick: "Jean", text: "Salut" };
render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
const container = screen.getByText("Salut").closest(".chat-msg");
expect(container).toHaveClass("chat-msg-user");
expect(container).not.toHaveStyle({ color: "#ff00ff" });
});
it("affiche un message audio avec le bouton play", () => {
const msg: ChatMsg = {
...baseMsg,
type: "audio",
nick: "Arlequin",
audioData: "AAAA",
audioMime: "audio/wav",
};
render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
expect(screen.getByText(/Arlequin/)).toBeInTheDocument();
const playBtn = screen.getByRole("button");
expect(playBtn).toBeInTheDocument();
expect(playBtn).toHaveClass("chat-audio-play");
});
it("affiche un message image avec l'image", () => {
const msg: ChatMsg = {
...baseMsg,
type: "image",
nick: "Arlequin",
text: "Un paysage",
imageData: "AAAA",
imageMime: "image/png",
};
render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
expect(screen.getByText("Un paysage")).toBeInTheDocument();
const img = screen.getByRole("img");
expect(img).toHaveAttribute("src", "data:image/png;base64,AAAA");
expect(img).toHaveAttribute("alt", "Un paysage");
});
it("affiche un message music avec le player audio", () => {
const msg: ChatMsg = {
...baseMsg,
type: "music",
nick: "Arlequin",
text: "Ma composition",
audioData: "BBBB",
audioMime: "audio/mpeg",
};
const { container } = render(<ChatMessage msg={msg} getNickColor={getNickColor} channel={channel} />);
expect(screen.getByText("Ma composition")).toBeInTheDocument();
const audio = container.querySelector("audio");
expect(audio).toBeTruthy();
expect(audio?.getAttribute("src")).toBe("data:audio/mpeg;base64,BBBB");
expect(audio?.hasAttribute("controls")).toBe(true);
});
});
+110
View File
@@ -0,0 +1,110 @@
import React from "react";
import type { ChatMsg } from "./chat-types";
export interface ChatMessageProps {
msg: ChatMsg;
getNickColor: (nick: string) => string | undefined;
channel: string;
}
export const ChatMessage = React.memo(function ChatMessage({ msg, getNickColor, channel }: ChatMessageProps) {
switch (msg.type) {
case "system":
return (
<div className="chat-msg chat-msg-system">
{(msg.text || "").split("\n").map((line, i) => (
<div key={i}>{line || "\u00A0"}</div>
))}
</div>
);
case "join":
return (
<div className="chat-msg chat-msg-system">
{"--> "}{msg.nick} a rejoint {msg.channel || channel}
</div>
);
case "part":
return (
<div className="chat-msg chat-msg-system">
{"<-- "}{msg.nick} a quitte {msg.channel || channel}
</div>
);
case "audio": {
const color = msg.nick ? getNickColor(msg.nick) : undefined;
return (
<div key={msg.id} className="chat-msg chat-msg-audio" style={color ? { color } : undefined}>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-audio-indicator">&#9835;</span>
<button className="chat-audio-play" onClick={() => {
if (msg.audioData && msg.audioMime) {
const a = new Audio(`data:${msg.audioMime};base64,${msg.audioData}`);
a.volume = 0.7;
a.play().catch(() => {});
}
}}>&#9654;</button>
</div>
);
}
case "image": {
const color = msg.nick ? getNickColor(msg.nick) : undefined;
return (
<div className="chat-msg chat-msg-image" style={color ? { color } : undefined}>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-text">{msg.text}</span>
{msg.imageData && msg.imageMime && (
<img
src={`data:${msg.imageMime};base64,${msg.imageData}`}
alt={msg.text || "Image generee"}
className="chat-generated-image"
style={{ maxWidth: "512px", maxHeight: "512px", display: "block", marginTop: "4px", borderRadius: "4px" }}
/>
)}
</div>
);
}
case "music": {
const color = msg.nick ? getNickColor(msg.nick) : undefined;
return (
<div key={msg.id} className="chat-msg chat-msg-music" style={color ? { color } : undefined}>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-text">{msg.text}</span>
{msg.audioData && msg.audioMime && (
<audio
controls
src={`data:${msg.audioMime};base64,${msg.audioData}`}
style={{ display: "block", marginTop: "4px", maxWidth: "400px" }}
/>
)}
</div>
);
}
case "message":
default: {
const color = msg.nick ? getNickColor(msg.nick) : undefined;
const className = color ? "chat-msg chat-msg-persona" : "chat-msg chat-msg-user";
return (
<div
className={className}
style={color ? { color } : undefined}
>
<span className="chat-nick" style={color ? { color } : undefined}>
{"<"}{msg.nick || "???"}{">"}{" "}
</span>
<span className="chat-text">{msg.text}</span>
</div>
);
}
}
});
@@ -0,0 +1,80 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ChatSidebar } from "./ChatSidebar";
import type { PersonaColor } from "./chat-types";
const personaColors: PersonaColor = {
Arlequin: "#ff00ff",
Pierrot: "#00ff00",
};
const users = ["Arlequin", "Pierrot", "Jean", "Marie"];
function renderSidebar(overrides: Partial<{
personaColors: PersonaColor;
users: string[];
sidebarCollapsed: { personas: boolean; users: boolean };
toggleSidebar: (section: "personas" | "users") => void;
}> = {}) {
const props = {
personaColors: overrides.personaColors ?? personaColors,
users: overrides.users ?? users,
sidebarCollapsed: overrides.sidebarCollapsed ?? { personas: false, users: false },
toggleSidebar: overrides.toggleSidebar ?? vi.fn(),
};
return { ...render(<ChatSidebar {...props} />), props };
}
describe("ChatSidebar", () => {
it("affiche les sections personas et connectes", () => {
renderSidebar();
expect(screen.getByText(/Personas/)).toBeInTheDocument();
expect(screen.getByText(/Connectes/)).toBeInTheDocument();
});
it("toggle la section personas au clic", async () => {
const user = userEvent.setup();
const toggleSidebar = vi.fn();
renderSidebar({ toggleSidebar });
await user.click(screen.getByText(/Personas/));
expect(toggleSidebar).toHaveBeenCalledWith("personas");
});
it("toggle la section users au clic", async () => {
const user = userEvent.setup();
const toggleSidebar = vi.fn();
renderSidebar({ toggleSidebar });
await user.click(screen.getByText(/Connectes/));
expect(toggleSidebar).toHaveBeenCalledWith("users");
});
it("affiche les personas avec leurs couleurs", () => {
renderSidebar();
const arlequin = screen.getByText(/Arlequin/);
expect(arlequin).toBeInTheDocument();
expect(arlequin.closest(".chat-sidebar-persona")).toHaveStyle({ color: "#ff00ff" });
const pierrot = screen.getByText(/Pierrot/);
expect(pierrot).toBeInTheDocument();
expect(pierrot.closest(".chat-sidebar-persona")).toHaveStyle({ color: "#00ff00" });
});
it("affiche les users sans couleur", () => {
renderSidebar();
expect(screen.getByText("Jean")).toBeInTheDocument();
expect(screen.getByText("Marie")).toBeInTheDocument();
expect(screen.getByText("Jean").closest(".chat-user")).toBeInTheDocument();
});
it("masque les personas quand la section est collapsed", () => {
renderSidebar({ sidebarCollapsed: { personas: true, users: false } });
expect(screen.queryByText(/Arlequin/)).not.toBeInTheDocument();
expect(screen.getByText("Jean")).toBeInTheDocument();
});
it("masque les users quand la section est collapsed", () => {
renderSidebar({ sidebarCollapsed: { personas: false, users: true } });
expect(screen.getByText(/Arlequin/)).toBeInTheDocument();
expect(screen.queryByText("Jean")).not.toBeInTheDocument();
});
});
+55
View File
@@ -0,0 +1,55 @@
import React from "react";
import type { PersonaColor } from "./chat-types";
export interface ChatSidebarProps {
personaColors: PersonaColor;
users: string[];
sidebarCollapsed: { personas: boolean; users: boolean };
toggleSidebar: (section: "personas" | "users") => void;
}
export function ChatSidebar({ personaColors, users, sidebarCollapsed, toggleSidebar }: ChatSidebarProps) {
return (
<div className="chat-sidebar">
<div className="chat-sidebar-section">
<div className="chat-sidebar-title" onClick={() => toggleSidebar("personas")}>
{sidebarCollapsed.personas ? "+" : "-"} Personas
</div>
{!sidebarCollapsed.personas && (
<div className="chat-sidebar-personas">
{Object.entries(
users.filter(u => personaColors[u]).reduce((acc, u) => {
const key = personaColors[u] ? "active" : "idle";
(acc[key] = acc[key] || []).push(u);
return acc;
}, {} as Record<string, string[]>)
).map(([, group]) =>
group.map(u => (
<div
key={u}
className="chat-sidebar-persona"
style={{ color: personaColors[u] }}
onClick={() => {
const input = document.querySelector<HTMLInputElement>(".chat-input input");
if (input) { input.value = `@${u} `; input.focus(); }
}}
title={`@${u}`}
>
{u}
</div>
))
)}
</div>
)}
</div>
<div className="chat-sidebar-section">
<div className="chat-sidebar-title" onClick={() => toggleSidebar("users")}>
{sidebarCollapsed.users ? "+" : "-"} Connectes ({users.filter(u => !personaColors[u]).length})
</div>
{!sidebarCollapsed.users && users.filter(u => !personaColors[u]).map((u) => (
<div key={u} className="chat-user">{u}</div>
))}
</div>
</div>
);
}
+11 -4
View File
@@ -28,23 +28,30 @@ describe("PersonaList", () => {
});
it("renders persona cards after loading", async () => {
const user = userEvent.setup();
vi.mocked(api.listPersonas).mockResolvedValue(mockPersonas);
render(<PersonaList onSelect={vi.fn()} />);
const gpt4Group = await screen.findByText("gpt-4");
await user.click(gpt4Group);
expect(await screen.findByText("Clown Rouge")).toBeInTheDocument();
expect(screen.getByText("Clown Bleu")).toBeInTheDocument();
const claudeGroup = await screen.findByText("claude-3");
await user.click(claudeGroup);
expect(await screen.findByText("Clown Bleu")).toBeInTheDocument();
expect(screen.getByText("gpt-4")).toBeInTheDocument();
expect(screen.getByText("claude-3")).toBeInTheDocument();
expect(screen.getByText("Un clown joyeux")).toBeInTheDocument();
});
it("calls onSelect when a persona card is clicked", async () => {
const user = userEvent.setup();
vi.mocked(api.listPersonas).mockResolvedValue(mockPersonas);
const onSelect = vi.fn();
render(<PersonaList onSelect={onSelect} />);
const group = await screen.findByText("gpt-4");
await user.click(group);
const card = await screen.findByText("Clown Rouge");
await userEvent.click(card);
await user.click(card);
expect(onSelect).toHaveBeenCalledWith("p1");
});
@@ -59,6 +66,6 @@ describe("PersonaList", () => {
vi.mocked(api.listPersonas).mockRejectedValue(new Error("Network error"));
render(<PersonaList onSelect={vi.fn()} />);
expect(await screen.findByText("Network error")).toBeInTheDocument();
expect(await screen.findByText(/ERREUR: Network error/)).toBeInTheDocument();
});
});
+18
View File
@@ -0,0 +1,18 @@
export interface ChatMsg {
id: number;
type: "system" | "message" | "join" | "part" | "persona" | "channelInfo" | "userlist" | "command" | "uploadCapability" | "audio" | "image" | "music";
nick?: string;
text?: string;
color?: string;
channel?: string;
users?: string[];
audioData?: string;
audioMime?: string;
imageData?: string;
imageMime?: string;
timestamp: number;
}
export interface PersonaColor {
[nick: string]: string;
}
+77
View File
@@ -0,0 +1,77 @@
import { useCallback, useEffect, useState } from "react";
import { api, type SessionData } from "../api";
const NICK_KEY = "kxkm-nick";
const EMAIL_KEY = "kxkm-email";
function readStorage(key: string): string | null {
try {
return typeof sessionStorage !== "undefined" ? sessionStorage.getItem(key) : null;
} catch {
return null;
}
}
function writeStorage(key: string, value: string): void {
try {
if (typeof sessionStorage !== "undefined") sessionStorage.setItem(key, value);
} catch {
// Ignore storage failures in private mode / tests.
}
}
function removeStorage(key: string): void {
try {
if (typeof sessionStorage !== "undefined") sessionStorage.removeItem(key);
} catch {
// Ignore storage failures in private mode / tests.
}
}
export function useAppSession() {
const [session, setSession] = useState<SessionData | null>(null);
const [nick, setNickState] = useState<string | null>(() => readStorage(NICK_KEY));
const [checkingSession, setCheckingSession] = useState(true);
useEffect(() => {
let alive = true;
api.getSession()
.then((current) => {
if (alive) setSession(current);
})
.catch(() => {
if (alive) setSession(null);
})
.finally(() => {
if (alive) setCheckingSession(false);
});
return () => {
alive = false;
};
}, [nick]);
const setNick = useCallback((username: string, email?: string) => {
setNickState(username);
writeStorage(NICK_KEY, username);
if (email) writeStorage(EMAIL_KEY, email);
else removeStorage(EMAIL_KEY);
}, []);
const clearSessionState = useCallback(() => {
setSession(null);
setNickState(null);
removeStorage(NICK_KEY);
removeStorage(EMAIL_KEY);
}, []);
return {
session,
setSession,
nick,
setNick,
clearSessionState,
checkingSession,
};
}
+428
View File
@@ -0,0 +1,428 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { getPersonaColor } from "@kxkm/ui";
import { useWebSocket } from "./useWebSocket";
import type { UseWebSocketReturn } from "./useWebSocket";
import { useMinitelSounds } from "./useMinitelSounds";
import { resolveWebSocketUrl } from "../lib/websocket-url";
import type { ChatMsg, PersonaColor } from "../components/chat-types";
const MAX_MESSAGES = 500;
const MAX_HISTORY = 100;
let msgIdCounter = 0;
function buildWsUrl(): string {
const base = resolveWebSocketUrl();
const nick = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("kxkm-nick") : null;
if (!nick) return base;
const sep = base.includes("?") ? "&" : "?";
return `${base}${sep}nick=${encodeURIComponent(nick)}`;
}
export interface UseChatStateReturn {
messages: ChatMsg[];
users: string[];
channel: string;
input: string;
setInput: (value: string) => void;
personaColors: PersonaColor;
sidebarCollapsed: { personas: boolean; users: boolean };
toggleSidebar: (section: "personas" | "users") => void;
typingPersona: string | null;
ws: UseWebSocketReturn;
sounds: ReturnType<typeof useMinitelSounds>;
messagesEndRef: React.RefObject<HTMLDivElement | null>;
messagesContainerRef: React.RefObject<HTMLDivElement | null>;
getNickColor: (nick: string) => string | undefined;
handleSend: () => void;
handleKeyDown: (e: React.KeyboardEvent) => void;
}
export function useChatState(): UseChatStateReturn {
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [users, setUsers] = useState<string[]>([]);
const [channel, setChannel] = useState("#general");
const [input, setInput] = useState("");
const [personaColors, setPersonaColors] = useState<PersonaColor>({});
const [sidebarCollapsed, setSidebarCollapsed] = useState({ personas: true, users: true });
const [typingPersona, setTypingPersona] = useState<string | null>(null);
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const historyRef = useRef<string[]>([]);
const historyIndexRef = useRef(-1);
const savedInputRef = useRef("");
const keyPressCountRef = useRef(0);
const ullaTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const [tabIndex, setTabIndex] = useState(-1);
const [tabPrefix, setTabPrefix] = useState("");
const sounds = useMinitelSounds();
// Clean up /ulla timeouts on unmount
useEffect(() => {
return () => {
ullaTimersRef.current.forEach((id) => clearTimeout(id));
ullaTimersRef.current = [];
};
}, []);
const handleMessage = useCallback((data: unknown) => {
const msg = data as Record<string, unknown>;
if (!msg || typeof msg !== "object" || typeof msg.type !== "string") return;
const type = msg.type as ChatMsg["type"];
switch (type) {
case "persona":
if (typeof msg.nick === "string") {
const color = typeof msg.color === "string" && /^#[0-9a-fA-F]{3,8}$|^[a-z]{3,20}$/i.test(msg.color)
? msg.color
: getPersonaColor(msg.nick);
setPersonaColors((prev) => ({ ...prev, [msg.nick as string]: color }));
}
return;
case "userlist":
if (Array.isArray(msg.users)) {
setUsers(msg.users as string[]);
}
return;
case "channelInfo":
if (typeof msg.channel === "string") {
setChannel(msg.channel as string);
}
return;
case "uploadCapability":
return;
case "image": {
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type: "image",
nick: typeof msg.nick === "string" ? msg.nick : undefined,
text: typeof msg.text === "string" ? msg.text : undefined,
imageData: typeof msg.imageData === "string" ? msg.imageData : undefined,
imageMime: typeof msg.imageMime === "string" ? msg.imageMime : undefined,
timestamp: Date.now(),
};
setMessages((prev) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
return;
}
case "music": {
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type: "music",
nick: typeof msg.nick === "string" ? msg.nick : undefined,
text: typeof msg.text === "string" ? msg.text : undefined,
audioData: typeof msg.audioData === "string" ? msg.audioData : undefined,
audioMime: typeof msg.audioMime === "string" ? msg.audioMime : undefined,
timestamp: Date.now(),
};
setMessages((prev) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
return;
}
case "audio": {
if (typeof msg.data === "string" && typeof msg.mimeType === "string") {
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type: "audio",
nick: typeof msg.nick === "string" ? msg.nick : undefined,
text: "\u266A message vocal",
audioData: msg.data as string,
audioMime: msg.mimeType as string,
timestamp: Date.now(),
};
setMessages((prev) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
}
return;
}
default: {
// Intercept typing indicators
if (type === "system" && typeof msg.text === "string") {
const typingMatch = msg.text.match(/^(.+) est en train d'ecrire/);
if (typingMatch) {
setTypingPersona(typingMatch[1]);
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
typingTimerRef.current = setTimeout(() => setTypingPersona(null), 8000);
return;
}
}
const chatMsg: ChatMsg = {
id: ++msgIdCounter,
type,
nick: typeof msg.nick === "string" ? msg.nick : undefined,
text: typeof msg.text === "string" ? msg.text : undefined,
color: typeof msg.color === "string" ? msg.color : undefined,
channel: typeof msg.channel === "string" ? msg.channel : undefined,
timestamp: Date.now(),
};
setMessages((prev) => {
const next = [...prev, chatMsg];
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next;
});
if (type === "message" && chatMsg.nick) {
setTypingPersona(null);
}
if (type === "message" && chatMsg.nick && personaColors[chatMsg.nick]) {
sounds.receive();
}
if (type === "join" && chatMsg.nick) {
setUsers((prev) =>
prev.includes(chatMsg.nick!) ? prev : [...prev, chatMsg.nick!]
);
} else if (type === "part" && chatMsg.nick) {
setUsers((prev) => prev.filter((u) => u !== chatMsg.nick));
}
}
}
}, [sounds, personaColors]);
const [wsUrl] = useState(buildWsUrl);
const ws = useWebSocket({
url: wsUrl,
onMessage: handleMessage,
enabled: true,
});
// Track whether user has scrolled up
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;
function onScroll() {
if (!container) return;
const atBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < 40;
autoScrollRef.current = atBottom;
}
container.addEventListener("scroll", onScroll);
return () => container.removeEventListener("scroll", onScroll);
}, []);
// Auto-scroll when new messages arrive
useEffect(() => {
if (autoScrollRef.current) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
// Ref to always have current handleSend in the global keydown handler
const handleSendRef = useRef<() => void>(() => {});
// Global F-key handler
useEffect(() => {
function handleGlobalKeyDown(e: KeyboardEvent) {
switch (e.key) {
case "F1":
e.preventDefault();
window.location.hash = "#/";
break;
case "F2":
e.preventDefault();
messagesContainerRef.current?.scrollBy({ top: 300, behavior: "smooth" });
break;
case "F3":
e.preventDefault();
history.back();
break;
case "F4":
e.preventDefault();
setInput("");
break;
case "F5":
e.preventDefault();
handleSendRef.current();
break;
}
}
window.addEventListener("keydown", handleGlobalKeyDown);
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
}, []);
function handleSend() {
const trimmed = input.trim();
if (!trimmed || !ws.connected) return;
// Push to history
historyRef.current.unshift(trimmed);
if (historyRef.current.length > MAX_HISTORY) historyRef.current.pop();
historyIndexRef.current = -1;
savedInputRef.current = "";
// /ulla easter egg
if (trimmed.toLowerCase() === "/ulla") {
const ullaMessages = [
"\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557",
"\u2551 3615 ULLA \u2014 MESSAGERIE \u2551",
"\u2551 \u2551",
"\u2551 Salut beau gosse... \uD83D\uDE18 \u2551",
"\u2551 Tu cherches quoi ce soir ? \u2551",
"\u2551 Tape 1 pour RENCONTRE \u2551",
"\u2551 Tape 2 pour DIALOGUE \u2551",
"\u2551 Tape 3 pour MYSTERE \u2551",
"\u2551 \u2551",
"\u2551 0,34\u20AC/min \u2014 ah non, c'est \u2551",
"\u2551 gratuit ici, c'est du LOCAL \uD83C\uDFF4\u200D\u2620\uFE0F \u2551",
"\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D",
];
ullaTimersRef.current.forEach((id) => clearTimeout(id));
ullaTimersRef.current = [];
ullaMessages.forEach((line, i) => {
const timerId = setTimeout(() => {
setMessages(prev => [...prev, {
id: ++msgIdCounter,
type: "system",
text: line,
timestamp: Date.now(),
}]);
}, i * 200);
ullaTimersRef.current.push(timerId);
});
setInput("");
return;
}
sounds.send();
if (trimmed.startsWith("/")) {
ws.send({ type: "command", text: trimmed });
} else {
ws.send({ type: "message", text: trimmed });
}
setInput("");
}
// Keep handleSendRef in sync
useEffect(() => { handleSendRef.current = handleSend; });
function handleKeyDown(e: React.KeyboardEvent) {
// Debounced Minitel keyPress sound (every 3rd key)
if (e.key.length === 1) {
keyPressCountRef.current++;
if (keyPressCountRef.current % 3 === 0) {
sounds.keyPress();
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
return;
}
// Tab completion for nicks and slash commands
if (e.key === "Tab") {
e.preventDefault();
const text = input;
// Slash command completion
if (text.startsWith("/") && !text.includes(" ")) {
const slashCommands = ["/help", "/clear", "/nick", "/join", "/channels", "/msg", "/web", "/imagine", "/compose", "/status", "/model", "/persona", "/reload", "/export"];
const prefix = tabPrefix || text;
const matches = slashCommands.filter((c) => c.startsWith(prefix.toLowerCase()));
if (matches.length === 0) return;
const nextIdx = (tabIndex + 1) % matches.length;
setInput(matches[nextIdx] + " ");
setTabIndex(nextIdx);
if (!tabPrefix) setTabPrefix(prefix);
return;
}
// Nick completion
const words = text.split(" ");
const lastWord = words[words.length - 1];
const prefix = tabPrefix || lastWord;
const matches = users.filter((u) =>
u.toLowerCase().startsWith(prefix.toLowerCase()),
);
if (matches.length === 0) return;
const nextIdx = (tabIndex + 1) % matches.length;
words[words.length - 1] = matches[nextIdx] + (words.length === 1 ? ": " : " ");
setInput(words.join(" "));
setTabIndex(nextIdx);
if (!tabPrefix) setTabPrefix(prefix);
return;
}
// ArrowUp — navigate back through message history
if (e.key === "ArrowUp") {
const history = historyRef.current;
if (history.length === 0) return;
e.preventDefault();
if (historyIndexRef.current < history.length - 1) {
if (historyIndexRef.current === -1) savedInputRef.current = input;
historyIndexRef.current++;
setInput(history[historyIndexRef.current]);
}
return;
}
// ArrowDown — navigate forward through message history
if (e.key === "ArrowDown") {
e.preventDefault();
if (historyIndexRef.current > 0) {
historyIndexRef.current--;
setInput(historyRef.current[historyIndexRef.current]);
} else if (historyIndexRef.current === 0) {
historyIndexRef.current = -1;
setInput(savedInputRef.current);
}
return;
}
// Reset tab state on any other key
if (tabIndex >= 0) {
setTabIndex(-1);
setTabPrefix("");
}
}
const getNickColor = useCallback((nick: string): string | undefined => {
return personaColors[nick];
}, [personaColors]);
const toggleSidebar = useCallback((section: "personas" | "users") => {
setSidebarCollapsed(p => ({ ...p, [section]: !p[section] }));
}, []);
return {
messages,
users,
channel,
input,
setInput,
personaColors,
sidebarCollapsed,
toggleSidebar,
typingPersona,
ws,
sounds,
messagesEndRef,
messagesContainerRef,
getNickColor,
handleSend,
handleKeyDown,
};
}
+38
View File
@@ -0,0 +1,38 @@
import { useCallback, useEffect, useState } from "react";
export interface HashRoute {
page: string;
id: string;
}
function parseHash(hash: string, defaultPage = "chat"): HashRoute {
const normalized = hash.replace(/^#\/?/, "");
if (!normalized) return { page: defaultPage, id: "" };
const parts = normalized.split("/");
const page = parts[0] || defaultPage;
const id = parts.slice(1).join("/");
return { page, id };
}
export function useHashRoute(defaultPage = "chat") {
const [route, setRoute] = useState<HashRoute>(() =>
typeof window !== "undefined" ? parseHash(window.location.hash, defaultPage) : { page: defaultPage, id: "" },
);
useEffect(() => {
function onHashChange() {
setRoute(parseHash(window.location.hash, defaultPage));
}
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, [defaultPage]);
const navigate = useCallback((page: string, id?: string) => {
const next = id ? `${page}/${id}` : page;
setRoute(parseHash(`#${next}`, defaultPage));
window.location.hash = next;
}, [defaultPage]);
return { route, navigate };
}
+11
View File
@@ -0,0 +1,11 @@
export function resolveWebSocketUrl(path = "/ws"): string {
const configured = import.meta.env.VITE_WS_URL;
if (configured) return configured;
if (typeof window === "undefined") {
return `ws://127.0.0.1:4180${path}`;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}${path}`;
}
+5
View File
@@ -1,8 +1,13 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { publishUiCssVariables } from "@kxkm/ui";
import App from "./App";
import "./styles.css";
if (typeof document !== "undefined") {
publishUiCssVariables(document.documentElement.style);
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -16,6 +16,7 @@
"status": "node ../../scripts/workspace-package.js worker status",
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json --pretty false",
"check": "tsc -p tsconfig.json --noEmit --pretty false"
"check": "tsc -p tsconfig.json --noEmit --pretty false",
"test": "npm run build && node --test dist/*.test.js"
}
}
+53 -421
View File
@@ -5,6 +5,9 @@
* topological order using stub executors, and updates run status in the DB.
*/
import { promisify } from "node:util";
import { execFile } from "node:child_process";
import * as path from "node:path";
import {
loadDatabaseConfig,
createPostgresPool,
@@ -15,34 +18,18 @@ import {
import {
createNodeEngineRegistry,
createQueueState,
canDequeue,
dequeue,
enqueue,
markComplete,
topologicalSort,
validateEdgeContracts,
collectNodeInputs,
resolveFinalStatus,
listDefaultRuntimes,
createRun,
validateJobSpec,
buildTrlCommand,
DEFAULT_HYPERPARAMS,
type TrainingJobSpec,
type NodeRun,
type RunStep,
type GraphNode,
type NodeGraph,
type StepStatus,
type RunStatus,
type NodeEngineRegistry,
type QueueState,
createNodeEngineOverview,
} from "@kxkm/node-engine";
import { createIsoTimestamp } from "@kxkm/core";
import { formatOverviewLine, ansi } from "@kxkm/tui";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import * as path from "node:path";
import { formatOverviewLine } from "@kxkm/tui";
import {
createShutdownController,
createNodeExecutor,
executeRun,
runPollCycle,
waitForNextPollTick,
type WorkerLogger,
} from "./worker-runtime.js";
const execFileAsync = promisify(execFile);
@@ -67,327 +54,29 @@ const DEBUG = process.env.NODE_ENV !== "production" || process.env.DEBUG === "1"
function log(msg: string): void {
if (!DEBUG) return;
const ts = new Date().toISOString();
console.log(`[${ts}] ${msg}`);
process.stdout.write(`[${ts}] ${msg}\n`);
}
function logError(msg: string, err?: unknown): void {
const ts = new Date().toISOString();
const detail = err instanceof Error ? err.message : String(err ?? "");
console.error(`[${ts}] ERROR: ${msg}${detail ? " — " + detail : ""}`);
process.stderr.write(`[${ts}] ERROR: ${msg}${detail ? " — " + detail : ""}\n`);
}
// ---------------------------------------------------------------------------
// Stub executors
// ---------------------------------------------------------------------------
type StubResult = Record<string, unknown>;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function executeNodeStub(
nodeType: string,
inputs: Record<string, unknown>,
params: Record<string, unknown>,
): Promise<StubResult> {
await delay(STEP_DELAY_MS);
switch (nodeType) {
// Dataset sources — return a mock dataset
case "dataset_file":
case "dataset_folder":
case "huggingface_dataset":
case "web_scraper":
return { dataset: { items: [], format: "stub" } };
// Data processing — pass through inputs
case "clean_text":
case "remove_duplicates":
case "split_dataset":
return { dataset: inputs.dataset ?? { items: [], format: "stub" } };
// Dataset builders — pass through as dataset_ready
case "format_instruction_dataset":
case "chat_dataset":
return { dataset_ready: inputs.dataset ?? { items: [], format: "stub" } };
// Evaluation — execute via eval_model.py
case "prompt_test":
case "benchmark": {
const evalModel = typeof params.model === "string" && params.model
? params.model
: (inputs.model as Record<string, unknown>)?.modelName as string || "unsloth/llama-3-8b";
const adapterPath = (inputs.model as Record<string, unknown>)?.adapterPath as string || undefined;
const promptsPath = typeof params.promptsPath === "string" ? params.promptsPath : "";
const evalOutputPath = `/tmp/kxkm-eval-${Date.now()}.json`;
if (DRY_RUN) {
log(` [dry-run] would evaluate model=${evalModel} adapter=${adapterPath || "none"}`);
return { evaluation: { kind: "dry-run", score: 1 } };
}
if (!promptsPath) {
// No prompts file — return stub score
log(` [eval] no promptsPath provided — returning stub evaluation`);
return { evaluation: { kind: "stub", score: 1 } };
}
const scriptPath = path.join(SCRIPTS_DIR, "eval_model.py");
const args = [
scriptPath,
"--model", evalModel,
"--prompts", promptsPath,
"--output", evalOutputPath,
];
if (adapterPath) args.push("--adapter", adapterPath);
log(` [eval] ${PYTHON_BIN} ${args.join(" ")}`);
try {
const { stdout, stderr } = await execFileAsync(PYTHON_BIN, args, {
timeout: TRAINING_TIMEOUT_MS,
maxBuffer: 50 * 1024 * 1024,
});
if (stderr) log(` [eval] stderr: ${stderr.slice(-500)}`);
const jsonLine = stdout.trim().split("\n").pop() || "{}";
let evalResult: Record<string, unknown> = {};
try {
evalResult = JSON.parse(jsonLine);
} catch (parseErr) {
logError(` [eval] Failed to parse JSON output`, parseErr);
}
log(` [eval] result: status=${evalResult.status} score=${evalResult.score}`);
return {
evaluation: {
kind: "real",
score: evalResult.score,
metrics: evalResult.metrics,
outputFile: evalOutputPath,
},
const workerLogger: WorkerLogger = {
log,
error: logError,
};
} catch (err) {
logError(` [eval] failed`, err);
return { evaluation: { kind: "error", score: 0, error: err instanceof Error ? err.message : String(err) } };
}
}
// Training — execute via train_unsloth.py
case "sft_training":
case "lora_training":
case "qlora_training": {
const baseModel = typeof params.baseModel === "string" && params.baseModel
? params.baseModel
: "unsloth/llama-3-8b";
const datasetPath = typeof params.datasetPath === "string" ? params.datasetPath : "";
const outputDir = typeof params.outputDir === "string"
? params.outputDir
: `/tmp/kxkm-training-${Date.now()}`;
const hp = {
...DEFAULT_HYPERPARAMS,
...(params.hyperparams && typeof params.hyperparams === "object" ? params.hyperparams : {}),
};
if (DRY_RUN) {
const jobSpec = validateJobSpec({
type: nodeType as TrainingJobSpec["type"], baseModel,
datasetPath: datasetPath || "/data/dataset.jsonl",
outputDir, hyperparams: hp,
});
log(` [dry-run] would execute: ${buildTrlCommand(jobSpec)}`);
return { model: { kind: "dry-run", modelName: `${baseModel}-finetuned`, jobSpec } };
}
if (!datasetPath) {
return { model: { kind: "error", error: "datasetPath is required for training" } };
}
const scriptPath = path.join(SCRIPTS_DIR, "train_unsloth.py");
const args = [
scriptPath,
"--model", baseModel,
"--data", datasetPath,
"--output", outputDir,
"--method", params.dpo === true ? "dpo" : nodeType === "qlora_training" ? "qlora" : nodeType === "sft_training" ? "sft" : "lora",
"--lr", String(hp.learningRate),
"--epochs", String(hp.epochs),
"--batch-size", String(hp.batchSize),
"--lora-rank", String(hp.loraRank),
"--lora-alpha", String(hp.loraAlpha),
"--warmup-steps", String(hp.warmupSteps),
"--max-seq-length", String(hp.maxSeqLength),
];
if (nodeType === "qlora_training") args.push("--quantize", "4bit");
log(` [training] ${PYTHON_BIN} ${args.join(" ")}`);
try {
const { stdout, stderr } = await execFileAsync(PYTHON_BIN, args, {
timeout: TRAINING_TIMEOUT_MS,
maxBuffer: 50 * 1024 * 1024,
});
if (stderr) log(` [training] stderr: ${stderr.slice(-500)}`);
const jsonLine = stdout.trim().split("\n").pop() || "{}";
let trainResult: Record<string, unknown> = {};
try {
trainResult = JSON.parse(jsonLine);
} catch (parseErr) {
logError(` [training] Failed to parse JSON output`, parseErr);
}
log(` [training] result: status=${trainResult.status} loss=${(trainResult.metrics as Record<string, unknown> | undefined)?.trainLoss}`);
return {
model: {
kind: "trained",
modelName: `${baseModel}-finetuned`,
adapterPath: trainResult.adapterPath || outputDir,
metrics: trainResult.metrics,
status: trainResult.status,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logError(` [training] failed`, err);
return { model: { kind: "error", error: msg } };
}
}
// Registry
case "register_model":
return { registered_model: { id: "stub" } };
// Deployment — import LoRA adapter into Ollama
case "deploy_api": {
const modelInput = inputs.registered_model as Record<string, unknown> || inputs.model as Record<string, unknown> || {};
const adapterPath = modelInput.adapterPath as string || (typeof params.adapterPath === "string" ? params.adapterPath : "");
const baseOllamaModel = typeof params.baseOllamaModel === "string" ? params.baseOllamaModel : "llama3.2:1b";
const deployName = typeof params.deployName === "string" ? params.deployName : `kxkm-${Date.now()}`;
if (DRY_RUN || !adapterPath) {
log(` [deploy] dry-run or no adapter: base=${baseOllamaModel} name=${deployName}`);
return { deployment: { kind: DRY_RUN ? "dry-run" : "stub", id: deployName } };
}
const scriptPath = path.join(SCRIPTS_DIR, "ollama-import-adapter.sh");
const args = [scriptPath, "--base-model", baseOllamaModel, "--adapter-path", adapterPath, "--name", deployName];
log(` [deploy] importing to Ollama: ${deployName} from ${baseOllamaModel} + ${adapterPath}`);
try {
const { stdout, stderr } = await execFileAsync("/bin/bash", args, { timeout: 120_000 });
if (stderr) log(` [deploy] stderr: ${stderr.slice(-500)}`);
const jsonLine = stdout.trim().split("\n").pop() || "{}";
let result;
try {
result = JSON.parse(jsonLine);
} catch {
logError(` [deploy] invalid JSON: ${jsonLine.slice(0, 100)}`);
return { deployment: { kind: "error", id: deployName, error: "invalid_json_output" } };
}
log(` [deploy] result: ${JSON.stringify(result)}`);
return { deployment: { kind: "ollama", id: deployName, ...result } };
} catch (err) {
logError(` [deploy] failed`, err);
return { deployment: { kind: "error", id: deployName, error: err instanceof Error ? err.message : String(err) } };
}
}
default:
return {};
}
}
// ---------------------------------------------------------------------------
// Run executor
// ---------------------------------------------------------------------------
async function executeRun(
run: NodeRun,
registry: NodeEngineRegistry,
options: { shouldCancel?: () => boolean } = {},
): Promise<void> {
const graph = run.graphSnapshot;
const shouldCancel = options.shouldCancel ?? (() => false);
// Validate edges against registry contracts
validateEdgeContracts(graph, registry);
// Get execution order
const sorted = topologicalSort(graph);
// Track outputs per node for input collection
const outputsByNode = new Map<string, Record<string, unknown>>();
// Mark run as running
run.status = "running";
run.startedAt = createIsoTimestamp();
let cancelled = false;
log(` Executing ${sorted.length} node(s) in topological order`);
// Restore already-completed steps (recovery support)
for (const node of sorted) {
const step = run.steps.find((s) => s.id === node.id);
if (step?.status === "completed") {
log(` [${node.id}] ${node.type} — already completed (recovered)`);
}
}
for (const node of sorted) {
const step = run.steps.find((s) => s.id === node.id);
if (!step) continue;
if (step.status === "completed") continue; // skip recovered steps
if (shouldCancel() || shutdownRequested) {
cancelled = true;
log(` [${node.id}] ${node.type} — cancelled`);
break;
}
step.status = "running";
step.startedAt = createIsoTimestamp();
log(` [${node.id}] ${node.type} — running`);
try {
const inputs = collectNodeInputs(graph, node.id, outputsByNode);
const result = await executeNodeStub(node.type, inputs, node.params);
outputsByNode.set(node.id, result);
step.status = "completed";
step.finishedAt = createIsoTimestamp();
step.outputs = Object.keys(result);
log(` [${node.id}] ${node.type} — completed`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
step.status = "failed";
step.finishedAt = createIsoTimestamp();
step.error = message;
logError(` [${node.id}] ${node.type} — failed`, err);
break; // stop on first failure (like V1)
}
}
// Resolve final status from step statuses
const stepStatuses = run.steps.map((s) => s.status);
run.status = resolveFinalStatus(stepStatuses, cancelled);
run.finishedAt = createIsoTimestamp();
}
// ---------------------------------------------------------------------------
// Shutdown handling
// ---------------------------------------------------------------------------
let shutdownRequested = false;
const shutdown = createShutdownController();
function requestShutdown(): void {
if (shutdownRequested) return;
shutdownRequested = true;
if (shutdown.isShutdownRequested()) return;
shutdown.requestShutdown();
log("Shutdown requested — finishing current work...");
}
@@ -437,6 +126,18 @@ async function main(): Promise<void> {
log(`Recovered ${recovered.length} stale run(s): ${recovered.map((r) => r.id).join(", ")}`);
}
const executeNode = createNodeExecutor(
{
dryRun: DRY_RUN,
stepDelayMs: STEP_DELAY_MS,
pythonBin: PYTHON_BIN,
scriptsDir: SCRIPTS_DIR,
trainingTimeoutMs: TRAINING_TIMEOUT_MS,
},
execFileAsync,
workerLogger,
);
// 6. Graceful shutdown
process.on("SIGTERM", requestShutdown);
process.on("SIGINT", requestShutdown);
@@ -444,107 +145,38 @@ async function main(): Promise<void> {
// 7. Poll loop
log(`Entering poll loop (interval=${POLL_INTERVAL_MS}ms)`);
while (!shutdownRequested) {
while (!shutdown.isShutdownRequested()) {
try {
// Fetch queued runs from the DB
const queuedDbRuns = await runRepo.listByStatus("queued", 20);
// Sync DB queued runs into in-memory queue state
for (const dbRun of queuedDbRuns) {
enqueue(queueState, dbRun.id);
}
// Process runs while we have capacity
while (canDequeue(queueState) && !shutdownRequested) {
const runId = dequeue(queueState);
if (!runId) break;
log(`Dequeued run: ${runId}`);
// Update status to running in DB
await runRepo.updateStatus(runId, "running");
// Look up the run from the DB
const dbRun = await runRepo.findById(runId);
if (!dbRun) {
logError(`Run ${runId} not found in DB — skipping`);
markComplete(queueState, runId);
continue;
}
const graphRecord = await graphRepo.findById(dbRun.graphId);
if (!graphRecord) {
logError(`Graph ${dbRun.graphId} for run ${runId} not found — marking failed`);
await runRepo.updateStatus(runId, "failed");
markComplete(queueState, runId);
continue;
}
// Build a NodeGraph from the record (minimal — no edges/nodes stored in DB yet,
// so we create a stub graph from the record for now)
const graph: NodeGraph = {
id: graphRecord.id,
name: graphRecord.name,
description: graphRecord.description,
nodes: [],
edges: [],
createdAt: createIsoTimestamp(),
updatedAt: createIsoTimestamp(),
};
// Create an in-memory run with steps from the graph
const nodeRun = createRun(graph, "worker");
// Override the id to match the DB run
(nodeRun as { id: string }).id = runId;
try {
await executeRun(nodeRun, registry, {
shouldCancel: () => {
// Check if run was cancelled in DB (async check would be better
// but keeping it simple — the cancel is also checked via shutdownRequested)
return shutdownRequested;
},
const cycle = await runPollCycle({
queueState,
runRepo,
graphRepo,
registry,
executeNode,
shutdown,
logger: workerLogger,
});
// Persist final status
await runRepo.updateStatus(runId, nodeRun.status);
log(`Run ${runId} finished with status: ${nodeRun.status}`);
} catch (err) {
logError(`Run ${runId} failed unexpectedly`, err);
await runRepo.updateStatus(runId, "failed");
} finally {
markComplete(queueState, runId);
}
}
// Log overview periodically
const overview = formatOverviewLine({
queue: {
desiredWorkers: MAX_CONCURRENCY,
activeWorkers: queueState.running.length,
queuedRuns: queueState.queued.length,
runningRuns: queueState.running.length,
},
registry: {
const overview = formatOverviewLine(
createNodeEngineOverview({
graphs: (await graphRepo.list()).length,
models: 0,
},
storage: {
backend: "postgres",
artifacts: "filesystem",
},
});
queuedRuns: queueState.queued.length,
runningRuns: queueState.running.length,
desiredWorkers: MAX_CONCURRENCY,
activeWorkers: queueState.running.length,
}),
);
if (queuedDbRuns.length > 0) {
if (cycle.queuedDbRuns > 0) {
log(`Poll status: ${overview}`);
}
} catch (err) {
logError("Poll loop error", err);
}
// Wait before next poll (check shutdown frequently)
if (!shutdownRequested) {
await delay(POLL_INTERVAL_MS);
if (!shutdown.isShutdownRequested()) {
await waitForNextPollTick(POLL_INTERVAL_MS);
}
}
+230
View File
@@ -0,0 +1,230 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createRun,
createNodeEngineRegistry,
createQueueState,
type NodeRunRecord,
type RunStatus,
} from "@kxkm/node-engine";
import {
createNodeExecutor,
createShutdownController,
executeRun,
parseLastJsonLine,
runPollCycle,
} from "./worker-runtime.js";
function createLogger() {
const logs: string[] = [];
const errors: Array<{ msg: string; err?: unknown }> = [];
return {
logs,
errors,
logger: {
log(msg: string) {
logs.push(msg);
},
error(msg: string, err?: unknown) {
errors.push({ msg, err });
},
},
};
}
type BenchmarkResult = {
evaluation?: {
kind?: string;
score?: number;
error?: string;
};
};
type DeployResult = {
deployment?: {
kind?: string;
id?: string;
error?: string;
};
};
test("parseLastJsonLine parses the final JSON line and rejects invalid output", () => {
const parsed = parseLastJsonLine("noise\n{\"status\":\"ok\",\"score\":7}\n");
assert.equal(parsed.ok, true);
if (!parsed.ok) throw new Error("expected parsed json");
assert.equal(parsed.value.status, "ok");
assert.equal(parsed.value.score, 7);
const invalid = parseLastJsonLine("noise\nnot-json\n");
assert.equal(invalid.ok, false);
if (invalid.ok) throw new Error("expected parse failure");
assert.equal(invalid.rawLine, "not-json");
assert.equal(invalid.value && Object.keys(invalid.value).length, 0);
});
test("createShutdownController is idempotent", () => {
const controller = createShutdownController();
assert.equal(controller.isShutdownRequested(), false);
controller.requestShutdown();
controller.requestShutdown();
assert.equal(controller.isShutdownRequested(), true);
});
test("executeRun completes steps in order and forwards upstream outputs", async () => {
const registry = createNodeEngineRegistry();
const graph = {
id: "graph-1",
name: "Graph",
description: "Test graph",
nodes: [
{ id: "n1", type: "dataset_file", runtime: "local_cpu", params: {} },
{ id: "n2", type: "clean_text", runtime: "local_cpu", params: {} },
],
edges: [
{ from: { node: "n1", output: "dataset" }, to: { node: "n2", input: "dataset" } },
],
createdAt: "2026-03-17T00:00:00Z",
updatedAt: "2026-03-17T00:00:00Z",
};
const run = createRun(graph, "worker");
const seenInputs: Record<string, unknown>[] = [];
const { logger } = createLogger();
await executeRun(run, registry, {
executeNode: async (nodeType, inputs) => {
seenInputs.push(inputs);
if (nodeType === "dataset_file") return { dataset: { items: ["hello"], format: "stub" } };
return { dataset: inputs.dataset };
},
logger,
});
assert.equal(run.status, "completed");
assert.equal(run.steps[0]?.status, "completed");
assert.equal(run.steps[1]?.status, "completed");
assert.deepEqual(run.steps[0]?.outputs, ["dataset"]);
assert.deepEqual(run.steps[1]?.outputs, ["dataset"]);
assert.deepEqual(seenInputs[1]?.dataset, { items: ["hello"], format: "stub" });
});
test("executeRun cancels before the second step when cancellation is requested", async () => {
const registry = createNodeEngineRegistry();
const graph = {
id: "graph-2",
name: "Graph",
description: "Test graph",
nodes: [
{ id: "n1", type: "dataset_file", runtime: "local_cpu", params: {} },
{ id: "n2", type: "clean_text", runtime: "local_cpu", params: {} },
],
edges: [
{ from: { node: "n1", output: "dataset" }, to: { node: "n2", input: "dataset" } },
],
createdAt: "2026-03-17T00:00:00Z",
updatedAt: "2026-03-17T00:00:00Z",
};
const run = createRun(graph, "worker");
let executions = 0;
const { logger } = createLogger();
await executeRun(run, registry, {
executeNode: async () => {
executions += 1;
return { dataset: { items: [], format: "stub" } };
},
shouldCancel: () => executions > 0,
logger,
});
assert.equal(executions, 1);
assert.equal(run.status, "cancelled");
assert.equal(run.steps[0]?.status, "completed");
assert.equal(run.steps[1]?.status, "pending");
});
test("runPollCycle dequeues queued runs and persists the final status", async () => {
const queueState = createQueueState({ maxConcurrency: 1 });
const queuedRuns: NodeRunRecord[] = [
{ id: "run-1", graphId: "graph-1", status: "queued", createdAt: "2026-03-17T00:00:00Z" },
];
const statusUpdates: Array<[string, string]> = [];
const { logger } = createLogger();
const runRepo = {
async listByStatus(status: RunStatus, limit: number) {
assert.equal(status, "queued");
assert.equal(limit, 20);
return queuedRuns;
},
async findById(id: string) {
return id === "run-1" ? queuedRuns[0] : null;
},
async updateStatus(id: string, status: RunStatus) {
statusUpdates.push([id, status]);
},
};
const graphRepo = {
async findById(id: string) {
return id === "graph-1"
? { id: "graph-1", name: "Graph", description: "Empty graph" }
: null;
},
async list() {
return [];
},
};
const result = await runPollCycle({
queueState,
runRepo,
graphRepo,
registry: createNodeEngineRegistry(),
executeNode: async () => ({}),
shutdown: createShutdownController(),
logger,
});
assert.equal(result.queuedDbRuns, 1);
assert.deepEqual(result.processedRunIds, ["run-1"]);
assert.deepEqual(statusUpdates, [
["run-1", "running"],
["run-1", "completed"],
]);
assert.deepEqual(queueState.queued, []);
assert.deepEqual(queueState.running, []);
});
test("createNodeExecutor tolerates invalid JSON from subprocesses", async () => {
const { logger, errors } = createLogger();
const executor = createNodeExecutor(
{
dryRun: false,
stepDelayMs: 0,
pythonBin: "python3",
scriptsDir: "/tmp/scripts",
trainingTimeoutMs: 1000,
},
async () => ({ stdout: "garbage\n", stderr: "" }),
logger,
);
const benchmark = (await executor(
"benchmark",
{ model: { modelName: "demo-model", adapterPath: "/tmp/adapter" } },
{ promptsPath: "/tmp/prompts.json" },
)) as BenchmarkResult;
assert.equal(benchmark.evaluation?.kind, "real");
assert.equal(benchmark.evaluation?.score, undefined);
assert.ok(errors.some((entry) => entry.msg.includes("Failed to parse JSON output")));
const deploy = (await executor(
"deploy_api",
{ registered_model: { adapterPath: "/tmp/adapter" } },
{ deployName: "demo-deploy" },
)) as DeployResult;
assert.equal(deploy.deployment?.kind, "error");
assert.equal(deploy.deployment?.error, "invalid_json_output");
});
+564
View File
@@ -0,0 +1,564 @@
import type { ExecFileOptions } from "node:child_process";
import * as path from "node:path";
import { createIsoTimestamp } from "@kxkm/core";
import {
DEFAULT_HYPERPARAMS,
buildTrlCommand,
createRun,
canDequeue,
collectNodeInputs,
dequeue,
enqueue,
markComplete,
resolveFinalStatus,
topologicalSort,
validateEdgeContracts,
validateJobSpec,
type NodeEngineRegistry,
type NodeGraph,
type NodeRun,
type NodeRunRecord,
type QueueState,
type RunStatus,
type TrainingJobSpec,
} from "@kxkm/node-engine";
export interface WorkerLogger {
log(msg: string): void;
error(msg: string, err?: unknown): void;
}
export interface WorkerConfig {
dryRun: boolean;
stepDelayMs: number;
pythonBin: string;
scriptsDir: string;
trainingTimeoutMs: number;
}
export interface SubprocessRunner {
(file: string, args: string[], options: ExecFileOptions): Promise<{ stdout: string; stderr: string }>;
}
export interface ShutdownController {
requestShutdown(): void;
isShutdownRequested(): boolean;
}
export interface GraphRecordLike {
id: string;
name: string;
description: string;
}
export interface RunRepoLike {
listByStatus(status: RunStatus, limit: number): Promise<NodeRunRecord[]>;
findById(id: string): Promise<NodeRunRecord | null>;
updateStatus(id: string, status: RunStatus): Promise<void>;
}
export interface GraphRepoLike {
findById(id: string): Promise<GraphRecordLike | null>;
list(): Promise<GraphRecordLike[]>;
}
export interface ExecuteNodeInputs {
[key: string]: unknown;
}
export interface ExecuteNodeParams {
[key: string]: unknown;
}
export type ExecuteNodeFn = (
nodeType: string,
inputs: ExecuteNodeInputs,
params: ExecuteNodeParams,
) => Promise<Record<string, unknown>>;
export interface RunPollCycleResult {
queuedDbRuns: number;
processedRunIds: string[];
}
export interface ProcessDequeuedRunDeps {
runId: string;
runRepo: RunRepoLike;
graphRepo: GraphRepoLike;
registry: NodeEngineRegistry;
executeNode: ExecuteNodeFn;
logger: WorkerLogger;
shouldCancel: () => boolean;
}
export interface RunExecutionOptions {
shouldCancel?: () => boolean;
executeNode: ExecuteNodeFn;
logger: WorkerLogger;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function createShutdownController(): ShutdownController {
let shutdownRequested = false;
return {
requestShutdown(): void {
shutdownRequested = true;
},
isShutdownRequested(): boolean {
return shutdownRequested;
},
};
}
export function syncQueuedRuns(queueState: QueueState, queuedDbRuns: NodeRunRecord[]): number {
let added = 0;
for (const dbRun of queuedDbRuns) {
const before = queueState.queued.length;
enqueue(queueState, dbRun.id);
if (queueState.queued.length > before) added++;
}
return added;
}
export function buildWorkerGraph(graphRecord: GraphRecordLike): NodeGraph {
const now = createIsoTimestamp();
return {
id: graphRecord.id,
name: graphRecord.name,
description: graphRecord.description,
nodes: [],
edges: [],
createdAt: now,
updatedAt: now,
};
}
export function createWorkerRun(graph: NodeGraph, runId: string, actor = "worker"): NodeRun {
const run = createRun(graph, actor);
run.id = runId;
return run;
}
export type JsonParseResult =
| { ok: true; rawLine: string; value: Record<string, unknown> }
| { ok: false; rawLine: string; value: Record<string, never>; error: Error };
export function parseLastJsonLine(stdout: string): JsonParseResult {
const rawLine = stdout.trim().split("\n").pop() || "{}";
try {
return { ok: true, rawLine, value: JSON.parse(rawLine) as Record<string, unknown> };
} catch (err) {
return {
ok: false,
rawLine,
value: {},
error: err instanceof Error ? err : new Error(String(err)),
};
}
}
export function createNodeExecutor(
config: WorkerConfig,
runner: SubprocessRunner,
logger: WorkerLogger,
): ExecuteNodeFn {
return async function executeNodeStub(
nodeType: string,
inputs: ExecuteNodeInputs,
params: ExecuteNodeParams,
): Promise<Record<string, unknown>> {
await delay(config.stepDelayMs);
switch (nodeType) {
case "dataset_file":
case "dataset_folder":
case "huggingface_dataset":
case "web_scraper":
return { dataset: { items: [], format: "stub" } };
case "clean_text":
case "remove_duplicates":
case "split_dataset":
return { dataset: inputs.dataset ?? { items: [], format: "stub" } };
case "format_instruction_dataset":
case "chat_dataset":
return { dataset_ready: inputs.dataset ?? { items: [], format: "stub" } };
case "prompt_test":
case "benchmark": {
const evalModel = typeof params.model === "string" && params.model
? params.model
: ((inputs.model as Record<string, unknown> | undefined)?.modelName as string | undefined) || "unsloth/llama-3-8b";
const adapterPath = (inputs.model as Record<string, unknown> | undefined)?.adapterPath as string | undefined;
const promptsPath = typeof params.promptsPath === "string" ? params.promptsPath : "";
const evalOutputPath = `/tmp/kxkm-eval-${Date.now()}.json`;
if (config.dryRun) {
logger.log(` [dry-run] would evaluate model=${evalModel} adapter=${adapterPath || "none"}`);
return { evaluation: { kind: "dry-run", score: 1 } };
}
if (!promptsPath) {
logger.log(" [eval] no promptsPath provided — returning stub evaluation");
return { evaluation: { kind: "stub", score: 1 } };
}
const scriptPath = path.join(config.scriptsDir, "eval_model.py");
const args = [
scriptPath,
"--model",
evalModel,
"--prompts",
promptsPath,
"--output",
evalOutputPath,
];
if (adapterPath) args.push("--adapter", adapterPath);
logger.log(` [eval] ${config.pythonBin} ${args.join(" ")}`);
try {
const { stdout, stderr } = await runner(config.pythonBin, args, {
timeout: config.trainingTimeoutMs,
maxBuffer: 50 * 1024 * 1024,
});
if (stderr) logger.log(` [eval] stderr: ${stderr.slice(-500)}`);
const parsed = parseLastJsonLine(stdout);
if (!parsed.ok) logger.error(" [eval] Failed to parse JSON output", parsed.error);
const evalResult = parsed.value;
logger.log(` [eval] result: status=${evalResult.status} score=${evalResult.score}`);
return {
evaluation: {
kind: "real",
score: evalResult.score,
metrics: evalResult.metrics,
outputFile: evalOutputPath,
},
};
} catch (err) {
logger.error(" [eval] failed", err);
return {
evaluation: {
kind: "error",
score: 0,
error: err instanceof Error ? err.message : String(err),
},
};
}
}
case "sft_training":
case "lora_training":
case "qlora_training": {
const baseModel = typeof params.baseModel === "string" && params.baseModel
? params.baseModel
: "unsloth/llama-3-8b";
const datasetPath = typeof params.datasetPath === "string" ? params.datasetPath : "";
const outputDir = typeof params.outputDir === "string"
? params.outputDir
: `/tmp/kxkm-training-${Date.now()}`;
const hp = {
...DEFAULT_HYPERPARAMS,
...(params.hyperparams && typeof params.hyperparams === "object" ? params.hyperparams : {}),
};
if (config.dryRun) {
const jobSpec = validateJobSpec({
type: nodeType as TrainingJobSpec["type"],
baseModel,
datasetPath: datasetPath || "/data/dataset.jsonl",
outputDir,
hyperparams: hp,
});
logger.log(` [dry-run] would execute: ${buildTrlCommand(jobSpec)}`);
return {
model: {
kind: "dry-run",
modelName: `${baseModel}-finetuned`,
jobSpec,
},
};
}
if (!datasetPath) {
return { model: { kind: "error", error: "datasetPath is required for training" } };
}
const scriptPath = path.join(config.scriptsDir, "train_unsloth.py");
const method = params.dpo === true ? "dpo" : nodeType === "qlora_training" ? "qlora" : nodeType === "sft_training" ? "sft" : "lora";
const args = [
scriptPath,
"--model",
baseModel,
"--data",
datasetPath,
"--output",
outputDir,
"--method",
method,
"--lr",
String((hp.learningRate as number) ?? ""),
"--epochs",
String((hp.epochs as number) ?? ""),
"--batch-size",
String((hp.batchSize as number) ?? ""),
"--lora-rank",
String((hp.loraRank as number) ?? ""),
"--lora-alpha",
String((hp.loraAlpha as number) ?? ""),
"--warmup-steps",
String((hp.warmupSteps as number) ?? ""),
"--max-seq-length",
String((hp.maxSeqLength as number) ?? ""),
];
if (nodeType === "qlora_training") args.push("--quantize", "4bit");
logger.log(` [training] ${config.pythonBin} ${args.join(" ")}`);
try {
const { stdout, stderr } = await runner(config.pythonBin, args, {
timeout: config.trainingTimeoutMs,
maxBuffer: 50 * 1024 * 1024,
});
if (stderr) logger.log(` [training] stderr: ${stderr.slice(-500)}`);
const parsed = parseLastJsonLine(stdout);
if (!parsed.ok) logger.error(" [training] Failed to parse JSON output", parsed.error);
const trainResult = parsed.value;
logger.log(
` [training] result: status=${trainResult.status} loss=${(trainResult.metrics as Record<string, unknown> | undefined)?.trainLoss}`,
);
return {
model: {
kind: "trained",
modelName: `${baseModel}-finetuned`,
adapterPath: (trainResult.adapterPath as string | undefined) || outputDir,
metrics: trainResult.metrics,
status: trainResult.status,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.error(" [training] failed", err);
return { model: { kind: "error", error: msg } };
}
}
case "register_model":
return { registered_model: { id: "stub" } };
case "deploy_api": {
const modelInput = (inputs.registered_model as Record<string, unknown> | undefined) || (inputs.model as Record<string, unknown> | undefined) || {};
const adapterPath = (modelInput.adapterPath as string | undefined) || (typeof params.adapterPath === "string" ? params.adapterPath : "");
const baseOllamaModel = typeof params.baseOllamaModel === "string" ? params.baseOllamaModel : "llama3.2:1b";
const deployName = typeof params.deployName === "string" ? params.deployName : `kxkm-${Date.now()}`;
if (config.dryRun || !adapterPath) {
logger.log(` [deploy] dry-run or no adapter: base=${baseOllamaModel} name=${deployName}`);
return { deployment: { kind: config.dryRun ? "dry-run" : "stub", id: deployName } };
}
const scriptPath = path.join(config.scriptsDir, "ollama-import-adapter.sh");
const args = [
scriptPath,
"--base-model",
baseOllamaModel,
"--adapter-path",
adapterPath,
"--name",
deployName,
];
logger.log(` [deploy] importing to Ollama: ${deployName} from ${baseOllamaModel} + ${adapterPath}`);
try {
const { stdout, stderr } = await runner("/bin/bash", args, { timeout: 120_000 });
if (stderr) logger.log(` [deploy] stderr: ${stderr.slice(-500)}`);
const parsed = parseLastJsonLine(stdout);
if (!parsed.ok) {
logger.error(" [deploy] invalid JSON", parsed.error);
return { deployment: { kind: "error", id: deployName, error: "invalid_json_output" } };
}
const result = parsed.value;
logger.log(` [deploy] result: ${JSON.stringify(result)}`);
return { deployment: { kind: "ollama", id: deployName, ...result } };
} catch (err) {
logger.error(" [deploy] failed", err);
return {
deployment: {
kind: "error",
id: deployName,
error: err instanceof Error ? err.message : String(err),
},
};
}
}
default:
return {};
}
};
}
export interface ExecuteRunDeps {
executeNode: ExecuteNodeFn;
logger: WorkerLogger;
shouldCancel?: () => boolean;
}
export async function executeRun(
run: NodeRun,
registry: NodeEngineRegistry,
deps: ExecuteRunDeps,
): Promise<void> {
const shouldCancel = deps.shouldCancel ?? (() => false);
validateEdgeContracts(run.graphSnapshot, registry);
const sorted = topologicalSort(run.graphSnapshot);
const outputsByNode = new Map<string, Record<string, unknown>>();
run.status = "running";
run.startedAt = createIsoTimestamp();
let cancelled = false;
deps.logger.log(` Executing ${sorted.length} node(s) in topological order`);
for (const node of sorted) {
const step = run.steps.find((entry) => entry.id === node.id);
if (step?.status === "completed") {
deps.logger.log(` [${node.id}] ${node.type} — already completed (recovered)`);
}
}
for (const node of sorted) {
const step = run.steps.find((entry) => entry.id === node.id);
if (!step) continue;
if (step.status === "completed") continue;
if (shouldCancel()) {
cancelled = true;
deps.logger.log(` [${node.id}] ${node.type} — cancelled`);
break;
}
step.status = "running";
step.startedAt = createIsoTimestamp();
deps.logger.log(` [${node.id}] ${node.type} — running`);
try {
const inputs = collectNodeInputs(run.graphSnapshot, node.id, outputsByNode);
const result = await deps.executeNode(node.type, inputs, node.params);
outputsByNode.set(node.id, result);
step.status = "completed";
step.finishedAt = createIsoTimestamp();
step.outputs = Object.keys(result);
deps.logger.log(` [${node.id}] ${node.type} — completed`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
step.status = "failed";
step.finishedAt = createIsoTimestamp();
step.error = message;
deps.logger.error(` [${node.id}] ${node.type} — failed`, err);
break;
}
}
const stepStatuses = run.steps.map((entry) => entry.status);
run.status = resolveFinalStatus(stepStatuses, cancelled);
run.finishedAt = createIsoTimestamp();
}
export async function persistRunStatus(
runRepo: RunRepoLike,
runId: string,
status: RunStatus,
): Promise<void> {
await runRepo.updateStatus(runId, status);
}
export async function processDequeuedRun(
deps: ProcessDequeuedRunDeps,
): Promise<{ kind: "completed" | "missing-run" | "missing-graph" | "failed"; status?: RunStatus }> {
await deps.runRepo.updateStatus(deps.runId, "running");
const dbRun = await deps.runRepo.findById(deps.runId);
if (!dbRun) {
deps.logger.error(`Run ${deps.runId} not found in DB — skipping`);
return { kind: "missing-run" };
}
const graphRecord = await deps.graphRepo.findById(dbRun.graphId);
if (!graphRecord) {
deps.logger.error(`Graph ${dbRun.graphId} for run ${deps.runId} not found — marking failed`);
await persistRunStatus(deps.runRepo, deps.runId, "failed");
return { kind: "missing-graph", status: "failed" };
}
const graph = buildWorkerGraph(graphRecord);
const nodeRun = createWorkerRun(graph, deps.runId, "worker");
try {
await executeRun(nodeRun, deps.registry, {
executeNode: deps.executeNode,
logger: deps.logger,
shouldCancel: deps.shouldCancel,
});
await persistRunStatus(deps.runRepo, deps.runId, nodeRun.status);
return { kind: "completed", status: nodeRun.status };
} catch (err) {
deps.logger.error(`Run ${deps.runId} failed unexpectedly`, err);
await persistRunStatus(deps.runRepo, deps.runId, "failed");
return { kind: "failed", status: "failed" };
}
}
export async function runPollCycle(
params: {
queueState: QueueState;
runRepo: RunRepoLike;
graphRepo: GraphRepoLike;
registry: NodeEngineRegistry;
executeNode: ExecuteNodeFn;
shutdown: ShutdownController;
logger: WorkerLogger;
},
): Promise<RunPollCycleResult> {
const queuedDbRuns = await params.runRepo.listByStatus("queued", 20);
syncQueuedRuns(params.queueState, queuedDbRuns);
const processedRunIds: string[] = [];
while (canDequeue(params.queueState) && !params.shutdown.isShutdownRequested()) {
const runId = dequeue(params.queueState);
if (!runId) break;
processedRunIds.push(runId);
params.logger.log(`Dequeued run: ${runId}`);
try {
await processDequeuedRun({
runId,
runRepo: params.runRepo,
graphRepo: params.graphRepo,
registry: params.registry,
executeNode: params.executeNode,
logger: params.logger,
shouldCancel: () => params.shutdown.isShutdownRequested(),
});
} finally {
markComplete(params.queueState, runId);
}
}
return { queuedDbRuns: queuedDbRuns.length, processedRunIds };
}
export async function waitForNextPollTick(ms: number): Promise<void> {
await delay(ms);
}
+92 -2
View File
@@ -45,7 +45,7 @@ services:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3333/api/ping"]
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3333/api/ping').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))\""]
interval: 15s
timeout: 5s
retries: 3
@@ -78,11 +78,13 @@ services:
PIPER_VOICE_DIR: "/app/data/piper-voices"
COQUI_TOS_AGREED: "1"
VISION_MODEL: "qwen3-vl:8b"
LIGHTRAG_URL: "http://localhost:9621"
TTS_URL: "http://localhost:9100"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3333/api/v2/health"]
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3333/api/v2/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))\""]
interval: 15s
timeout: 5s
retries: 3
@@ -220,6 +222,94 @@ services:
api:
condition: service_healthy
# -------------------------------------------------------------------------
# Chatterbox TTS — GPU-accelerated voice synthesis (Docker)
# Usage: docker compose --profile v2 up -d
# API: POST /tts, POST /v1/audio/speech
# -------------------------------------------------------------------------
chatterbox:
image: ghcr.io/devnen/chatterbox-tts-server:latest
restart: unless-stopped
profiles: [v2]
ports:
- "${CHATTERBOX_PORT:-9200}:8004"
volumes:
- ./data/voice-samples:/app/voices:ro
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
healthcheck:
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8004/get_predefined_voices')\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
# -------------------------------------------------------------------------
# TTS Sidecar — Proxy to Chatterbox + Piper fallback
# Runs on host network, proxies /synthesize to Chatterbox :9200
# -------------------------------------------------------------------------
tts-sidecar:
build: .
restart: unless-stopped
profiles: [v2]
network_mode: host
command: ["python3", "scripts/tts-server.py", "--port", "9100", "--backend", "chatterbox-remote"]
environment:
CHATTERBOX_URL: "http://127.0.0.1:9200"
PIPER_VOICE_DIR: "/app/data/piper-voices"
KXKM_VOICE_SAMPLES_DIR: "/app/data/voice-samples"
volumes:
- ./data:/app/data
- ./scripts:/app/scripts:ro
depends_on:
chatterbox:
condition: service_healthy
# -------------------------------------------------------------------------
# LightRAG — Graph RAG server (Ollama backend)
# API: POST /query, POST /documents/text, GET /health
# Web UI: http://localhost:9621
# -------------------------------------------------------------------------
lightrag:
build:
context: .
dockerfile_inline: |
FROM python:3.12-slim
RUN pip install --no-cache-dir 'lightrag-hku[api]'
EXPOSE 9621
CMD ["lightrag-server", "--host", "0.0.0.0", "--port", "9621"]
restart: unless-stopped
profiles: [v2]
network_mode: host
environment:
LLM_MODEL: "qwen3:8b"
EMBEDDING_MODEL: "nomic-embed-text"
EMBEDDING_DIM: "768"
OLLAMA_HOST: "http://localhost:11434"
LLM_BINDING: ollama
EMBEDDING_BINDING: ollama
RAG_DIR: "/data/lightrag"
command: >
lightrag-server
--host 0.0.0.0
--port 9621
--working-dir /data/lightrag
--llm-binding ollama
--embedding-binding ollama
volumes:
- ./data/lightrag:/data/lightrag
healthcheck:
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:9621/health')\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
app-data:
pg-data:
+41 -22
View File
@@ -67,57 +67,72 @@ flowchart TD
OSSAgent --> |hf| OSSHF[HuggingFace models]
```
## Todo agents (lot 17)
## Todo agents (lot 17+ — mis a jour 2026-03-19)
### Coordinateur
- [ ] Consolider PLAN.md avec etat reel (lots 14-16 complets)
- [ ] Mettre a jour TODO.md avec backlog Phase 6+
- [ ] Synchroniser FEATURE_MAP.md matrice
- [x] Consolider PLAN.md avec etat reel (lots 14-16 complets)
- [x] Synchroniser FEATURE_MAP.md matrice
- [ ] Mettre a jour TODO.md avec backlog Phase 7+
- [ ] Documenter actions dans ops/v2/logs/
### Backend API
- [ ] Refactorer ws-chat.ts (1449 → <400 LOC par module)
- [ ] Refactorer app.ts (1292 → routes + middleware + handlers)
- [x] Extraire app-bootstrap.ts et app-middleware.ts de app.ts
- [x] Extraire ws-conversation-router.ts de ws-chat.ts
- [ ] Refactorer ws-chat.ts residuel (<400 LOC cible)
- [ ] Remplacer writeFileSync par async dans ws-chat.ts
- [ ] Ajouter error boundaries sur WebSocket handlers
- [ ] Scinder tests storage volumineux (storage-test-split)
- [ ] Decouper Chat.tsx en seams transport/liste/composer/media
### Node Engine
- [x] Extraire registry.ts du hotspot node-engine
- [ ] Ajouter node type `music_generation` (ACE-Step 1.5)
- [ ] Ajouter node type `voice_clone` (XTTS-v2)
- [ ] Ajouter node type `voice_clone` (Chatterbox)
- [ ] Tester pipeline DPO end-to-end sur kxkm-ai
### Personas
- [ ] Evaluer PCL (Persona-Aware Contrastive Learning) pour coherence
- [ ] Evaluer OpenCharacter pour generation profils synthetiques
- [ ] Ajouter `/compose` command (generation musicale)
- [x] Ajouter `/compose` command (generation musicale)
### Frontend
- [ ] Implementer lot 16 UI Minitel rose (phosphore, VIDEOTEX)
- [x] Implementer lot 16 UI Minitel rose (phosphore, VIDEOTEX)
- [x] VoiceChat push-to-talk + level meter + silence auto
- [x] Player audio + viewer image plein ecran
- [x] Mediatheque gallery/playlist
- [x] Progress bars animees Compose/Imagine
- [ ] Ajouter memoization (React.memo) sur composants lourds
- [ ] Lazy-load ChatHistory, VoiceChat, NodeEditor
- [ ] vault66-crt-effect (React CRT WebGL)
### Ops/TUI
- [ ] Deployer deep-audit.js sur kxkm-ai
- [ ] Ajouter SearXNG au docker-compose
- [x] Deployer deep-audit.js sur kxkm-ai
- [x] Ajouter SearXNG au docker-compose
- [x] TTS sidecar HTTP (tts-server.py :9100, dual Chatterbox/Piper)
- [x] deploy.sh tmux (build + deploy automatise)
- [ ] Ajouter MinerU/Docling au docker-compose
- [ ] Fix Docker transformers (rebuild propre avec torch)
### Training
- [ ] Spike BGE-M3 embeddings (remplacer nomic-embed-text)
- [x] Spike BGE-M3 (resultat negatif sur Apple/Metal, baseline maintenue)
- [x] TTS dual backend Chatterbox/Piper valide
- [ ] Tester ACE-Step 1.5 sur RTX 4090
- [ ] Evaluer Chatterbox TTS vs Piper
- [ ] LightRAG graph RAG (personas/lore)
### Veille OSS
- [x] Veille mars 2026 complete (40+ projets analyses, top 10 recommandations)
- [ ] Suivre LLMRTC (WebRTC voice TypeScript)
- [ ] Suivre A2A Protocol (interop agents)
- [ ] Suivre MCP SDK updates
- [ ] Evaluer Kokoro TTS (82M params, ultra-leger)
## Pipeline d'intervention
@@ -165,13 +180,17 @@ stateDiagram-v2
- Privilegier TUI et scripts avec logs lisibles, puis purge des logs obsoletes.
- Conserver la V1 comme reference comportementale, V2 comme cible active.
### Etat de cycle (2026-03-17 10:30)
- Audit/tests: vert (`deep-audit`, `check:v2`, `test:v2`).
- Correctifs deployables: garde TTS + cleanup sessions memory-mode.
- Logs: purge des fichiers vides et conservation des artefacts utiles.
- Veille OSS: shortlist d'integration prioritaire = `SearXNG` + `Docling`.
### Etat de cycle (2026-03-19)
### Micro-mission suivante
1. Backend API: extraction du bloc `upload/analyse` de `ws-chat.ts` vers module dedie.
2. Ops/TUI: ajouter score de dette technique pondere dans `deep-audit` JSON.
3. Coordinateur: synchroniser PLAN/TODO apres chaque sous-lot (max 1h).
- Audit/tests: en cours de verification.
- Lots 0-4, 12-14, 16 complets. Lot 15 en cours (2/4 done).
- TTS dual backend (Chatterbox/Piper) deploye avec deploy.sh tmux.
- 33 personas (qwen3:8b x21, mistral:7b x7, gemma3:4b x4).
- Veille OSS mars 2026: 40+ projets analyses.
- Deep audit: 7 bugs HIGH/MEDIUM identifies, 6 corriges.
### Micro-missions lot 15 restantes
1. Backend API: scinder tests storage volumineux (storage-test-split).
2. Frontend: decouper Chat.tsx en seams transport/liste/composer/media.
3. Coordinateur: synchroniser PLAN/TODO/state apres cloture lot 15.
+21 -5
View File
@@ -24,13 +24,15 @@ graph TB
MULTI[ws-multimodal.ts — TTS HTTP + Vision]
MSTORE[media-store.ts — Persistance media]
CTX[context-store.ts — Contexte JSONL 4000ch]
RAG[rag.ts — Embeddings cosine]
RAG[rag.ts — LightRAG + local fallback]
REST[Routes REST — session, personas, media]
end
subgraph Services["Services"]
OLLAMA[Ollama — qwen3:8b mistral gemma3]
TTS[TTS Server piper :9100]
TTS[TTS Sidecar :9100 — proxy]
CBOX[Chatterbox GPU :9200 — Docker]
LRAG[LightRAG :9621 — Graph RAG]
COMFY[ComfyUI SDXL]
SEARX[SearXNG :8080]
PG[(PostgreSQL 16)]
@@ -50,8 +52,9 @@ graph TB
WS --> ROUTER --> LLM --> OLLAMA
ROUTER --> CTX
ROUTER --> RAG
LLM -- "TTS" --> MULTI --> TTS
LLM -- "TTS" --> MULTI --> TTS --> CBOX
LLM -- "Vision" --> MULTI --> OLLAMA
RAG -- "query hybrid" --> LRAG --> OLLAMA
CMD -- "/imagine" --> COMFY
CMD -- "/web" --> SEARX
CMD -- "save" --> MSTORE
@@ -148,10 +151,23 @@ mindmap
| packages/persona-domain | 988 | 259 | Personas, feedback, editorial |
| packages/node-engine | 1499 | 605 | DAG execution, training |
| packages/storage | 1219 | 669 | PostgreSQL repos |
| packages/ui | 134 | 0 | Theme, colors, CSS vars |
| packages/ui | 134 | 29 | Theme, colors, CSS vars |
| packages/tui | 209 | 108 | ANSI formatting, tables |
| scripts | 37 fichiers | - | TTS, training, migration |
| **Total** | **~15600** | **~3200** | |
| **Total** | **~15600** | **417 tests** | |
## Services production (kxkm-ai)
| Service | Port | Stack | Rôle |
|---------|------|-------|------|
| API V2 | :3333 | Docker (Node.js) | Express + WebSocket chat |
| PostgreSQL | :5432 | Docker (Alpine) | Persistence |
| SearXNG | :8080 | Docker | Recherche web self-hosted |
| Chatterbox | :9200 | Docker (GPU) | TTS voice cloning |
| TTS Sidecar | :9100 | Docker (host) | Proxy Chatterbox + Piper fallback |
| LightRAG | :9621 | Docker (Python) | Graph RAG, knowledge graph |
| Ollama | :11434 | Natif | LLM inference |
| Worker | host | Docker | Node Engine DAG execution |
## Bugs critiques identifiés (audit 2026-03-18)
+117
View File
@@ -0,0 +1,117 @@
# Deep Audit 2026-03-19
> Analyse exhaustive par 3 agents paralleles (backend, frontend, packages) + veille OSS.
## Resultats validation
- **check:v2:** OK (TypeScript vert)
- **test:v2:** 204 tests, 0 fail, 320ms
## Metriques globales
| Couche | LOC | Tests LOC | Couverture estimee |
| --- | --- | --- | --- |
| apps/api | 5 254 | 1 004 | ~45% |
| apps/web | 6 454 | 374 | ~10% |
| apps/worker | 986 | 230 | ~20% |
| packages | 2 853 | 2 162 | ~72% |
| **Total** | **~15 547** | **~3 770** | **~40%** |
## Hotspots identifies
### Backend (P1)
| Fichier | LOC | Complexite | Probleme |
| --- | --- | --- | --- |
| ws-conversation-router.ts | 358 | Tres haute | Race condition persona locks, memory leak maps unbounded |
| ws-ollama.ts | 303 | Tres haute | Semaphore manuel, queue starvation, JSON streaming fragile |
| context-store.ts | 435 | Haute | RAM unbounded en compaction, deadlock potentiel |
| worker-runtime.ts | 564 | Tres haute | Subprocess timeout cascade, signal handling absent |
| app.ts | 538 | Moyenne | 7 repo factories inline, couplage fort |
### Frontend (P1)
| Composant | LOC | Probleme |
| --- | --- | --- |
| Chat.tsx | 631 | Monolithique, 10+ useState, getNickColor re-render |
| ChatHistory.tsx | 601 | Pagination complexe, pas de virtualisation |
| NodeEditor.tsx | 406 | Logique metier melee au rendu |
| VoiceChat.tsx | 388 | Memory leak recordingTimer, silenceTimer |
| ComposePage/ImaginePage | 203/185 | WS pattern duplique, progress bar simulee |
### Packages (P2)
| Package | Probleme |
| --- | --- |
| @kxkm/core | createId race condition en worker threads |
| @kxkm/node-engine | Hyperparams sans validation bounds, docker image hardcoded |
| @kxkm/persona-domain | Extra keys non types, DPO pairs silent [] |
| @kxkm/storage | Types unsafe sur row mapping (pas de zod) |
| @kxkm/ui | Zero tests |
## Vulnerabilites securite
| # | Severite | Fichier | Description |
| --- | --- | --- | --- |
| SEC-01 | CRITICAL | app-middleware.ts | Cookie session sans Secure flag (HTTPS) |
| SEC-02 | HIGH | routes/session.ts | Admin token plaintext en env vars |
| SEC-03 | MEDIUM | ws-upload-handler.ts | MIME type validation contournable (header, pas magic bytes) |
| SEC-04 | MEDIUM | /api/session/login | Pas de rate limiting (brute force) |
| SEC-05 | LOW | ws-ollama.ts | Tool execution sans whitelist / sandbox inputs |
## Refactoring prioritaire
### Tier 1 — Immediat
1. **Secure flag cookie** (5 min) — app-middleware.ts
2. **Rate limit login** (1h) — routes/session.ts
3. **MIME magic bytes validation** (30 min) — ws-upload-handler.ts, lib `file-type`
4. **VoiceChat timer cleanup** (30 min) — recordingTimerRef + silenceTimerRef
5. **Chat.tsx getNickColor useCallback** (15 min) — reduire re-renders 50%
### Tier 2 — Sprint suivant
1. **Decouper Chat.tsx** en ChatMessages + ChatInput + ChatSidebar (2-3j)
2. **Remplacer semaphore manuel par p-limit** dans ws-ollama.ts (1h)
3. **Input validation schemas (zod)** sur endpoints API (4h)
4. **Storage row mapping runtime validation** (4h)
5. **Tests Chat.tsx + VoiceChat.tsx** (2j)
### Tier 3 — Moyen terme
1. **Virtualisation chat messages** (react-window)
2. **Worker strategy pattern** pour node executors
3. **Error telemetry** (Sentry ou equivalent)
4. **A11y pass WCAG 2.1 AA** (labels, focus, contrast)
5. **Integration test suite end-to-end**
## Veille OSS — Nouvelles decouvertes mars 2026
| Projet | Pertinence | Notes |
| --- | --- | --- |
| **Qwen3-TTS** (Alibaba) | P2 | 0.6B-1.7B, clone voix 3s, 10 langues, Apache 2.0 |
| **Pocket TTS** (Kyutai) | P3 | 100M params, CPU temps reel, voice cloning |
| **NexusRAG** | P3 | Hybrid: vector + LightRAG + Docling + Ollama |
| **AgoraAI** | P2 | Multi-persona voice-to-voice, dual-queue async |
| **webgl-crt-shader** | P3 | GPU-accelere, plus authentique que vault66 CSS |
| **LightRAG v1.4.11rc2** | P1 | Releases actives, Ollama natif, workspace isolation |
## Backlog mis a jour
### Lot 15 (en cours) — 2/4 done
- [x] persona-domain-seams
- [x] node-engine-seams (registry.ts extrait)
- [ ] storage-test-split
- [ ] web-chat-modularization (Chat.tsx → seams)
### Backlog Phase 7+
1. Chatterbox TTS deploiement production (P1)
2. LightRAG v1.4.11 integration (P1)
3. Secure flag + rate limit login (P1 securite)
4. Chat.tsx modularisation (lot 15)
5. Tests couverture >60% (Chat, VoiceChat, ws-ollama)
6. vault66 ou webgl-crt-shader (P3)
7. AgoraAI etude architecture (P3)
8. Qwen3-TTS evaluation (P2)
+35
View File
@@ -0,0 +1,35 @@
# BGE-M3 Benchmark - 2026-03-17
## Etat local confirme
- Le RAG courant utilise encore des embeddings locaux via Ollama dans `apps/api/src/rag.ts`, avec `nomic-embed-text` par defaut.
- Le repo contient deja un bench local `scripts/bench-embeddings.js`.
- Le health check `scripts/health-embeddings.sh` sonde Ollama et detecte la presence ou non de `bge-m3`.
## Etat machine observe
- `npm run -s smoke:embeddings` passe.
- `ollama pull bge-m3` a ete execute avec succes; `bge-m3:latest` est maintenant present localement.
- `bash scripts/health-embeddings.sh --strict` passe et confirme la presence de `bge-m3`.
- Le benchmark local resout maintenant correctement les noms de modeles tagges `:latest` et remonte les erreurs Ollama par modele.
- Sur cette machine Apple/Metal, `bge-m3:latest` echoue au chargement avec une erreur `ggml_metal_init` / `MTLLibraryErrorDomain`.
- Sur cette meme machine, `nomic-embed-text:latest` et `qwen3-embedding:0.6b` retournent aussi des `500` Ollama de chargement de modele, donc le benchmark numerique ne peut pas etre compare localement ici.
## Commandes utiles
- `npm run -s smoke:embeddings`
- `bash scripts/health-embeddings.sh --strict`
- `node scripts/bench-embeddings.js --models bge-m3 --json-only`
- `node scripts/bench-embeddings.js --models qwen3-embedding:0.6b,bge-m3 --json-only`
## Decision actuelle
1. Invalider `bge-m3` comme upgrade local sur cette machine macOS/Metal tant que le runner Ollama termine sur `ggml_metal_init`.
2. Conserver la baseline applicative actuelle et ne pas changer `apps/api/src/rag.ts` sur la base de ce host.
3. Si on veut requalifier `bge-m3`, le refaire sur une cible Linux/CPU ou Linux/CUDA, pas sur ce host Apple/Metal.
## Sources officielles
- BGE-M3 model card: https://huggingface.co/BAAI/bge-m3
- FlagEmbedding repository: https://github.com/FlagOpen/FlagEmbedding
- Ollama embedding models overview: https://ollama.com/blog/embedding-models
+410
View File
@@ -0,0 +1,410 @@
# Spike: Integration Chatterbox TTS — 2026-03-19
## 1. Resume de Chatterbox
[Chatterbox](https://github.com/resemble-ai/chatterbox) est un moteur TTS open-source (MIT) de Resemble AI, 23 700+ stars GitHub. Package PyPI `chatterbox-tts` v0.1.6, Python >= 3.10.
### Trois variantes
| Variante | Params | Langues | Diffusion steps | Voice cloning | Emotion control | Paralinguistics |
|---|---|---|---|---|---|---|
| **Original** | 0.5B (LLaMA) | EN uniquement | 10 | Oui (zero-shot) | Oui (exaggeration) | Non |
| **Multilingual** | 0.5B | 23 langues (FR, EN, DE, ES, JP...) | 10 | Oui (zero-shot) | Oui (exaggeration) | Non |
| **Turbo** | 350M | EN uniquement | 1 (distille) | Oui (zero-shot) | Non | Oui: `[laugh]`, `[cough]`, `[chuckle]` |
Points cles :
- Zero-shot voice cloning via audio prompt (WAV/MP3, 6-30s de reference)
- Entraine sur 0.5M heures de donnees nettoyees
- Hot-swap entre les trois moteurs sans redemarrage
- Licence MIT (pas de CPML comme XTTS-v2/Coqui)
### API Python native
```python
from chatterbox.tts import ChatterboxTTS
model = ChatterboxTTS.from_pretrained(device="cuda")
wav = model.generate("Bonjour le monde", audio_prompt_path="ref.wav")
# Multilingual
from chatterbox.mtl_tts import ChatterboxMultilingualTTS
model = ChatterboxMultilingualTTS.from_pretrained(device="cuda")
wav = model.generate("Bonjour", audio_prompt_path="ref.wav", language_id="fr")
# Turbo
from chatterbox.turbo_tts import ChatterboxTurboTTS
model = ChatterboxTurboTTS.from_pretrained(device="cuda")
wav = model.generate("Hello [laugh] world", audio_prompt_path="ref.wav")
```
## 2. Comparaison avec Piper
| Critere | Piper | Chatterbox Multilingual | Chatterbox Turbo |
|---|---|---|---|
| **Qualite vocale** | Moyenne (synthe VITS) | Excellente (LLaMA + diffusion) | Tres bonne (distille) |
| **Latence** | ~50ms (CPU) | ~2-5s (GPU, 10 steps) | ~0.5-1.5s (GPU, 1 step) |
| **VRAM** | 0 (CPU pur, ONNX) | ~3-4 GB | ~2 GB |
| **Voice cloning** | Non (voix pre-entrainee) | Oui (zero-shot, audio ref) | Oui (zero-shot, audio ref) |
| **Francais** | Oui (fr_FR-siwis, fr_FR-gilles) | Oui (natif, 23 langues) | Non (EN uniquement) |
| **Emotion/expressivite** | Non | Oui (exaggeration slider) | Oui (tags paralinguistiques) |
| **Licence** | MIT | MIT | MIT |
| **Deps** | onnxruntime (leger) | torch, torchaudio (~2 GB) | torch, torchaudio (~2 GB) |
| **Deployment** | Trivial, CPU anywhere | GPU requis (CUDA) | GPU requis (CUDA) |
### Verdict
- **Piper** reste indispensable comme fallback CPU (Docker, CI, machines sans GPU)
- **Chatterbox Multilingual** est le choix principal pour la prod GPU: francais natif + voice cloning
- **Chatterbox Turbo** interessant pour les personas EN uniquement (Batty, Moorcock, Sherlock) grace aux paralinguistics
## 3. Serveurs OpenAI-compatibles existants
### Option A: devnen/Chatterbox-TTS-Server (recommande)
- FastAPI, support complet des 3 moteurs (Original + Multilingual + Turbo)
- Hot-swap entre moteurs via API
- Endpoint principal: `POST /tts` avec params: text, voice mode, reference audio, temperature, exaggeration, cfg_weight, seed, speed_factor, language
- OpenAI-compatible: `POST /v1/audio/speech` (drop-in)
- Docker support natif, CUDA 12.8 compatible
- Voices predefinies + voice cloning
- Chunking intelligent pour textes longs
- MIT License
### Option B: travisvn/chatterbox-tts-api
- FastAPI, OpenAI-compatible (`POST /v1/audio/speech`)
- Voice library management (`GET /voices`, upload/delete)
- Multilingual support (22 langues)
- Streaming SSE pour audio progressif
- Docker + docker-compose avec GPU
- Note: support Turbo "coming soon" (pas encore integre)
### Recommandation
**devnen/Chatterbox-TTS-Server** est le choix optimal:
- Support complet des 3 moteurs
- CUDA 12.8 teste (RTX 4090/5090)
- Hot-swap sans redemarrage
- Plus mature et actif
## 4. Plan de migration du TTS sidecar
### Etat actuel
```
Docker (API) --HTTP :9100--> tts-server.py (host)
|
TTS_BACKEND="chatterbox" ?
/ \
Chatterbox MTL Piper (fallback)
(from_pretrained) (ONNX, CPU)
```
Le `tts-server.py` actuel charge deja Chatterbox Multilingual via `chatterbox.mtl_tts.ChatterboxMultilingualTTS`. L'integration est donc a mi-chemin.
### Phase 1: Deployer Chatterbox-TTS-Server standalone (1-2 jours)
**Objectif**: Avoir un serveur TTS OpenAI-compatible sur kxkm-ai, testable independamment.
1. Cloner `devnen/Chatterbox-TTS-Server` sur kxkm-ai
2. Creer un venv dedie `/home/kxkm/.venvs/chatterbox-server`
3. Installer avec CUDA 12.8: `pip install chatterbox-tts torch torchaudio --index-url https://download.pytorch.org/whl/cu128`
4. Configurer `config.yaml`:
- `server.host: 127.0.0.1`
- `server.port: 9200` (a cote du :9100 existant)
- `model.engine: multilingual` (defaut pour le francais)
- `paths.predefined_voices: /home/kxkm/kxkm_clown/data/voice-samples`
5. Lancer via tmux: `tmux new-session -d -s chatterbox-server 'python server.py'`
6. Tester: `curl -X POST http://127.0.0.1:9200/tts -H 'Content-Type: application/json' -d '{"text":"Bonjour"}'`
7. Tester OpenAI-compat: `curl -X POST http://127.0.0.1:9200/v1/audio/speech -d '{"model":"chatterbox","input":"Bonjour","voice":"default"}'`
**Validation**: health check vert, audio genere, latence < 5s pour 100 chars.
### Phase 2: Migrer tts-server.py vers Chatterbox-TTS-Server (1-2 jours)
**Objectif**: Router les requetes TTS du conteneur Docker vers Chatterbox par defaut, Piper en fallback.
Option A (proxy simple, recommande):
```
Docker (API) --HTTP :9100--> tts-server.py (inchange)
|
TTS_BACKEND="chatterbox" ?
/ \
HTTP :9200 Piper (fallback CPU)
(Chatterbox-TTS-Server)
```
- Modifier `synthesize_chatterbox()` dans tts-server.py pour faire un `requests.post("http://127.0.0.1:9200/tts", ...)` au lieu de charger le modele en processus
- Avantage: le modele tourne dans un processus dedie, pas de conflit memoire
- Le fallback Piper reste en local dans tts-server.py
Option B (remplacement direct):
- Exposer directement le Chatterbox-TTS-Server sur :9100
- Ajouter un endpoint `/synthesize` compatible avec le contrat actuel
- Plus propre mais plus de travail d'adaptation
**Recommandation**: Option A d'abord, migration vers B si stable.
### Phase 3: Voice cloning per-persona (2-3 jours)
**Objectif**: Chaque persona a sa propre voix clonee via Chatterbox.
1. Generer des samples de reference de haute qualite pour chaque persona:
- Utiliser les samples Piper existants (`data/voice-samples/*.wav`) comme point de depart
- Re-generer avec Chatterbox Multilingual pour une meilleure qualite
- Ajuster manuellement: intonation, debit, expressivite
2. Uploader les samples dans la voice library du Chatterbox-TTS-Server:
- `POST /voices` pour chaque persona
- Nommer les voix par basename persona (schaeffer, batty, etc.)
3. Mettre a jour le `VOICE_MAP` dans tts-server.py pour mapper persona -> voix Chatterbox
4. Tester chaque persona avec un texte representatif
5. Valider la coherence de voix entre requetes (seed fixe)
## 5. Configuration recommandee pour kxkm-ai
### Hardware
- GPU: RTX 4090 (24 GB VRAM)
- CUDA: 12.8
- OS: Ubuntu 24.04
### Budget VRAM estime
| Composant | VRAM |
|---|---|
| Chatterbox Multilingual (0.5B) | ~3-4 GB |
| Chatterbox Turbo (350M) | ~2 GB |
| Ollama (qwen3:8b, quantise) | ~6-8 GB |
| Total en usage parallele | ~10-12 GB |
| Marge disponible RTX 4090 | ~12-14 GB |
Le budget VRAM est confortable. Les deux modeles Chatterbox peuvent coexister avec Ollama.
### Modeles a pre-telecharger
```bash
# Le premier appel from_pretrained() telecharge depuis HuggingFace Hub
# Cache: ~/.cache/huggingface/hub/
python3 -c "from chatterbox.mtl_tts import ChatterboxMultilingualTTS; ChatterboxMultilingualTTS.from_pretrained(device='cuda')"
python3 -c "from chatterbox.turbo_tts import ChatterboxTurboTTS; ChatterboxTurboTTS.from_pretrained(device='cuda')"
```
### Config serveur recommandee
```yaml
server:
host: 127.0.0.1
port: 9200
log_file_max_size_mb: 10
model:
engine: multilingual # defaut FR
device: cuda
generation_defaults:
temperature: 0.7
exaggeration: 0.3 # leger pour naturalite
cfg_weight: 0.5
speed_factor: 1.0
seed: -1 # aleatoire par defaut
paths:
predefined_voices: ./voices
reference_audio: ./reference
output: ./output
```
## 6. Mapping des 33 personas vers des voix Chatterbox
### Strategie
- **FR personas (majorite)**: Chatterbox Multilingual, `language_id="fr"`
- **EN personas**: Chatterbox Turbo (paralinguistics) ou Multilingual `language_id="en"`
- Chaque persona utilise son propre sample de reference pour le voice cloning
### Mapping complet
| # | ID | Nick | Langue | Moteur recommande | Voice sample ref | Notes |
|---|---|---|---|---|---|---|
| 1 | schaeffer | Schaeffer | FR | Multilingual | schaeffer.wav | Precis, technique |
| 2 | batty | Batty | FR | Multilingual | batty.wav | Lyrique, sombre |
| 3 | radigue | Radigue | FR | Multilingual | radigue.wav | Calme, meditatif |
| 4 | oliveros | Oliveros | FR | Multilingual | oliveros.wav | Profond, posee |
| 5 | sunra | SunRa | FR | Multilingual | sunra.wav | Cosmique, mystique |
| 6 | haraway | Haraway | FR | Multilingual | haraway.wav | Intellectuel, militant |
| 7 | pharmacius | Pharmacius | FR | Multilingual | pharmacius.wav | Orchestrateur, neutre |
| 8 | turing | Turing | FR | Multilingual | turing.wav | Logique, questionneur |
| 9 | swartz | Swartz | FR | Multilingual | swartz.wav | Passionne, activiste |
| 10 | merzbow | Merzbow | FR | Multilingual | merzbow.wav | Bruit, intense |
| 11 | hypatia | Hypatia | FR | Multilingual | hypatia.wav | Savante, posee |
| 12 | decroux | Decroux | FR | Multilingual | decroux.wav | Corps, tension |
| 13 | mnouchkine | Mnouchkine | FR | Multilingual | mnouchkine.wav | Theatre, collectif |
| 14 | royaldlx | RoyalDeLuxe | FR | Multilingual | royaldeluxe.wav | Rue, populaire |
| 15 | ikeda | Ikeda | FR | Multilingual | ikeda.wav | Donnees, minimal |
| 16 | teamlab | TeamLab | FR | Multilingual | teamlab.wav | Ecologie numerique |
| 17 | demoscene | Demoscene | FR | Multilingual | demoscene.wav | Code, contrainte |
| 18 | pina | Pina | FR | Multilingual | pina.wav | Danse, emotion |
| 19 | grotowski | Grotowski | FR | Multilingual | grotowski.wav | Theatre, rituel |
| 20 | cirque | Fratellini | FR | Multilingual | fratellini.wav | Cirque, joie |
| 21 | curie | Curie | FR | Multilingual | curie.wav | Science, aventure |
| 22 | foucault | Foucault | FR | Multilingual | foucault.wav | Pouvoir, analyse |
| 23 | deleuze | Deleuze | FR | Multilingual | deleuze.wav | Rhizome, multiplicite |
| 24 | bookchin | Bookchin | FR | Multilingual | bookchin.wav | Ecologie sociale |
| 25 | leguin | LeGuin | FR | Multilingual | leguin.wav | Utopie, recit |
| 26 | cage | Cage | FR | Multilingual | cage.wav | Silence, hasard |
| 27 | bjork | Bjork | FR | Multilingual | bjork.wav | Voix, nature, machine |
| 28 | fuller | Fuller | FR | Multilingual | fuller.wav | Geodesique, systeme |
| 29 | tarkovski | Tarkovski | FR | Multilingual | tarkovski.wav | Temps, image |
| 30 | oram | Oram | FR | Multilingual | oram.wav | Electronique, decouverte |
| 31 | sherlock | Sherlock | FR | Multilingual | sherlock.wav | Indices, deduction |
| 32 | picasso | Picasso | FR | Multilingual | picasso.wav | Image, creation |
| 33 | eno | Eno | FR | Multilingual | eno.wav | Systemes, surprise |
| - | moorcock | Moorcock | EN | Turbo | moorcock.wav | `[laugh]` possible, multivers |
Notes:
- Toutes les personas parlent FR par defaut (systemPrompt en francais)
- Moorcock est la seule persona avec `en_GB-alan-medium` dans le VOICE_MAP actuel, candidate naturelle pour Turbo EN
- Les tags paralinguistiques (`[laugh]`, `[cough]`) sont a injecter dans le systemPrompt ou le post-processing pour les personas EN/Turbo
- L'exaggeration slider permet de differencier les tonalites: faible pour Radigue (calme), fort pour Merzbow (intense)
### Parametres d'expressivite par persona (suggestion)
| Persona | exaggeration | temperature | Notes |
|---|---|---|---|
| Pharmacius | 0.1 | 0.5 | Neutre, orchestrateur |
| Radigue | 0.1 | 0.4 | Calme, meditatif |
| Merzbow | 0.8 | 0.9 | Intense, bruitiste |
| Batty | 0.6 | 0.7 | Lyrique, emotif |
| SunRa | 0.5 | 0.8 | Cosmique, exalte |
| Schaeffer | 0.2 | 0.5 | Precis, technique |
| Sherlock | 0.3 | 0.5 | Analytique |
| Grotowski | 0.5 | 0.6 | Rituel, intense |
| Autres | 0.3 | 0.6 | Defaut equilibre |
## 7. API endpoints necessaires
### Endpoints existants dans tts-server.py (contrat actuel)
```
POST /synthesize { text, voice, persona } -> audio/wav
POST /compose { prompt, duration } -> audio/wav
GET /health -> { ok, backend }
```
### Endpoints cibles (Chatterbox-TTS-Server)
```
# OpenAI-compatible (drop-in pour clients OpenAI)
POST /v1/audio/speech
Body: { model, input, voice, response_format, speed, seed }
Response: audio/wav | audio/mp3 | audio/opus
# Endpoint natif (controle fin)
POST /tts
Body: {
text, voice_mode ("predefined"|"clone"),
predefined_voice, reference_audio,
split_text, chunk_size,
temperature, exaggeration, cfg_weight,
seed, speed_factor, language
}
Response: audio/wav
# Voice management
GET /voices -> liste des voix
POST /voices -> upload voice sample
DELETE /voices/{name} -> supprimer voix
GET /languages -> langues supportees
# Health
GET /health -> status serveur
GET /api/ui/initial-data -> status complet + config
# Engine switching (devnen)
POST /api/engine/switch -> { engine: "multilingual"|"turbo"|"original" }
```
### Adaptation du contrat /synthesize
Pour la Phase 2 (proxy), le `tts-server.py` doit mapper:
```python
# Requete entrante (contrat actuel)
{ "text": "Bonjour", "persona": "schaeffer", "voice": "fr_FR-siwis-medium" }
# Requete sortante vers Chatterbox-TTS-Server
POST http://127.0.0.1:9200/tts
{
"text": "Bonjour",
"voice_mode": "predefined",
"predefined_voice": "schaeffer",
"temperature": 0.5,
"exaggeration": 0.2,
"language": "fr"
}
```
## 8. Risques et mitigations
| Risque | Impact | Probabilite | Mitigation |
|---|---|---|---|
| **VRAM OOM** avec Ollama + Chatterbox simultanes | Service down | Moyenne | Budget VRAM valide (~12 GB sur 24). Monitor `nvidia-smi`. Unload moteur non utilise. |
| **Latence Chatterbox** trop elevee pour chat temps reel | UX degradee | Moyenne | Utiliser Turbo (1-step) pour les cas sensibles a la latence. Piper fallback pour < 200ms. |
| **Qualite voice cloning FR** insuffisante | Voix non naturelle | Faible | Chatterbox Multilingual entraine sur FR. Tester avec les 33 personas. Ajuster exaggeration/temperature. |
| **Chatterbox Turbo** ne supporte pas le FR | Personas FR limitees | Certaine | Turbo = EN only. Utiliser Multilingual pour FR (toutes sauf Moorcock). |
| **Regression API** lors de la migration | Breaking change | Faible | Phase 2 en proxy: le contrat /synthesize reste identique. Tests smoke avant deploy. |
| **Deps Python** en conflit avec venv existant | Installation bloquee | Faible | Venv dedie `/home/kxkm/.venvs/chatterbox-server`, isole de `/home/kxkm/venv`. |
| **Licence Coqui CPML** (XTTS-v2) vs MIT (Chatterbox) | Legal | N/A | Migration vers Chatterbox = sortie du regime CPML. Bonus juridique. |
| **Modele HuggingFace** non disponible offline | Premiere utilisation | Faible | Pre-telecharger les modeles pendant le provisioning. Cache HF persistant. |
| **Coexistence mascarade** sur kxkm-ai | Conflit ports/GPU | Faible | Ports distincts (9200 vs 3000+). VRAM suffisante pour coexister. |
## 9. Estimation d'effort
| Phase | Tache | Effort | Prerequis |
|---|---|---|---|
| **Phase 1** | Deployer Chatterbox-TTS-Server standalone | 1-2 jours | Acces SSH kxkm-ai |
| | Configurer, tester, valider health | | |
| **Phase 2** | Modifier tts-server.py (proxy HTTP) | 1-2 jours | Phase 1 validee |
| | Tests smoke end-to-end | | |
| **Phase 3** | Voice cloning 33 personas | 2-3 jours | Phase 2 validee |
| | Upload samples, tuning expressivite | | |
| | Validation qualite par persona | | |
| **Total** | | **4-7 jours** | |
### Dependances externes
- Aucun cout cloud (tout on-prem sur kxkm-ai)
- Modeles HuggingFace gratuits (MIT)
- Pas de TOS/CPML a accepter (contrairement a XTTS-v2)
### Quick wins
1. Le `tts-server.py` charge deja `chatterbox.mtl_tts.ChatterboxMultilingualTTS` -- l'integration est a mi-chemin
2. Les voice samples existent deja dans `data/voice-samples/`
3. Le deploy script `scripts/deploy.sh --tts` est deja en place (tmux session)
4. Le contrat API `/synthesize` n'a pas besoin de changer cote client
## 10. Decision et prochaines etapes
### Recommandation
Adopter **devnen/Chatterbox-TTS-Server** comme serveur TTS principal, en remplacement progressif de l'integration directe dans tts-server.py.
### Prochaines etapes
1. [ ] Valider le spike avec l'equipe
2. [ ] Phase 1: deployer sur kxkm-ai (:9200)
3. [ ] Benchmark latence/qualite sur 5 personas representatives
4. [ ] Phase 2: modifier tts-server.py en mode proxy
5. [ ] Phase 3: voice cloning complet des 33 personas
6. [ ] Retirer la dependance XTTS-v2/Coqui (simplification, exit CPML)
---
## Sources
- https://github.com/resemble-ai/chatterbox (23 700+ stars, MIT)
- https://pypi.org/project/chatterbox-tts/ (v0.1.6)
- https://github.com/devnen/Chatterbox-TTS-Server (FastAPI, OpenAI-compat, 3 moteurs)
- https://github.com/travisvn/chatterbox-tts-api (FastAPI, OpenAI-compat, voice library)
- Code existant: `scripts/tts-server.py`, `scripts/generate-voice-samples.js`, `scripts/tts_clone_voice.py`
- Validation precedente: `docs/VOICE_CLONING_VALIDATION_2026-03-17.md`
+45
View File
@@ -0,0 +1,45 @@
# Documents / Search Spike - 2026-03-17
## Etat local confirme
- `apps/api/src/web-search.ts` tente deja `SearXNG` en premier, puis `WEB_SEARCH_API_BASE`, puis DuckDuckGo.
- `docker-compose.yml` expose deja un service `searxng` sous le profil `v2`.
- `ops/v2/searxng/settings.yml` versionne maintenant la config locale pour autoriser `format=json`.
- `apps/api/src/ws-upload-handler.ts` envoie deja les PDFs vers `scripts/extract_pdf_docling.py`.
- `scripts/extract_pdf_docling.py` sait utiliser `Docling`, puis `PyMuPDF` en fallback.
- `scripts/extract_document.py` couvre deja les formats bureautiques hors PDF.
- `MinerU` n'est pas encore branche runtime; il reste un spike adjacent.
## Gaps reels
- Pas de check ops unifie pour distinguer `seam pret` et `service/dependance effectivement provisionne`.
- Pas de branchement runtime MinerU dans le pipeline upload.
- Pas de verification CI/ops legere pour la presence des deps `docling`, `fitz` ou `magic_pdf`.
## Recommandation minimale
1. Garder `SearXNG` comme backend prioritaire, avec fallback visible et explicite.
2. Traiter `Docling` comme premier parseur PDF local, puis `PyMuPDF` en repli.
3. Garder `MinerU` au stade spike jusqu'a preuve de valeur sur des PDFs complexes.
4. Utiliser un health check ops non destructif avant toute activation stricte en runtime.
## Commandes utiles
- `npm run smoke:documents-search`
- `bash scripts/health-doc-search.sh all --verbose`
- `bash scripts/health-doc-search.sh search --strict`
- `bash scripts/health-doc-search.sh docs`
- `docker compose --profile v2 config --services`
## Etat machine observe
- `docker compose --profile v2 config --services` expose bien `searxng`, `api` et `worker`.
- Sur cette machine, `SearXNG` tourne sur `http://localhost:8080` et `bash scripts/health-doc-search.sh search --strict` est vert.
- Les modules Python `docling`, `fitz` et `magic_pdf` ne sont pas provisionnes actuellement.
## Sources officielles
- SearXNG docs: https://docs.searxng.org/
- SearXNG GitHub: https://github.com/searxng/searxng
- Docling docs: https://docling-project.github.io/docling/
- MinerU repo: https://github.com/opendatalab/MinerU
+12 -6
View File
@@ -99,8 +99,10 @@ flowchart TD
| --- | --- |
| RAG local | Embeddings via Ollama (`nomic-embed-text`), cosine similarity, contexte manifeste injecte |
| STT (Speech-to-Text) | `faster-whisper` (CTranslate2, int8) ou fallback `openai-whisper`, modeles tiny/base/small/medium/large |
| TTS (Text-to-Speech) | `piper-tts` (fallback rapide) + XTTS-v2 (voice cloning zero-shot per persona, GPU) |
| TTS (Text-to-Speech) | Dual backend: `piper-tts` (CPU) + `Chatterbox` (GPU, emotion tags, MIT) via TTS sidecar HTTP :9100 |
| Vision | `qwen3-vl:8b` via Ollama, analyse d'images uploadees, description en francais |
| VoiceChat | Push-to-talk (maintenir), level meter temps reel, silence auto 2s, 26 voix piper |
| Mediatheque | Gallery images + playlist audio, persistance disk, API REST |
| Extraction PDF | Docling (tables, layout, OCR) + fallback PyMuPDF |
| Texte/CSV/JSON | Lecture directe des fichiers texte, CSV, JSONL uploades |
| Generation musicale | `/compose` — ACE-Step 1.5 (GPU, <4GB VRAM) + fallback MusicGen |
@@ -234,8 +236,10 @@ flowchart TD
| **Multimodal** | | | |
| RAG (embeddings locaux) | -- | OK | haute |
| STT (faster-whisper) | -- | OK | haute |
| TTS (piper-tts) | -- | OK | moyenne |
| Vision (minicpm-v) | -- | OK | haute |
| TTS (piper + Chatterbox) | -- | OK | moyenne |
| VoiceChat push-to-talk | -- | OK | haute |
| Mediatheque gallery/playlist | -- | OK | moyenne |
| Vision (qwen3-vl:8b) | -- | OK | haute |
| Extraction PDF | -- | OK | moyenne |
| Memoire persona | -- | OK | haute |
| **Admin** | | | |
@@ -277,7 +281,9 @@ flowchart TD
| Cookies HttpOnly | OK | OK | haute |
| RBAC roles | -- | OK | haute |
| **Tests** | | | |
| Backend unit tests | -- | OK (102) | haute |
| React component tests | -- | OK (33) | haute |
| Smoke tests | OK | OK (22) | haute |
| Backend unit tests | -- | OK (204) | haute |
| React component tests | -- | OK | haute |
| Smoke tests | OK | OK | haute |
| UI Minitel VIDEOTEX | -- | OK | haute |
| Deploy tmux (deploy.sh) | -- | OK | moyenne |
| CI/CD GitHub Actions | -- | OK | haute |
+294
View File
@@ -0,0 +1,294 @@
# Spike: Integration LightRAG pour kxkm_clown
**Date**: 2026-03-19
**Auteur**: Claude (spike automatise)
**Statut**: DRAFT
**Version cible LightRAG**: v1.4.11 (lightrag-hku sur PyPI)
---
## 1. Resume de LightRAG
### Qu'est-ce que LightRAG ?
LightRAG (HKU) est un framework de Graph RAG open-source (licence MIT) qui combine
la recherche vectorielle classique avec un graphe de connaissances (knowledge graph)
extrait automatiquement des documents. Contrairement au RAG naif (chunk + cosine similarity),
LightRAG construit un graphe d'entites et de relations, ce qui permet des requetes
multi-niveaux : local (contexte precis), global (themes generaux), hybrid, et mix
(graphe + vecteurs combines).
### Fonctionnalites cles
| Fonctionnalite | Detail |
|---|---|
| **Graph RAG** | Extraction automatique d'entites/relations via LLM, stockage dans un graphe |
| **Modes de requete** | `local`, `global`, `hybrid`, `naive`, `mix` (KG + vecteurs), `bypass` |
| **Backends LLM** | OpenAI, Ollama, vLLM, Hugging Face, tout API compatible OpenAI |
| **Backends Embedding** | OpenAI, Ollama, Sentence-Transformers, API compatible |
| **Stockage vectoriel** | NanoVectorDB (defaut), PostgreSQL (pgvector), Milvus, Chroma, Faiss, Qdrant, MongoDB |
| **Stockage graphe** | NetworkX (defaut), Neo4J, PostgreSQL (AGE), OpenSearch |
| **Stockage KV** | JSON (defaut), PostgreSQL, Redis, MongoDB, OpenSearch |
| **Reranker** | Support natif (bge-reranker-v2-m3, Jina), ameliore significativement le mode mix |
| **API REST** | Serveur integre (lightrag-server) avec Web UI, API compatible Ollama |
| **Docker** | docker-compose officiel inclus |
| **Citations** | Support des citations/sources dans les reponses |
| **Deletion** | Suppression de documents avec regeneration automatique du KG |
| **Cache LLM** | Cache des reponses LLM pour eviter les appels redondants |
### Architecture interne
```
Documents
|
v
[Chunking (1200 tokens, overlap 100)]
|
v
[Embedding via Ollama/OpenAI] --> [Vector Store (pgvector)]
|
v
[Entity/Relation Extraction via LLM] --> [Knowledge Graph (NetworkX/PG)]
|
v
[KV Store (metadonnees, doc status)] --> [PostgreSQL/JSON]
Query:
User Query --> [Embedding] --> [Vector Search] \
--> [Merge + Rerank] --> [LLM Generation]
--> [KG Traversal (entites/relations)] /
```
---
## 2. Comparaison avec le RAG actuel (LocalRAG)
### LocalRAG actuel (`apps/api/src/rag.ts`)
Le systeme actuel est un RAG minimal in-memory en TypeScript :
- **Chunking** : Split par paragraphes, fusion des chunks courts (max 500 chars)
- **Embedding** : nomic-embed-text via Ollama `/api/embed`
- **Stockage** : En memoire (array de `DocumentChunk[]`), perdu au restart
- **Recherche** : Cosine similarity brute-force, seuil min 0.3
- **Documents indexes** : `manifeste.md`, `manifeste_references_nouvelles.md`
- **Integration** : Injecte via `ChatOptions.rag` dans ws-chat et ws-ollama
### Tableau comparatif
| Critere | LocalRAG (actuel) | LightRAG (cible) |
|---|---|---|
| **Type** | Vector RAG naif | Graph RAG + Vector RAG |
| **Langage** | TypeScript | Python |
| **Persistance** | Aucune (in-memory) | PostgreSQL / fichiers |
| **Chunking** | 500 chars, paragraphes | 1200 tokens, overlap intelligent |
| **Recherche** | Cosine brute-force | Multi-mode (local/global/hybrid/mix) |
| **Graphe de connaissances** | Non | Oui (entites + relations) |
| **Reranking** | Non | Oui (bge-reranker) |
| **Scalabilite** | ~100 chunks max raisonnable | Datasets large-scale |
| **Cache** | Non | Cache LLM integre |
| **Web UI** | Non | Oui (lightrag-server) |
| **API REST** | Non (code embarque) | Oui (compatible Ollama) |
| **Maintenance** | Custom, fragile | Communaute active (22k+ stars) |
### Points forts de LocalRAG a conserver
- Simplicite d'integration (meme process Node.js)
- Latence minimale (pas de round-trip reseau supplementaire)
- Zero dependance externe
### Points faibles de LocalRAG justifiant la migration
- Pas de persistance : re-indexation a chaque restart
- Pas de graphe de connaissances : les relations entre concepts du manifeste sont perdues
- Chunking naif : decoupe par paragraphes sans respect des limites semantiques
- Pas de reranking : les resultats sont parfois peu pertinents
- Pas d'UI d'administration pour visualiser les documents indexes
---
## 3. Plan d'integration en 3 phases
### Phase 1 : Installation et configuration standalone
**Objectif** : LightRAG tourne sur kxkm-ai, accessible via API REST, valide avec les documents existants.
**Taches** :
1. Deployer LightRAG via Docker Compose sur kxkm-ai
- Image officielle `ghcr.io/hkuds/lightrag`
- Configurer `.env` pour utiliser Ollama local (localhost:11434)
- Modele LLM : `qwen3:8b` (deja sur le serveur, 8B params suffisants pour extraction d'entites)
- Modele embedding : `nomic-embed-text` (coherence avec le systeme actuel)
- Stockage : PostgreSQL (meme instance que le projet, database separee `lightrag`)
2. Verifier le fonctionnement :
- Inserer un document test via API REST
- Tester les 4 modes de requete (local, global, hybrid, mix)
- Valider la Web UI
3. Benchmarker les performances :
- Temps d'indexation du manifeste (~10 KB)
- Latence de requete par mode
- Utilisation GPU (extraction d'entites via qwen3:8b)
**Criteres de succes** :
- LightRAG repond aux requetes en < 5s
- Le graphe de connaissances contient des entites pertinentes du manifeste
- Pas d'impact sur les performances d'Ollama pour les autres usages
**Effort estime** : 0.5 jour
### Phase 2 : Migration des documents manifeste vers LightRAG
**Objectif** : Tous les documents de reference du projet sont indexes dans LightRAG avec un graphe de connaissances complet.
**Taches** :
1. Indexer les documents existants :
- `data/manifeste.md`
- `data/manifeste_references_nouvelles.md`
- Descriptions des personas (system prompts)
2. Enrichir le corpus :
- Ajouter les documents de reference artistique
- Ajouter les fiches personas detaillees
3. Valider la qualite du graphe :
- Visualiser le KG via la Web UI
- Verifier les entites extraites (artistes, concepts, references)
- Ajuster les parametres d'extraction si necessaire (`entity_types`, `language`)
4. Configurer les parametres de requete optimaux :
- Tester `addon_params.language: "French"` pour l'extraction
- Ajuster `chunk_token_size` si necessaire
- Tester le reranker si disponible
**Criteres de succes** :
- Les requetes en francais retournent des resultats pertinents
- Le graphe contient les entites cles : artistes, oeuvres, concepts esthetiques
- Les relations entre concepts sont coherentes
**Effort estime** : 1 jour
### Phase 3 : Remplacement de LocalRAG par un adapter LightRAG
**Objectif** : Le code de l'API Node.js utilise LightRAG via son API REST au lieu de LocalRAG.
**Taches** :
1. Creer un adapter `LightRAGClient` dans `apps/api/src/` :
```typescript
// apps/api/src/lightrag-client.ts
export class LightRAGClient {
constructor(private baseUrl: string) {}
async search(query: string, mode: "hybrid" | "mix" = "mix", topK = 3): Promise<SearchResult[]>
async insert(text: string, source: string): Promise<void>
get ready(): boolean
}
```
2. L'adapter doit implementer la meme interface que `LocalRAG` (methodes `search`, `addDocument`, `size`)
3. Modifier `server.ts` pour instancier `LightRAGClient` au lieu de `LocalRAG`
4. Mettre a jour `ChatOptions` dans `chat-types.ts` pour accepter le nouveau type
5. Conserver `LocalRAG` comme fallback si LightRAG est indisponible
6. Ajouter un health check LightRAG dans le monitoring
**Points d'integration** :
- `apps/api/src/server.ts` : ligne 44 (`new LocalRAG(...)` -> `new LightRAGClient(...)`)
- `apps/api/src/ws-conversation-router.ts` : ligne 95-97 (appel `rag.search`)
- `apps/api/src/ws-ollama.ts` : ligne 145-146 (appel `rag.search`)
- `apps/api/src/chat-types.ts` : ligne 34 (type `rag?: LocalRAG`)
**Criteres de succes** :
- Les personas utilisent LightRAG pour le contexte sans changement d'UX
- Fallback vers LocalRAG si LightRAG est down
- Temps de reponse total < 8s (incluant RAG + LLM generation)
**Effort estime** : 1 jour
---
## 4. Configuration recommandee pour Ollama
### Modeles requis
| Usage | Modele | Taille | Deja installe? |
|---|---|---|---|
| **LLM (extraction KG)** | `qwen3:8b` | ~5 GB | A verifier |
| **Embedding** | `nomic-embed-text` | ~274 MB | Oui (utilise par LocalRAG) |
| **Reranker** (optionnel) | `bge-reranker-v2-m3` (via API) | ~1 GB | Non |
### Recommandations modeles
- **qwen3:8b** est recommande par LightRAG pour l'extraction d'entites/relations avec des LLM open-source.
Le contexte de 32K tokens est suffisant. La RTX 4090 (24 GB VRAM) peut le charger confortablement.
- **nomic-embed-text** : conserver le meme modele d'embedding garantit la coherence
avec les embeddings existants et evite une re-indexation.
- **Reranker** : Le reranker ameliore significativement le mode `mix`. A evaluer en Phase 2.
### Configuration Ollama
Pas de changement de config Ollama necessaire. LightRAG utilise les endpoints standard :
- `POST /api/chat` (LLM generation)
- `POST /api/embed` (embeddings)
### Storage
```
data/lightrag/ # Working directory LightRAG
├── graph_chunk_entity_relation.graphml # Export graphe (si NetworkX)
└── kv_store_llm_response_cache.json # Cache LLM
```
Pour PostgreSQL (recommande en production) :
- Database : `lightrag` (separee de la DB principale du projet)
- Extensions requises : `pgvector`, `age` (pour le graph storage)
- Tables creees automatiquement par LightRAG
---
## 5. Risques et mitigations
| # | Risque | Impact | Probabilite | Mitigation |
|---|---|---|---|---|
| R1 | **Qualite extraction FR** : LightRAG est optimise pour l'anglais, l'extraction d'entites en francais peut etre moins precise | Moyen | Moyenne | Configurer `addon_params.language: "French"`, tester avec qwen3:8b qui a un bon support multilingue |
| R2 | **Latence** : L'extraction d'entites via LLM ajoute de la latence a l'indexation | Faible | Haute | L'indexation est asynchrone (au demarrage), pas d'impact sur les requetes. Requetes < 5s en mode hybrid |
| R3 | **Ressources GPU** : qwen3:8b pour l'extraction + modele persona en parallele | Moyen | Moyenne | Ollama gere le swapping de modeles. Indexation en batch hors peak. La 4090 a 24 GB VRAM |
| R4 | **Complexite operationnelle** : Ajout d'un service Python (LightRAG server) a maintenir | Moyen | Faible | Docker Compose, health checks, monitoring existant. Service stateless |
| R5 | **Lock-in embedding** : Changer de modele d'embedding necessite une re-indexation complete | Faible | Faible | Conserver nomic-embed-text, documenter la procedure de migration |
| R6 | **Indisponibilite LightRAG** : Si le service tombe, les personas n'ont plus de contexte RAG | Moyen | Faible | Fallback vers LocalRAG in-memory. Health check avec alerte |
| R7 | **Taille du graphe** : Avec peu de documents (~20 KB de manifeste), le graphe peut etre trop sparse | Faible | Moyenne | Enrichir le corpus en Phase 2. Le mode `naive` reste disponible comme fallback |
---
## 6. Estimation d'effort total
| Phase | Effort | Prerequis |
|---|---|---|
| Phase 1 : Standalone | 0.5 jour | Acces SSH kxkm-ai, Docker |
| Phase 2 : Migration docs | 1 jour | Phase 1 validee |
| Phase 3 : Adapter API | 1 jour | Phase 2 validee |
| **Total** | **2.5 jours** | |
### Dependances externes
- Docker et Docker Compose sur kxkm-ai (deja present)
- PostgreSQL avec extensions pgvector et age (a installer/activer)
- Ollama avec qwen3:8b (a pull si absent)
### Definition of Done
- [ ] LightRAG server tourne sur kxkm-ai (Docker)
- [ ] Documents manifeste indexes avec graphe de connaissances
- [ ] Requetes en francais retournent des resultats pertinents (modes hybrid et mix)
- [ ] Adapter LightRAGClient integre dans apps/api
- [ ] Fallback LocalRAG fonctionnel
- [ ] Health check et monitoring en place
- [ ] Documentation operationnelle a jour
---
## 7. References
- LightRAG GitHub : https://github.com/HKUDS/LightRAG
- Paper : https://arxiv.org/abs/2410.05779
- PyPI : https://pypi.org/project/lightrag-hku/
- Docker : https://github.com/HKUDS/LightRAG/pkgs/container/lightrag
- Code actuel LocalRAG : `apps/api/src/rag.ts`
- Points d'integration : `apps/api/src/server.ts`, `ws-conversation-router.ts`, `ws-ollama.ts`
+69
View File
@@ -0,0 +1,69 @@
# EXECUTION STATUS (kxkm-clown-v2)
Updated: 2026-03-17T22:13:03Z
## lot-0-cadrage
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: docs-reviewed
- Open tasks: none
## lot-1-socle
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: npm run check:v2, npm run test:v2
- Open tasks: none
## lot-2-domaines
- Status: done
- Owner: Backend API
- Execution: managed
- Checks: npm run test:v2
- Open tasks: none
## lot-3-surfaces
- Status: done
- Owner: Frontend
- Execution: managed
- Checks: npm run -w @kxkm/web check
- Open tasks: none
## lot-4-bascule
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: npm run smoke:v2
- Open tasks: none
## lot-12-deep-audit
- Status: done
- Owner: Coordinateur
- Execution: manual
- Checks: npm run check:v2, npm run test:v2, npm run -w @kxkm/web test, node ops/v2/deep-audit.js --json
- Open tasks: none
## lot-13-voice-mcp
- Status: done
- Owner: Multimodal
- Execution: manual
- Checks: node scripts/mcp-server-smoke.js, npm run smoke:voice-mcp, npm run smoke:voice-clone, bash ops/v2/run-spike-checks.sh voice-clone --yes
- Open tasks: none
## lot-14-documents-search
- Status: done
- Owner: Ops/TUI
- Execution: manual
- Checks: docker compose --profile v2 config --services, bash scripts/health-doc-search.sh search --strict, npm run smoke:documents-search, npm run smoke:embeddings, bash ops/v2/run-spike-checks.sh embeddings --yes
- Open tasks: none
## lot-15-hotspot-reduction
- Status: running
- Owner: Coordinateur
- Execution: manual
- Checks: npm run -w @kxkm/persona-domain check, npm run test:v2, bash ops/v2/run-deep-cycle.sh run --yes
- Open tasks:
- node-engine-seams [pending] (P2, Worker/Engine)
- storage-test-split [pending] (P3, Backend API)
- web-chat-modularization [pending] (P2, Frontend)
+55
View File
@@ -0,0 +1,55 @@
# OSS Priorities - 2026-03-17
## Decision
Priorite d'integration pour le cycle actuel:
1. `SearXNG` comme backend de recherche self-hosted.
2. `Docling` et `MinerU` comme spike document parsing adjacent.
3. `MCP TypeScript SDK` comme standard d'outillage/personas.
4. `LiveKit Agents JS` comme candidat principal pour le lot voice/WebRTC.
Projets gardes comme benchmarks produit, pas comme dependances embarquees:
- `Open WebUI`
- `LibreChat`
- `AnythingLLM`
## Shortlist
| Projet | Role retenu | Pourquoi maintenant | Source |
| --- | --- | --- | --- |
| SearXNG | integration directe | moteur self-hosted, API JSON, alignement avec la recherche web locale | https://github.com/searxng/searxng , https://docs.searxng.org/ |
| Docling | spike prioritaire | conversion multi-format, OCR, sorties Markdown/JSON, execution locale | https://docling-project.github.io/docling/ |
| MinerU | spike prioritaire | PDF complexes, OCR CPU/GPU, oriente extraction LLM | https://github.com/opendatalab/MinerU |
| MCP TypeScript SDK | integration directe | standardiser tools/resources/prompts cote personas et services | https://github.com/modelcontextprotocol/typescript-sdk |
| LiveKit Agents JS | lot suivant | voice/WebRTC temps reel en Node, bon fit pour la suite voice-mcp | https://github.com/livekit/agents-js |
## Benchmarks produit
| Projet | Usage retenu | Source |
| --- | --- | --- |
| Open WebUI | benchmark UX local/Ollama/RAG | https://github.com/open-webui/open-webui |
| LibreChat | benchmark multi-provider, auth, MCP, memory | https://www.librechat.ai/ , https://github.com/LibreChat-AI |
| AnythingLLM | benchmark workspaces/RAG/agents | https://github.com/Mintplex-Labs/anything-llm |
## Notes d'adoption
- `SearXNG` est le seul candidat a brancher dans le cycle current sans attendre une refonte majeure.
- `Docling` et `MinerU` doivent rester adjacents tant que la boucle `audit -> test -> resume -> sync-docs -> purge` n'est pas stabilisee.
- `LiveKit Agents JS` ne doit pas entrer dans le cycle backend immediat; il reste assigne au lot voice-mcp.
- `BGE-M3` reste un spike benchmark, pas une decision de remplacement immediate.
## Seams prets
| Zone | Etat actuel | Petite action utile maintenant |
| --- | --- | --- |
| Recherche web | `apps/api/src/web-search.ts` tente `SearXNG` puis `WEB_SEARCH_API_BASE` puis DuckDuckGo, et `scripts/mcp-server.js` a deja un fallback SearXNG. | Utiliser `scripts/health-doc-search.sh search --strict` pour valider le endpoint JSON et garder le fallback visible en ops. |
| PDF/doc parsing | `apps/api/src/ws-upload-handler.ts` appelle deja `scripts/extract_pdf_docling.py` pour les PDFs et `scripts/extract_document.py` pour le reste. | Utiliser `scripts/health-doc-search.sh docs` pour verifier les deps `docling`/`PyMuPDF` et la presence du futur chemin `magic_pdf` MinerU. |
| Docs de reference | `docs/DOCUMENT_AI_STATE_OF_ART_2026.md` recommande Docling d'abord, MinerU ensuite. | Laisser le produit inchangé et documenter l'ordre d'adoption avant tout branchement runtime. |
### Commande de preparation
```bash
bash scripts/health-doc-search.sh all --verbose
```
+89
View File
@@ -0,0 +1,89 @@
# Veille OSS — 2026-03-19
Mise a jour veille projets et librairies open source pour 3615-KXKM.
## Top recommandations (impact/effort)
| Priorite | Projet | Usage | URL |
| --- | --- | --- | --- |
| 1 | Chatterbox Turbo | TTS zero-shot, 350M, emotion tags, MIT | https://github.com/resemble-ai/chatterbox |
| 2 | LightRAG v1.4.11 | Graph RAG, Ollama natif, workspace isolation | https://github.com/HKUDS/LightRAG |
| 3 | Qwen3-TTS | TTS 0.6B-1.7B, clone 3s, 10 langues, Apache 2.0 | https://github.com/QwenLM/Qwen3-TTS |
| 4 | Kokoro | TTS 82M params, ultra-leger, CPU | https://github.com/hexgrad/kokoro |
| 5 | AgoraAI | Multi-persona voice-to-voice, dual-queue async | https://www.mdpi.com/2076-3417/16/4/2120 |
| 6 | vault66-crt-effect | React CRT presets (npm install) | https://github.com/mdombrov-33/vault66-crt-effect |
| 7 | webgl-crt-shader | WebGL GPU CRT shader, jan 2026 | https://github.com/gingerbeardman/webgl-crt-shader |
| 8 | NexusRAG | Hybrid: LightRAG + Docling + Ollama | https://github.com/LeDat98/NexusRAG |
| 9 | Pocket TTS | 100M params, CPU temps reel, voice cloning | https://github.com/kyutai-labs/pocket-tts |
| 10 | LiveKit Agents v1.4.5 | Voice/WebRTC, MCP natif, Apache 2.0 | https://github.com/livekit/agents |
## Nouvelles decouvertes (mars 2026)
### TTS
- **Qwen3-TTS** (Alibaba, jan 2026) — 0.6B-1.7B params, clone voix en 3s, 10 langues, latence 97ms, Apache 2.0. FastAPI OpenAI-compat. Concurrent serieux de Chatterbox.
- **Pocket TTS** (Kyutai) — 100M params, CPU temps reel, voice cloning. Ultra-leger, ideal edge/embarque. ~3600 stars.
- **Chatterbox Turbo** — 350M params, 1-step diffusion, tags paralinguistiques [laugh] [cough]. Multilingual (23 langues).
### RAG
- **LightRAG v1.4.11rc2** (13 mars 2026) — 29.4k stars. Nouveau Makefile, batch query embeddings, Qdrant fixes.
- **RAG-Anything** (HKUDS) — Extension LightRAG pour multimodal (images, tables, formules).
- **NexusRAG** — Hybrid: vector + LightRAG graph + cross-encoder reranking + Docling. Supporte Ollama nativement.
- **ApeRAG** — Production-ready GraphRAG, multi-modal, MCP support, K8s.
### Multi-persona / Agents
- **AgoraAI** (fev 2026, paper MDPI) — Framework voice-to-voice multi-persona. Resout le "Concurrency-Coherence Paradox" via Asynchronous Dual-Queue Processing. Directement applicable au use-case kxkm_clown.
- **CrewAI** — 44.3k stars, role-playing agents. Trop enterprise pour kxkm.
- **LobeChat Agent Groups** — Equipes d'agents specialises collaborant en parallele.
### MCP 2026
- **Roadmap 2026** : Streamable HTTP (serveurs MCP distants sans etat), Tasks (lifecycle), Enterprise (audit, SSO, gateway).
- **Ecosysteme** : OpenAI et Microsoft supportent MCP. Catalogue mcpservers.org.
- **LiveKit Agents** : Integration MCP native depuis v1.4.
### UI Retro / CRT
- **webgl-crt-shader** (gingerbeardman, jan 2026) — WebGL pur, GPU-accelere, tweakable. Plus performant qu'overlay CSS.
- **crt-beam-simulator** (Blur Busters) — Simulation physique du faisceau CRT, le plus realiste.
## Par categorie (mise a jour)
### TTS
| Projet | Stars | Licence | Notes |
| --- | --- | --- | --- |
| Chatterbox Turbo | 11k+ | MIT | Zero-shot, emotion, 350M, 1-step diffusion |
| Qwen3-TTS | Nouveau | Apache 2.0 | 0.6B-1.7B, clone 3s, 10 langues |
| Kokoro | 3k+ | Apache | 82M, ultra-rapide, CPU |
| Pocket TTS | 3.6k | Open | 100M, CPU temps reel |
| RealtimeTTS | 3.8k | MIT | Abstraction multi-backends |
| OpenVoice | 36k | MIT | Voice cloning zero-shot |
### RAG
| Projet | Stars | Notes |
| --- | --- | --- |
| LightRAG | 29.4k | Graph RAG, Ollama natif, v1.4.11rc2 |
| RAGFlow | 70k+ | Enterprise, deep document understanding |
| NexusRAG | Nouveau | Hybrid LightRAG + Docling + Ollama |
| ApeRAG | Nouveau | GraphRAG, MCP, K8s |
### Embeddings
| Modele | Taille | Ollama | Notes |
| --- | --- | --- | --- |
| nomic-embed-text | ~137M | Oui | Baseline actuelle |
| BGE-M3 | ~568M | Oui | Hybrid dense+sparse, multilingual |
| mxbai-embed-large | ~335M | Oui | Depasse text-embedding-3-large |
| Qwen3-Embedding-0.6B | 600M | A verifier | Nouveau 2026 |
### Voice temps reel
| Projet | Stars | Notes |
| --- | --- | --- |
| LiveKit Agents | 9.7k | v1.4.5, MCP natif, Apache 2.0 |
| pipecat | Actif | Voice/multimodal conversational AI |
| AgoraAI | Paper | Multi-persona voice, dual-queue |
+1 -1
View File
@@ -267,7 +267,7 @@ Les images uploadees sont analysees via un modele Ollama compatible vision.
| Parametre | Default | Description |
| --- | --- | --- |
| `VISION_MODEL` | `minicpm-v` | Modele Ollama avec capacite vision |
| `VISION_MODEL` | `qwen3-vl:8b` | Modele Ollama avec capacite vision |
| Timeout | 5 minutes | Timeout d'analyse |
| Prompt | Fixe | "Analyse cette image en detail. Decris ce que tu vois..." (francais) |
@@ -0,0 +1,39 @@
# Voice Cloning Validation - 2026-03-17
## Etat local confirme
- Le runtime principal sait deja basculer vers XTTS-v2 quand un sample voix existe: `apps/api/src/ws-multimodal.ts`.
- L'upload et le statut des samples voix existent deja cote admin: `apps/api/src/routes/personas.ts`, `apps/web/src/api.ts`, `apps/web/src/components/PersonaDetail.tsx`.
- Le repo contient deja les scripts XTTS `scripts/tts_clone_voice.py` et `scripts/xtts_clone.py`, plus le fallback Piper `scripts/tts_synthesize.py`.
- Le helper `apps/api/src/voice-samples.ts` unifie maintenant la resolution du nom de fichier entre upload admin et runtime TTS.
- Le health check `scripts/health-voice-clone.sh` fournit un probe non destructif des deps XTTS et des samples.
## Etat machine observe
- `npm run -s smoke:voice-clone` passe et sonde maintenant le meme interpreteur que le runtime API.
- Le runtime local `.venvs/voice-clone` est provisionne en `python3.12` avec `torch`, `coqui-tts`, `piper-tts` et `transformers<5`.
- `scripts/generate-voice-samples.js` consomme maintenant le roster canonique de `apps/api/src/personas-default.ts` et le meme contrat de nommage que le runtime.
- `data/voice-samples/pharmacius.wav` a ete genere avec Piper, et `data/piper-voices/fr_FR-gilles-low.onnx` est present localement.
- Sur `kxkm-ai`, `ffmpeg` est present, `bash scripts/health-voice-clone.sh --json --verbose` remonte `torch=true`, `tts=true`, `piper_module=true`, `coqui_tos_agreed=true`, `cuda=true` et `persona_sample_present=true`.
- Le smoke XTTS non interactif passe maintenant sur `kxkm-ai` avec `COQUI_TOS_AGREED=1 bash scripts/setup-voice-clone.sh all --persona pharmacius --yes --verbose`, et produit un rendu audio valide via `scripts/tts_clone_voice.py`.
## Commandes utiles
- `npm run -s smoke:voice-clone`
- `bash scripts/health-voice-clone.sh --json --verbose`
- `bash scripts/setup-voice-clone.sh bootstrap --yes`
- `bash scripts/setup-voice-clone.sh sample --persona pharmacius --yes`
- `COQUI_TOS_AGREED=1 bash scripts/setup-voice-clone.sh smoke --persona pharmacius --yes`
- `node scripts/generate-voice-samples.js --dry-run --persona SunRa`
## Decision actuelle
1. Garder Piper comme fallback immediat.
2. Considerer le runtime XTTS comme valide sur `kxkm-ai`, avec garde-fous scripts, sample local valide et smoke final vert sous `COQUI_TOS_AGREED=1`.
3. Fermer `voice-cloning-validation` et `lot-13-voice-mcp`; garder Piper comme voie de repli operationnelle.
## Sources officielles
- Coqui XTTS-v2 model card: https://huggingface.co/coqui/XTTS-v2
- Coqui TTS repository: https://github.com/coqui-ai/TTS
- Coqui XTTS streaming server note on `COQUI_TOS_AGREED=1`: https://github.com/coqui-ai/xtts-streaming-server
+42
View File
@@ -0,0 +1,42 @@
# Voice / MCP Spike - 2026-03-17
## Etat local confirme
- `apps/web/src/components/VoiceChat.tsx` est un chat vocal browser-side base sur `MediaRecorder`, `WebSocket` et un flux upload STT.
- `apps/api/src/ws-multimodal.ts` fournit deja le TTS, la synthese vocale par persona et les garde-fous de concurrence.
- `scripts/discord-voice.js` est un rail voice externe distinct, centre Discord, STT Python et TTS Python.
- `scripts/mcp-server.js` utilise maintenant le SDK MCP officiel sur stdio.
- `@modelcontextprotocol/sdk` est actif dans le runtime local et valide par smoke autonome.
- Aucun paquet `@livekit/*` n'est installe dans le workspace pour l'instant.
## Gaps reels
- Pas de runtime LiveKit agent dans le repo.
- Pas de transport browser WebRTC/room/agent pour remplacer le chat vocal WebSocket.
- Pas de health check dedicated pour valider le serveur MCP local de maniere autonome.
## Recommandation minimale
1. Garder `VoiceChat` comme experience browser actuelle tant que le spike LiveKit n'a pas montre un vrai gain.
2. Introduire un agent LiveKit dans un script adjoint seulement apres preuve de valeur, sans toucher au chat WebSocket principal.
3. Conserver le serveur MCP sur le SDK officiel et garder le smoke stdio comme garde-fou de protocole.
4. N'ajouter LiveKit/voice-cloning qu'apres validation d'un vrai besoin runtime.
## Commandes utiles
- `node scripts/mcp-server-smoke.js`
- `node scripts/mcp-server-smoke.js --with-tool-call`
- `node scripts/mcp-server.js`
- `npm run smoke:voice-mcp`
## Etat machine observe
- Le smoke MCP valide `initialize` + `tools/list` sans API locale demarree, avec le SDK officiel.
- Le `tools/call` peut etre force avec `--with-tool-call`, mais il depend alors de `KXKM_API_URL`.
## Sources officielles
- LiveKit Agents JS: https://github.com/livekit/agents-js
- LiveKit Agents docs: https://docs.livekit.io/agents/
- MCP SDK officiel: https://github.com/modelcontextprotocol/typescript-sdk
- MCP SDK docs: https://modelcontextprotocol.io/docs/sdk
+65
View File
@@ -0,0 +1,65 @@
{
"timestamp": "20260317-230603",
"generated_at": "2026-03-17T22:06:11Z",
"files": {
"audit": "/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230603.json",
"check": "/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230603.log",
"test": "/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230603.log",
"summary": "/home/kxkm/KXKM_Clown/ops/v2/outputs/deep-cycles/summary-20260317-230603.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230603.json",
"/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230603.log",
"/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230603.log"
],
"counts": {
"security": 0,
"performance": 0,
"large_files": 9
},
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
},
"hotspots": [
"packages/persona-domain/src/index.ts",
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx"
],
"open_findings": [],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/ui-contract",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "f407098d6b9eefa41e87481fce8160d6c61a56c07b52ffc4ec2cd676aad1dda5",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "ee0bf24524cdd085a6a0eeee1339aa6924311be226819e7c195471bc7abf8e38"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"timestamp": "20260317-230529",
"mode": "all",
"generated_at": "2026-03-17T22:05:31Z",
"checks": {
"voice-mcp": "ok",
"voice-clone": "ok",
"documents-search": "ok",
"embeddings": "ok"
},
"logs": {
"voice-mcp": "/home/kxkm/KXKM_Clown/ops/v2/logs/voice-mcp-smoke-20260317-230529.log",
"voice-clone": "/home/kxkm/KXKM_Clown/ops/v2/logs/voice-clone-smoke-20260317-230529.log",
"documents-search": "/home/kxkm/KXKM_Clown/ops/v2/logs/documents-search-smoke-20260317-230529.log",
"embeddings": "/home/kxkm/KXKM_Clown/ops/v2/logs/embeddings-smoke-20260317-230529.log"
},
"summary": "/home/kxkm/KXKM_Clown/ops/v2/outputs/spikes/summary-all-20260317-230529.md"
}
+48 -59
View File
@@ -1,86 +1,75 @@
# PLAN (kxkm-clown-v2)
Updated: 2026-03-17T08:45:00Z
Updated: 2026-03-19T08:20:00Z
## lot-0-cadrage [done]
- Description: Docs, architecture, feature map, agents, invariants, orchestration
- Depends on: none
- Owner: Coordinateur
- Execution: managed
- Checks: docs-reviewed
- Summary: Cadrage historique clos, conserve comme base de référence.
## lot-1-socle [done]
- Description: Workspace V2, packages, scripts TUI, verification
- Depends on: lot-0-cadrage
- Owner: Coordinateur
- Execution: managed
- Checks: npm run check:v2, npm run test:v2
- Summary: Socle monorepo en place, scripts TUI et vérifications disponibles.
## lot-2-domaines [done]
- Description: Auth, chat, storage, personas, node engine
- Depends on: lot-1-socle
- Owner: Backend API
- Execution: managed
- Checks: npm run test:v2
- Summary: Domaines métier V2 en place, avec couverture de base et repos réels.
## lot-3-surfaces [done]
- Description: Shell React/Vite, admin, chat, node engine, ops
- Depends on: lot-2-domaines
- Owner: Frontend
- Execution: managed
- Checks: npm run -w @kxkm/web check
- Summary: Surfaces V2 disponibles, avec dette UI et duplication admin encore présentes.
## lot-4-bascule [done]
- Description: Migration, parite, rollback, bascule
- Description: Migration, parité, rollback, bascule
- Depends on: lot-3-surfaces
- Owner: Coordinateur
- Execution: managed
- Checks: npm run smoke:v2
- Summary: Bascule initiale et outillage de migration réalisés.
## lot-5-production [done]
- Description: Training adapters, sandboxing, tests, turborepo, CI/CD, GitHub
## lot-12-deep-audit [done]
- Description: Deep analyse continue, refactoring, veille OSS, docs et infrastructure adjacente
- Depends on: lot-4-bascule
- Owner: Coordinateur
- Execution: manual
- Checks: npm run check:v2, npm run test:v2, npm run -w @kxkm/web test, node ops/v2/deep-audit.js --json
- Summary: Boucle deep-audit stabilisee: pipeline/docs/logs coherents, seams backend/frontend fermes et cycle ops industrialise.
## lot-6-consolidation [done]
- Description: Deep analyse, correctifs securite, feature parity V2, deploy
- Depends on: lot-5-production
## lot-7-training [done]
- Description: PyTorch, Unsloth, TRL, DPO pipeline, autoresearch, Ollama import
- Depends on: lot-6-consolidation
## lot-8-multimodal [done]
- Description: RAG, STT, TTS, vision, PDF, recherche web, memoire persona
- Depends on: lot-7-training
## lot-9-chat-avance [done]
- Description: Chat vocal, inter-persona, multi-channel, analytics, 26 personas
- Depends on: lot-8-multimodal
## lot-10-generation [done]
- Description: ComfyUI, Sherlock, Picasso, diversification modeles, contexte 750MB
- Depends on: lot-9-chat-avance
## lot-11-mcp-personas [done]
- Description: MCP tool-calling personas, pipeline fine-tune
- Depends on: lot-10-generation
## lot-12-deep-audit [in-progress]
- Description: Deep audit code, refactoring, veille OSS, diagrammes, infrastructure
- Depends on: lot-11-mcp-personas
- Deliverables:
- ops/v2/deep-audit.js (TUI security/perf/complexity)
- docs/OSS_WATCH enrichi (voice, music, PDF, WebRTC, MCP, persona fine-tune)
- docs/ARCHITECTURE.md (3 Mermaid ajoutes)
- docs/AGENTS.md (matrice 10 agents, pipeline intervention)
- PLAN.md + TODO.md consolides
- Refactoring ws-chat.ts + app.ts
- SearXNG + MinerU/Docling docker
## lot-13-voice-mcp [planned]
- Description: XTTS-v2 voice cloning, LLMRTC WebRTC, MCP SDK, Discord Pharmacius
## lot-13-voice-mcp [done]
- Description: Voice/WebRTC/MCP et convergence temps réel
- Depends on: lot-12-deep-audit
- Owner: Multimodal
- Execution: manual
- Checks: node scripts/mcp-server-smoke.js, npm run smoke:voice-mcp, npm run smoke:voice-clone, bash ops/v2/run-spike-checks.sh voice-clone --yes
- Summary: Spike voice/MCP stabilise: serveur MCP migre vers le SDK officiel, runtime XTTS valide sur kxkm-ai avec sample Piper genere, ffmpeg disponible et smoke non interactif vert sous COQUI_TOS_AGREED=1.
## lot-14-music-creative [planned]
## lot-14-documents-search [done]
- Description: Services adjacents de recherche et document parsing
- Depends on: lot-12-deep-audit
- Owner: Ops/TUI
- Execution: manual
- Checks: docker compose --profile v2 config --services, bash scripts/health-doc-search.sh search --strict, npm run smoke:documents-search, npm run smoke:embeddings, bash ops/v2/run-spike-checks.sh embeddings --yes
- Summary: Seams search/doc consolides: SearXNG tourne localement avec format json actif et health check strict vert; le spike BGE-M3 est clos comme resultat negatif sur ce host Apple/Metal, avec maintien de la baseline actuelle.
- Description: ACE-Step 1.5, /compose, Flux 2, A2A Protocol
- Depends on: lot-13-voice-mcp
## lot-15-hotspot-reduction [done]
- Description: Reduction chirurgicale des hotspots de domaine, moteur, tests storage et chat web
- Depends on: lot-13-voice-mcp, lot-14-documents-search
- Owner: Coordinateur
- Execution: manual
- Checks: npm run -w @kxkm/persona-domain check, npm run test:v2, bash ops/v2/run-deep-cycle.sh run --yes
- Summary: Lot 15 DONE: persona-domain seams, node-engine registry, storage-test-split (5 suites + helpers), web-chat-modularization (Chat.tsx 631→67 LOC, 5 modules). Cookie Secure flag + rate limit login. 210 tests, 0 fail.
+69
View File
@@ -0,0 +1,69 @@
# EXECUTION STATUS (kxkm-clown-v2)
Updated: 2026-03-17T22:13:03Z
## lot-0-cadrage
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: docs-reviewed
- Open tasks: none
## lot-1-socle
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: npm run check:v2, npm run test:v2
- Open tasks: none
## lot-2-domaines
- Status: done
- Owner: Backend API
- Execution: managed
- Checks: npm run test:v2
- Open tasks: none
## lot-3-surfaces
- Status: done
- Owner: Frontend
- Execution: managed
- Checks: npm run -w @kxkm/web check
- Open tasks: none
## lot-4-bascule
- Status: done
- Owner: Coordinateur
- Execution: managed
- Checks: npm run smoke:v2
- Open tasks: none
## lot-12-deep-audit
- Status: done
- Owner: Coordinateur
- Execution: manual
- Checks: npm run check:v2, npm run test:v2, npm run -w @kxkm/web test, node ops/v2/deep-audit.js --json
- Open tasks: none
## lot-13-voice-mcp
- Status: done
- Owner: Multimodal
- Execution: manual
- Checks: node scripts/mcp-server-smoke.js, npm run smoke:voice-mcp, npm run smoke:voice-clone, bash ops/v2/run-spike-checks.sh voice-clone --yes
- Open tasks: none
## lot-14-documents-search
- Status: done
- Owner: Ops/TUI
- Execution: manual
- Checks: docker compose --profile v2 config --services, bash scripts/health-doc-search.sh search --strict, npm run smoke:documents-search, npm run smoke:embeddings, bash ops/v2/run-spike-checks.sh embeddings --yes
- Open tasks: none
## lot-15-hotspot-reduction
- Status: running
- Owner: Coordinateur
- Execution: manual
- Checks: npm run -w @kxkm/persona-domain check, npm run test:v2, bash ops/v2/run-deep-cycle.sh run --yes
- Open tasks:
- node-engine-seams [pending] (P2, Worker/Engine)
- storage-test-split [pending] (P3, Backend API)
- web-chat-modularization [pending] (P2, Frontend)
+36 -96
View File
@@ -1,114 +1,54 @@
# TODO (kxkm-clown-v2)
Updated: 2026-03-17T08:45:00Z
Updated: 2026-03-19T08:20:00Z
## lot-0-cadrage
- [x] architecture (done)
- [x] docs (done)
- [x] agents (done)
- [x] architecture (done) | owner: Coordinateur | severity: P2 | out: outputs/lot-0-cadrage/architecture.csv
- [x] docs (done) | owner: Coordinateur | severity: P2 | out: outputs/lot-0-cadrage/docs.csv
- [x] agents (done) | owner: Coordinateur | severity: P2 | out: outputs/lot-0-cadrage/agents.csv
## lot-1-socle
- [x] workspace (done)
- [x] verification (done)
- [x] ops-tui (done)
- [x] workspace (done) | owner: Coordinateur | severity: P2 | out: outputs/lot-1-socle/workspace.csv
- [x] verification (done) | owner: Coordinateur | severity: P2 | out: outputs/lot-1-socle/verification.csv
- [x] ops-tui (done) | owner: Ops/TUI | severity: P2 | out: outputs/lot-1-socle/ops-tui.csv
## lot-2-domaines
- [x] auth-chat (done)
- [x] personas (done)
- [x] node-engine (done)
- [x] auth-chat (done) | owner: Backend API | severity: P2 | out: ops/v2/outputs/lot-2-domaines/auth-chat.csv
- [x] personas (done) | owner: Personas | severity: P2 | out: ops/v2/outputs/lot-2-domaines/personas.csv
- [x] node-engine (done) | owner: Worker/Engine | severity: P2 | out: ops/v2/outputs/lot-2-domaines/node-engine.csv
## lot-3-surfaces
- [x] web-shell (done)
- [x] persona-ui (done)
- [x] engine-ui (done)
- [x] web-shell (done) | owner: Frontend | severity: P2 | out: ops/v2/outputs/lot-3-surfaces/web-shell.csv
- [x] persona-ui (done) | owner: Frontend | severity: P2 | out: ops/v2/outputs/lot-3-surfaces/persona-ui.csv
- [x] engine-ui (done) | owner: Frontend | severity: P2 | out: ops/v2/outputs/lot-3-surfaces/engine-ui.csv
## lot-4-bascule
- [x] migration (done)
- [x] parity (done)
- [x] cutover (done)
## lot-5-production
- [x] training-adapters (done)
- [x] sandboxing (done)
- [x] tests (done)
- [x] turborepo (done)
- [x] ci-cd (done)
## lot-6-consolidation
- [x] deep-analyse (done)
- [x] security-fixes (done)
- [x] feature-parity (done)
- [x] deploy (done)
## lot-7-training
- [x] pytorch-unsloth (done)
- [x] dpo-pipeline (done)
- [x] autoresearch (done)
- [x] ollama-import (done)
## lot-8-multimodal
- [x] rag-local (done)
- [x] stt-whisper (done)
- [x] tts-piper (done)
- [x] vision-analysis (done)
- [x] pdf-extraction (done)
- [x] web-search (done)
- [x] persona-memory (done)
## lot-9-chat-avance
- [x] voice-chat (done)
- [x] inter-persona (done)
- [x] multi-channel (done)
- [x] analytics (done)
- [x] 26-personas (done)
## lot-10-generation
- [x] comfyui (done)
- [x] sherlock-picasso (done)
- [x] model-diversification (done)
- [x] context-750mb (done)
## lot-11-mcp-personas
- [x] mcp-tool-calling (done)
- [x] pipeline-finetune (done)
- [x] migration (done) | owner: Coordinateur | severity: P2 | out: ops/v2/outputs/lot-4-bascule/migration.csv
- [x] parity (done) | owner: Coordinateur | severity: P2 | out: ops/v2/outputs/lot-4-bascule/parity.csv
- [x] cutover (done) | owner: Coordinateur | severity: P2 | out: ops/v2/outputs/lot-4-bascule/cutover.csv
## lot-12-deep-audit
- [x] deep-audit-tui (done) | ops/v2/deep-audit.js
- [x] oss-watch-enriched (done) | docs/OSS_WATCH_2026-03-16.md
- [x] mermaid-diagrams (done) | docs/ARCHITECTURE.md
- [x] agents-matrix (done) | docs/AGENTS.md
- [x] plan-todo-consolidation (done) | PLAN.md + TODO.md
- [ ] deep-analyse-agents (in-progress) | 5 agents paralleles
- [ ] refactor-ws-chat (pending) | 1449 LOC → 4 modules
- [ ] refactor-app-ts (pending) | 1292 LOC → routes + middleware
- [ ] searxng-docker (pending) | docker-compose service
- [ ] mineru-docling (pending) | remplacer pdf-parse
- [ ] discord-pharmacius (pending) | bot Discord orchestrateur
- [ ] bge-m3-spike (pending) | upgrade embeddings RAG
- [x] pipeline-canonique (done) | owner: Coordinateur | severity: P1
- [x] deep-cycle-operator (done) | owner: Ops/TUI | severity: P1
- [x] ws-chat-seams (done) | owner: Backend API | severity: P1
- [x] app-bootstrap-extraction (done) | owner: Backend API | severity: P1
- [x] worker-runtime-harness (done) | owner: Worker/Engine | severity: P1
- [x] web-shell-convergence (done) | owner: Frontend | severity: P1
- [x] ui-contract (done) | owner: Frontend | severity: P2
- [x] oss-shortlist (done) | owner: Veille OSS | severity: P2
## lot-13-voice-mcp
- [x] livekit-agents-spike (done) | owner: Multimodal | severity: P2
- [x] mcp-sdk-integration (done) | owner: Backend API | severity: P2
- [x] voice-cloning-validation (done) | owner: Multimodal | severity: P2
- [ ] xtts-v2-voice-cloning (planned)
- [ ] llmrtc-webrtc (planned)
- [ ] mcp-sdk-integration (planned)
- [ ] pcl-opencharacter (planned)
## lot-14-documents-search
- [x] searxng-service (done) | owner: Ops/TUI | severity: P2
- [x] docling-mineru-spike (done) | owner: Veille OSS | severity: P2
- [x] bge-m3-benchmark (done) | owner: Veille OSS | severity: P3
## lot-14-music-creative
- [ ] ace-step-production (planned)
- [ ] compose-command (planned)
- [ ] flux2-comfyui (planned)
- [ ] a2a-protocol (planned)
## lot-15-hotspot-reduction
- [x] persona-domain-seams (done) | owner: Backend API | severity: P2
- [x] node-engine-seams (done) | owner: Worker/Engine | severity: P2
- [x] storage-test-split (done) | owner: Backend API | severity: P3
- [x] web-chat-modularization (done) | owner: Frontend | severity: P2
+149
View File
@@ -0,0 +1,149 @@
# =============================================================================
# LightRAG Configuration Example for kxkm_clown
# =============================================================================
# Ce fichier sert de reference pour configurer LightRAG sur kxkm-ai.
# Copier vers .env ou utiliser comme base pour docker-compose.yml
# =============================================================================
# ---------------------------------------------------------------------------
# LLM Configuration (via Ollama)
# ---------------------------------------------------------------------------
llm:
provider: ollama
model: qwen3:8b
base_url: http://localhost:11434
# Contexte max du modele (qwen3:8b supporte 32K)
max_context_size: 32768
# Nombre max d'appels LLM asynchrones simultanes
max_async: 4
# Parametres additionnels pour la generation
kwargs:
temperature: 0.0
num_predict: 2048
# ---------------------------------------------------------------------------
# Embedding Configuration (via Ollama)
# ---------------------------------------------------------------------------
embedding:
provider: ollama
model: nomic-embed-text
base_url: http://localhost:11434
# Dimension des embeddings nomic-embed-text
embedding_dim: 768
max_token_size: 8192
# Nombre de textes par batch d'embedding
batch_num: 32
max_async: 16
# ---------------------------------------------------------------------------
# Storage Configuration (PostgreSQL all-in-one)
# ---------------------------------------------------------------------------
storage:
# KV Storage : metadonnees documents et chunks
kv_storage: PGKVStorage
# Vector Storage : embeddings (requiert extension pgvector)
vector_storage: PGVectorStorage
# Graph Storage : graphe de connaissances (requiert extension age)
graph_storage: PGGraphStorage
# Document Status Storage
doc_status_storage: PGDocStatusStorage
postgres:
host: localhost
port: 5432
database: lightrag
user: kxkm
password: "${LIGHTRAG_PG_PASSWORD}" # A definir dans .env
# Creer la database si elle n'existe pas
# CREATE DATABASE lightrag;
# CREATE EXTENSION IF NOT EXISTS vector; -- pgvector
# CREATE EXTENSION IF NOT EXISTS age; -- Apache AGE pour graph storage
# ---------------------------------------------------------------------------
# Working Directory
# ---------------------------------------------------------------------------
working_dir: data/lightrag
# ---------------------------------------------------------------------------
# RAG Parameters
# ---------------------------------------------------------------------------
rag:
# Taille des chunks en tokens
chunk_token_size: 1200
# Overlap entre chunks
chunk_overlap_token_size: 100
# Nombre de boucles d'extraction d'entites (1 = standard, 2+ = plus precis mais plus lent)
entity_extract_max_gleaning: 1
# Langue pour l'extraction d'entites et la generation
language: French
# Types d'entites a extraire (adaptes au projet artistique)
entity_types:
- personne
- artiste
- oeuvre
- concept
- lieu
- evenement
- technique
- instrument
- mouvement_artistique
# Cache des reponses LLM
enable_llm_cache: true
# ---------------------------------------------------------------------------
# Query Defaults
# ---------------------------------------------------------------------------
query:
# Mode par defaut pour les requetes
default_mode: mix
# Nombre de resultats top-k
top_k: 30
# Seuil de similarite cosine
cosine_better_than_threshold: 0.2
# Format de reponse
response_type: "Multiple Paragraphs"
# Activer le reranking (necessite un reranker configure)
enable_rerank: false
# ---------------------------------------------------------------------------
# Server Configuration (lightrag-server)
# ---------------------------------------------------------------------------
server:
host: 0.0.0.0
port: 9621
# Authentification (optionnel)
# api_key: "${LIGHTRAG_API_KEY}"
# ---------------------------------------------------------------------------
# Docker Compose equivalent (.env)
# ---------------------------------------------------------------------------
# Pour deployer via Docker Compose, creer un fichier .env avec :
#
# # LLM
# LLM_PROVIDER=ollama
# LLM_MODEL=qwen3:8b
# OLLAMA_HOST=http://host.docker.internal:11434
#
# # Embedding
# EMBEDDING_PROVIDER=ollama
# EMBEDDING_MODEL=nomic-embed-text
# EMBEDDING_DIM=768
#
# # Storage (PostgreSQL)
# KV_STORAGE=PGKVStorage
# VECTOR_STORAGE=PGVectorStorage
# GRAPH_STORAGE=PGGraphStorage
# DOC_STATUS_STORAGE=PGDocStatusStorage
# POSTGRES_HOST=host.docker.internal
# POSTGRES_PORT=5432
# POSTGRES_DATABASE=lightrag
# POSTGRES_USER=kxkm
# POSTGRES_PASSWORD=changeme
#
# # RAG
# LANGUAGE=French
# CHUNK_TOKEN_SIZE=1200
# CHUNK_OVERLAP_TOKEN_SIZE=100
#
# # Server
# PORT=9621
+371
View File
@@ -0,0 +1,371 @@
{
"security": [],
"performance": [
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/context-store.ts",
"line": 245,
"snippet": "console.log(`[context] Compacting ${channel}: ${lines.length} entries`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/context-store.ts",
"line": 316,
"snippet": "console.log(`[context] Compacted ${channel}: ${toSummarize.length} entries → summary, kept ${toKeep.length} recent`);"
},
{
"severity": "P1",
"pattern": "readFileSync in non-init",
"file": "apps/api/src/server.ts",
"line": 61,
"snippet": "const text = fs.readFileSync(filePath, \"utf-8\");"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 63,
"snippet": "console.log(`[rag] Indexed ${filePath}: ${count} chunks`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 67,
"snippet": "console.log(`[rag] Ready: ${rag.size} total chunks`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 83,
"snippet": "console.log(\"[context] Persistent context store ready\");"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 106,
"snippet": "console.log(JSON.stringify({"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat.ts",
"line": 147,
"snippet": "const extracted = JSON.parse(data.message?.content || \"{}\") as {"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat.ts",
"line": 497,
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat.ts",
"line": 893,
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat.ts",
"line": 1262,
"snippet": "const result = JSON.parse(lastLine) as { status?: string; transcript?: string; error?: string };"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 379,
"snippet": "console.log(`[mcp-tools] ${persona.nick} calling ${name}(${JSON.stringify(args)})`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 644,
"snippet": "console.log(`[ws-chat] Refreshed personas: ${personas.map((p) => p.nick).join(\", \")}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 891,
"snippet": "if (stderr) console.log(`[compose] ${stderr.slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 1258,
"snippet": "if (stderr) console.log(`[ws-chat][audio] ${stderr.trim().slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 1286,
"snippet": "if (stderr) console.log(`[upload] pdf: ${stderr.slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 1314,
"snippet": "if (stderr) console.log(`[upload] doc extract: ${stderr.slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 1453,
"snippet": "console.log(`[ws-chat] WebSocket chat attached on /ws (Ollama: ${ollamaUrl})`);"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/worker/src/index.ts",
"line": 152,
"snippet": "const evalResult = JSON.parse(jsonLine);"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/worker/src/index.ts",
"line": 226,
"snippet": "const trainResult = JSON.parse(jsonLine);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/worker/src/index.ts",
"line": 67,
"snippet": "console.log(`[${ts}] ${msg}`);"
}
],
"metrics": [
{
"file": "apps/api/src/ws-chat.ts",
"lines": 1456,
"sizeKB": 48,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 1326,
"sizeKB": 47,
"flag": "large"
},
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 567,
"sizeKB": 19,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/worker/src/index.ts",
"lines": 548,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 424,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 420,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 293,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 273,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 212,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
]
}
+391
View File
@@ -0,0 +1,391 @@
{
"security": [],
"performance": [
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/context-store.ts",
"line": 245,
"snippet": "console.log(`[context] Compacting ${channel}: ${lines.length} entries`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/context-store.ts",
"line": 316,
"snippet": "console.log(`[context] Compacted ${channel}: ${toSummarize.length} entries → summary, kept ${toKeep.length} recent`);"
},
{
"severity": "P1",
"pattern": "readFileSync in non-init",
"file": "apps/api/src/server.ts",
"line": 61,
"snippet": "const text = fs.readFileSync(filePath, \"utf-8\");"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 63,
"snippet": "console.log(`[rag] Indexed ${filePath}: ${count} chunks`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 67,
"snippet": "console.log(`[rag] Ready: ${rag.size} total chunks`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 83,
"snippet": "console.log(\"[context] Persistent context store ready\");"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 106,
"snippet": "console.log(JSON.stringify({"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat.ts",
"line": 147,
"snippet": "const extracted = JSON.parse(data.message?.content || \"{}\") as {"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat.ts",
"line": 497,
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat.ts",
"line": 893,
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat.ts",
"line": 1262,
"snippet": "const result = JSON.parse(lastLine) as { status?: string; transcript?: string; error?: string };"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 379,
"snippet": "console.log(`[mcp-tools] ${persona.nick} calling ${name}(${JSON.stringify(args)})`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 644,
"snippet": "console.log(`[ws-chat] Refreshed personas: ${personas.map((p) => p.nick).join(\", \")}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 891,
"snippet": "if (stderr) console.log(`[compose] ${stderr.slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 1258,
"snippet": "if (stderr) console.log(`[ws-chat][audio] ${stderr.trim().slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 1286,
"snippet": "if (stderr) console.log(`[upload] pdf: ${stderr.slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 1314,
"snippet": "if (stderr) console.log(`[upload] doc extract: ${stderr.slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 1453,
"snippet": "console.log(`[ws-chat] WebSocket chat attached on /ws (Ollama: ${ollamaUrl})`);"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/worker/src/index.ts",
"line": 152,
"snippet": "const evalResult = JSON.parse(jsonLine);"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/worker/src/index.ts",
"line": 226,
"snippet": "const trainResult = JSON.parse(jsonLine);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/worker/src/index.ts",
"line": 67,
"snippet": "console.log(`[${ts}] ${msg}`);"
}
],
"metrics": [
{
"file": "apps/api/src/ws-chat.ts",
"lines": 1456,
"sizeKB": 48,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 1326,
"sizeKB": 47,
"flag": "large"
},
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 567,
"sizeKB": 19,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/worker/src/index.ts",
"lines": 548,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 424,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 420,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 293,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 273,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 212,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 78,
"level": "high",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 1,
"P2": 20
},
"tsErrors": 0,
"largeFiles": 8,
"mediumFiles": 6,
"deps": 2
}
}
}
+391
View File
@@ -0,0 +1,391 @@
{
"security": [],
"performance": [
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/context-store.ts",
"line": 245,
"snippet": "console.log(`[context] Compacting ${channel}: ${lines.length} entries`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/context-store.ts",
"line": 316,
"snippet": "console.log(`[context] Compacted ${channel}: ${toSummarize.length} entries → summary, kept ${toKeep.length} recent`);"
},
{
"severity": "P1",
"pattern": "readFileSync in non-init",
"file": "apps/api/src/server.ts",
"line": 61,
"snippet": "const text = fs.readFileSync(filePath, \"utf-8\");"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 63,
"snippet": "console.log(`[rag] Indexed ${filePath}: ${count} chunks`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 67,
"snippet": "console.log(`[rag] Ready: ${rag.size} total chunks`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 83,
"snippet": "console.log(\"[context] Persistent context store ready\");"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 106,
"snippet": "console.log(JSON.stringify({"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat.ts",
"line": 147,
"snippet": "const extracted = JSON.parse(data.message?.content || \"{}\") as {"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat.ts",
"line": 497,
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat.ts",
"line": 893,
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat.ts",
"line": 1262,
"snippet": "const result = JSON.parse(lastLine) as { status?: string; transcript?: string; error?: string };"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 379,
"snippet": "console.log(`[mcp-tools] ${persona.nick} calling ${name}(${JSON.stringify(args)})`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 644,
"snippet": "console.log(`[ws-chat] Refreshed personas: ${personas.map((p) => p.nick).join(\", \")}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 891,
"snippet": "if (stderr) console.log(`[compose] ${stderr.slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 1258,
"snippet": "if (stderr) console.log(`[ws-chat][audio] ${stderr.trim().slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 1286,
"snippet": "if (stderr) console.log(`[upload] pdf: ${stderr.slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 1314,
"snippet": "if (stderr) console.log(`[upload] doc extract: ${stderr.slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 1453,
"snippet": "console.log(`[ws-chat] WebSocket chat attached on /ws (Ollama: ${ollamaUrl})`);"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/worker/src/index.ts",
"line": 152,
"snippet": "const evalResult = JSON.parse(jsonLine);"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/worker/src/index.ts",
"line": 226,
"snippet": "const trainResult = JSON.parse(jsonLine);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/worker/src/index.ts",
"line": 67,
"snippet": "console.log(`[${ts}] ${msg}`);"
}
],
"metrics": [
{
"file": "apps/api/src/ws-chat.ts",
"lines": 1456,
"sizeKB": 48,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 1326,
"sizeKB": 47,
"flag": "large"
},
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 567,
"sizeKB": 19,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/worker/src/index.ts",
"lines": 548,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 424,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 420,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 293,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 273,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 212,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 78,
"level": "high",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 1,
"P2": 20
},
"tsErrors": 0,
"largeFiles": 8,
"mediumFiles": 6,
"deps": 2
}
}
}
+384
View File
@@ -0,0 +1,384 @@
{
"security": [],
"performance": [
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/context-store.ts",
"line": 245,
"snippet": "console.log(`[context] Compacting ${channel}: ${lines.length} entries`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/context-store.ts",
"line": 316,
"snippet": "console.log(`[context] Compacted ${channel}: ${toSummarize.length} entries → summary, kept ${toKeep.length} recent`);"
},
{
"severity": "P1",
"pattern": "readFileSync in non-init",
"file": "apps/api/src/server.ts",
"line": 61,
"snippet": "const text = fs.readFileSync(filePath, \"utf-8\");"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 63,
"snippet": "console.log(`[rag] Indexed ${filePath}: ${count} chunks`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 67,
"snippet": "console.log(`[rag] Ready: ${rag.size} total chunks`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 83,
"snippet": "console.log(\"[context] Persistent context store ready\");"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/server.ts",
"line": 106,
"snippet": "console.log(JSON.stringify({"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat.ts",
"line": 147,
"snippet": "const extracted = JSON.parse(data.message?.content || \"{}\") as {"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat.ts",
"line": 497,
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat.ts",
"line": 893,
"snippet": "const result = JSON.parse(stdout.trim().split(\"\\n\").pop() || \"{}\") as {"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 379,
"snippet": "console.log(`[mcp-tools] ${persona.nick} calling ${name}(${JSON.stringify(args)})`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 644,
"snippet": "console.log(`[ws-chat] Refreshed personas: ${personas.map((p) => p.nick).join(\", \")}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 891,
"snippet": "if (stderr) console.log(`[compose] ${stderr.slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-chat.ts",
"line": 1315,
"snippet": "console.log(`[ws-chat] WebSocket chat attached on /ws (Ollama: ${ollamaUrl})`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-upload-handler.ts",
"line": 93,
"snippet": "if (stderr) console.log(`[ws-chat][audio] ${stderr.trim().slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-upload-handler.ts",
"line": 118,
"snippet": "if (stderr) console.log(`[upload] pdf: ${stderr.slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-upload-handler.ts",
"line": 145,
"snippet": "if (stderr) console.log(`[upload] doc extract: ${stderr.slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/worker/src/index.ts",
"line": 152,
"snippet": "const evalResult = JSON.parse(jsonLine);"
},
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/worker/src/index.ts",
"line": 226,
"snippet": "const trainResult = JSON.parse(jsonLine);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/worker/src/index.ts",
"line": 67,
"snippet": "console.log(`[${ts}] ${msg}`);"
}
],
"metrics": [
{
"file": "apps/api/src/app.ts",
"lines": 1326,
"sizeKB": 47,
"flag": "large"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 1318,
"sizeKB": 42,
"flag": "large"
},
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 567,
"sizeKB": 19,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/worker/src/index.ts",
"lines": 548,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 424,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 420,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 293,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 273,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 212,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "fail",
"errors": 6,
"detail": "src/ws-chat.ts(1187,28): error TS1308: 'await' expressions are only allowed within async functions and at the top levels of modules.\nsrc/ws-chat.ts(1188,3): error TS1308: 'await' expressions are only allowed within async functions and at the top levels of modules.\nsrc/ws-chat.ts(1189,5): error TS2304: Cannot find name 'ws'.\nsrc/ws-chat.ts(1190,5): error TS2304: Cannot find name 'info'.\nsrc/ws-chat.ts(1191,5): error TS2304: Cannot find name 'parsed'.\nsrc/ws-chat.ts(1276,15): error TS2554: Expecte"
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 100,
"level": "high",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 1,
"P2": 19
},
"tsErrors": 6,
"largeFiles": 8,
"mediumFiles": 6,
"deps": 2
}
}
}
+306
View File
@@ -0,0 +1,306 @@
{
"security": [],
"performance": [
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/api/src/ws-commands.ts",
"line": 221,
"snippet": "console.log(`[compose] ${stderr.slice(-200)}`);"
},
{
"severity": "P2",
"pattern": "console.log in production",
"file": "apps/worker/src/index.ts",
"line": 57,
"snippet": "console.log(`[${ts}] ${msg}`);"
}
],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 616,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 573,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 420,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 330,
"sizeKB": 12,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 212,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 44,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 2
},
"tsErrors": 0,
"largeFiles": 10,
"mediumFiles": 8,
"deps": 2
}
}
}
+291
View File
@@ -0,0 +1,291 @@
{
"security": [],
"performance": [],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 616,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 573,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 420,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 330,
"sizeKB": 12,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 212,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 10,
"mediumFiles": 8,
"deps": 2
}
}
}
+317
View File
@@ -0,0 +1,317 @@
{
"security": [],
"performance": [
{
"severity": "P2",
"pattern": "JSON.parse without try/catch",
"file": "apps/api/src/ws-chat-smoke.test.ts",
"line": 82,
"snippet": "const body = typeof init?.body === \"string\" ? JSON.parse(init.body) : {};"
}
],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 617,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 421,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 372,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-conversation-router.ts",
"lines": 355,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 330,
"sizeKB": 12,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/api/src/ws-conversation-router.test.ts",
"lines": 242,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 203,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "@discordjs/voice",
"current": "0.19.1",
"wanted": "0.19.2",
"latest": "0.19.2"
},
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 42,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 1
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
}
}
+309
View File
@@ -0,0 +1,309 @@
{
"security": [],
"performance": [],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 617,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 421,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 372,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-conversation-router.ts",
"lines": 355,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 330,
"sizeKB": 12,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/api/src/ws-conversation-router.test.ts",
"lines": 242,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 203,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "@discordjs/voice",
"current": "0.19.1",
"wanted": "0.19.2",
"latest": "0.19.2"
},
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
}
}
+309
View File
@@ -0,0 +1,309 @@
{
"security": [],
"performance": [],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 617,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 421,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 375,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-conversation-router.ts",
"lines": 355,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 330,
"sizeKB": 12,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/api/src/ws-conversation-router.test.ts",
"lines": 242,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 203,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "@discordjs/voice",
"current": "0.19.1",
"wanted": "0.19.2",
"latest": "0.19.2"
},
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
}
}
+309
View File
@@ -0,0 +1,309 @@
{
"security": [],
"performance": [],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 617,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 421,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 375,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-conversation-router.ts",
"lines": 355,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 330,
"sizeKB": 12,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/api/src/ws-conversation-router.test.ts",
"lines": 242,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 203,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "@discordjs/voice",
"current": "0.19.1",
"wanted": "0.19.2",
"latest": "0.19.2"
},
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
}
}
+309
View File
@@ -0,0 +1,309 @@
{
"security": [],
"performance": [],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 617,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 421,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 375,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-conversation-router.ts",
"lines": 355,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 330,
"sizeKB": 12,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/api/src/ws-conversation-router.test.ts",
"lines": 242,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 203,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "@discordjs/voice",
"current": "0.19.1",
"wanted": "0.19.2",
"latest": "0.19.2"
},
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
}
}
+309
View File
@@ -0,0 +1,309 @@
{
"security": [],
"performance": [],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 617,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 421,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 375,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-conversation-router.ts",
"lines": 355,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 323,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/api/src/ws-conversation-router.test.ts",
"lines": 242,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 203,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "@discordjs/voice",
"current": "0.19.1",
"wanted": "0.19.2",
"latest": "0.19.2"
},
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "fail",
"errors": 1,
"detail": "src/ws-multimodal.ts(79,24): error TS2322: Type 'string | null' is not assignable to type 'string'.\n Type 'null' is not assignable to type 'string'."
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 45,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 1,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
}
}
+309
View File
@@ -0,0 +1,309 @@
{
"security": [],
"performance": [],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 617,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 421,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 375,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-conversation-router.ts",
"lines": 355,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 323,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/api/src/ws-conversation-router.test.ts",
"lines": 242,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 203,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "@discordjs/voice",
"current": "0.19.1",
"wanted": "0.19.2",
"latest": "0.19.2"
},
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "fail",
"errors": 1,
"detail": "src/ws-multimodal.ts(79,24): error TS2322: Type 'string | null' is not assignable to type 'string'.\n Type 'null' is not assignable to type 'string'."
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 45,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 1,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
}
}
+309
View File
@@ -0,0 +1,309 @@
{
"security": [],
"performance": [],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 617,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 421,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 375,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-conversation-router.ts",
"lines": 355,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 323,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/api/src/ws-conversation-router.test.ts",
"lines": 242,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 203,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "@discordjs/voice",
"current": "0.19.1",
"wanted": "0.19.2",
"latest": "0.19.2"
},
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
}
}
+309
View File
@@ -0,0 +1,309 @@
{
"security": [],
"performance": [],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 617,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 421,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 375,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-conversation-router.ts",
"lines": 355,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 323,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/api/src/ws-conversation-router.test.ts",
"lines": 242,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 203,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "@discordjs/voice",
"current": "0.19.1",
"wanted": "0.19.2",
"latest": "0.19.2"
},
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
}
}
+309
View File
@@ -0,0 +1,309 @@
{
"security": [],
"performance": [],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 617,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 452,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 421,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 375,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-conversation-router.ts",
"lines": 355,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 323,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/api/src/ws-conversation-router.test.ts",
"lines": 242,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 203,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "@discordjs/voice",
"current": "0.19.1",
"wanted": "0.19.2",
"latest": "0.19.2"
},
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
}
}
+309
View File
@@ -0,0 +1,309 @@
{
"security": [],
"performance": [],
"metrics": [
{
"file": "packages/persona-domain/src/index.ts",
"lines": 787,
"sizeKB": 22,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.ts",
"lines": 717,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.test.ts",
"lines": 670,
"sizeKB": 24,
"flag": "large"
},
{
"file": "packages/node-engine/src/index.test.ts",
"lines": 651,
"sizeKB": 20,
"flag": "large"
},
{
"file": "apps/web/src/components/Chat.tsx",
"lines": 617,
"sizeKB": 21,
"flag": "large"
},
{
"file": "apps/web/src/components/ChatHistory.tsx",
"lines": 602,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/worker/src/worker-runtime.ts",
"lines": 565,
"sizeKB": 18,
"flag": "large"
},
{
"file": "packages/storage/src/index.ts",
"lines": 551,
"sizeKB": 18,
"flag": "large"
},
{
"file": "apps/api/src/app.ts",
"lines": 536,
"sizeKB": 17,
"flag": "large"
},
{
"file": "apps/api/src/app.test.ts",
"lines": 493,
"sizeKB": 17,
"flag": "medium"
},
{
"file": "apps/api/src/context-store.ts",
"lines": 422,
"sizeKB": 15,
"flag": "medium"
},
{
"file": "apps/web/src/components/VoiceChat.tsx",
"lines": 421,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/personas-default.ts",
"lines": 418,
"sizeKB": 20,
"flag": "medium"
},
{
"file": "apps/web/src/components/NodeEditor.tsx",
"lines": 407,
"sizeKB": 14,
"flag": "medium"
},
{
"file": "apps/api/src/ws-chat.ts",
"lines": 375,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-conversation-router.ts",
"lines": 355,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/web/src/api.ts",
"lines": 328,
"sizeKB": 9,
"flag": "medium"
},
{
"file": "apps/api/src/routes/personas.ts",
"lines": 323,
"sizeKB": 11,
"flag": "medium"
},
{
"file": "apps/api/src/ws-commands.ts",
"lines": 316,
"sizeKB": 10,
"flag": "medium"
},
{
"file": "apps/api/src/ws-ollama.ts",
"lines": 290,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.test.ts",
"lines": 279,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaDetail.tsx",
"lines": 278,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/api/src/routes/chat-history.ts",
"lines": 275,
"sizeKB": 10,
"flag": "ok"
},
{
"file": "packages/chat-domain/src/index.ts",
"lines": 263,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/persona-domain/src/index.test.ts",
"lines": 260,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/components/Collectif.tsx",
"lines": 252,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "packages/node-engine/src/training.ts",
"lines": 250,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/PersonaList.tsx",
"lines": 243,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/api/src/ws-conversation-router.test.ts",
"lines": 242,
"sizeKB": 9,
"flag": "ok"
},
{
"file": "apps/web/src/App.tsx",
"lines": 236,
"sizeKB": 8,
"flag": "ok"
},
{
"file": "apps/worker/src/worker-runtime.test.ts",
"lines": 231,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/api/src/routes/session.ts",
"lines": 208,
"sizeKB": 7,
"flag": "ok"
},
{
"file": "apps/web/src/components/TrainingDashboard.tsx",
"lines": 203,
"sizeKB": 7,
"flag": "ok"
}
],
"deps": [
{
"package": "@discordjs/voice",
"current": "0.19.1",
"wanted": "0.19.2",
"latest": "0.19.2"
},
{
"package": "express"
},
{
"package": "pdf-parse",
"current": "1.1.4",
"wanted": "1.1.4",
"latest": "2.4.5"
}
],
"typescript": [
{
"dir": "apps/api",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/web",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "apps/worker",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/core",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/auth",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/chat-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/persona-domain",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/node-engine",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/storage",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/ui",
"status": "ok",
"errors": 0,
"detail": ""
},
{
"dir": "packages/tui",
"status": "ok",
"errors": 0,
"detail": ""
}
],
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
}
}
@@ -0,0 +1,72 @@
{
"timestamp": "20260317-212918",
"generated_at": "2026-03-17T20:29:31Z",
"files": {
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-212918.json",
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-212918.log",
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-212918.log",
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-212918.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"counts": {
"security": 0,
"performance": 2,
"large_files": 10
},
"debt": {
"score": 44,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 2
},
"tsErrors": 0,
"largeFiles": 10,
"mediumFiles": 8,
"deps": 2
}
},
"hotspots": [
"packages/persona-domain/src/index.ts",
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx"
],
"open_findings": [
{
"severity": "P2",
"file": "apps/api/src/ws-commands.ts",
"line": 221,
"pattern": "console.log in production"
},
{
"severity": "P2",
"file": "apps/worker/src/index.ts",
"line": 57,
"pattern": "console.log in production"
}
],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "77d53143f3bb6315a9d8b7d4eb0f2a25f5fa1e126e9a23b14fb855e587793bd7",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "9f6ed983113a397e11cacd5a7c04e25571967eb570036b8171928eb252969a2b"
}
}
@@ -0,0 +1,59 @@
{
"timestamp": "20260317-213137",
"generated_at": "2026-03-17T20:31:50Z",
"files": {
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-213137.json",
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-213137.log",
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-213137.log",
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-213137.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"counts": {
"security": 0,
"performance": 0,
"large_files": 10
},
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 10,
"mediumFiles": 8,
"deps": 2
}
},
"hotspots": [
"packages/persona-domain/src/index.ts",
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx"
],
"open_findings": [],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "98de552279816ebd6143b0b03b320fd85e5359db6af6832ded8040624d4f81d0",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "37da49bbbadcc5ccf6f2818169127ea142b86263db18246dc7502db796e40ac8"
}
}
@@ -0,0 +1,72 @@
{
"timestamp": "20260317-214953",
"generated_at": "2026-03-17T20:50:06Z",
"files": {
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-214953.json",
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-214953.log",
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-214953.log",
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-214953.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-214953.json",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-214953.log",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-214953.log"
],
"counts": {
"security": 0,
"performance": 1,
"large_files": 9
},
"debt": {
"score": 42,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 1
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
},
"hotspots": [
"packages/persona-domain/src/index.ts",
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx"
],
"open_findings": [
{
"severity": "P2",
"file": "apps/api/src/ws-chat-smoke.test.ts",
"line": 82,
"pattern": "JSON.parse without try/catch"
}
],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/ui-contract",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "ef7decd9bfc82325db46ba727791ea82ca23abacc25a4c5309ce83385ee379b7",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "a1d9151d5c607a75a294d95d8df48bcd6dc719ebdf8c68682a88090d3483eaec"
}
}
@@ -0,0 +1,65 @@
{
"timestamp": "20260317-215042",
"generated_at": "2026-03-17T20:50:55Z",
"files": {
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-215042.json",
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-215042.log",
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-215042.log",
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-215042.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-215042.json",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-215042.log",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-215042.log"
],
"counts": {
"security": 0,
"performance": 0,
"large_files": 9
},
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
},
"hotspots": [
"packages/persona-domain/src/index.ts",
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx"
],
"open_findings": [],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/ui-contract",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "051b739fd91f9e33923d9a65403a6a8f33870e8e9c9b3d128f6c3fd8a56184c2",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "5a556c1d63e4a10a8d4e1c6acebd6d953217b4c8954d1d5652b6f9379e09f3b2"
}
}
@@ -0,0 +1,65 @@
{
"timestamp": "20260317-215230",
"generated_at": "2026-03-17T20:52:43Z",
"files": {
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-215230.json",
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-215230.log",
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-215230.log",
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-215230.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-215230.json",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-215230.log",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-215230.log"
],
"counts": {
"security": 0,
"performance": 0,
"large_files": 9
},
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
},
"hotspots": [
"packages/persona-domain/src/index.ts",
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx"
],
"open_findings": [],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/ui-contract",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "17eb052a14022141ae0ac300a51e7466686bd48cec717995f4e127af36553d1b",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "0be1c56be0c436e44f739fe117793baf7b9b760c4efc93d2612253b3b8e5ce68"
}
}
@@ -0,0 +1,65 @@
{
"timestamp": "20260317-220132",
"generated_at": "2026-03-17T21:01:46Z",
"files": {
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-220132.json",
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-220132.log",
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-220132.log",
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-220132.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-220132.json",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-220132.log",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-220132.log"
],
"counts": {
"security": 0,
"performance": 0,
"large_files": 9
},
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
},
"hotspots": [
"packages/persona-domain/src/index.ts",
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx"
],
"open_findings": [],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/ui-contract",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "17eb052a14022141ae0ac300a51e7466686bd48cec717995f4e127af36553d1b",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "6fa8efd92491cdb9dea87fe0d31662c2ed53bb70cb78d61943901efd872db1fb"
}
}
@@ -0,0 +1,65 @@
{
"timestamp": "20260317-221553",
"generated_at": "2026-03-17T21:16:12Z",
"files": {
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-221553.json",
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-221553.log",
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-221553.log",
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-221553.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-221553.json",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-221553.log",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-221553.log"
],
"counts": {
"security": 0,
"performance": 0,
"large_files": 9
},
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
},
"hotspots": [
"packages/persona-domain/src/index.ts",
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx"
],
"open_findings": [],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/ui-contract",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "17eb052a14022141ae0ac300a51e7466686bd48cec717995f4e127af36553d1b",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "f30163615c053c34250dd5b59f8348878ee4e2bf7adac0547a06fcb529afec6c"
}
}
@@ -0,0 +1,65 @@
{
"timestamp": "20260317-222432",
"generated_at": "2026-03-17T21:24:53Z",
"files": {
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-222432.json",
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-222432.log",
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-222432.log",
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-222432.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-222432.json",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-222432.log",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-222432.log"
],
"counts": {
"security": 0,
"performance": 0,
"large_files": 9
},
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
},
"hotspots": [
"packages/persona-domain/src/index.ts",
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx"
],
"open_findings": [],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/ui-contract",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "6feb48cd179cfedbf33fc90f6a25ee9d7b2f6bb979e2f5be70d8390554eac17a",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "226f2c6b4312d964a2d7f70d9f2ec562c22c5f55a89de4236e6db879316a6253"
}
}
@@ -0,0 +1,65 @@
{
"timestamp": "20260317-222623",
"generated_at": "2026-03-17T21:26:36Z",
"files": {
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-222623.json",
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-222623.log",
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-222623.log",
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-222623.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-222623.json",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-222623.log",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-222623.log"
],
"counts": {
"security": 0,
"performance": 0,
"large_files": 9
},
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
},
"hotspots": [
"packages/persona-domain/src/index.ts",
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx"
],
"open_findings": [],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/ui-contract",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "6feb48cd179cfedbf33fc90f6a25ee9d7b2f6bb979e2f5be70d8390554eac17a",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "b5d3c9176eda959317a147cb2340a64a37225e5b5c5df1f7a1ff511bc6415326"
}
}
@@ -0,0 +1,65 @@
{
"timestamp": "20260317-223122",
"generated_at": "2026-03-17T21:32:41Z",
"files": {
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-223122.json",
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-223122.log",
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-223122.log",
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-223122.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-223122.json",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-223122.log",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-223122.log"
],
"counts": {
"security": 0,
"performance": 0,
"large_files": 9
},
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
},
"hotspots": [
"packages/persona-domain/src/index.ts",
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx"
],
"open_findings": [],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/ui-contract",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "6feb48cd179cfedbf33fc90f6a25ee9d7b2f6bb979e2f5be70d8390554eac17a",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "9a90dcde00268c0fb47ede934dd6f3ce596901f8bde0f976106e95b226db9afe"
}
}
@@ -0,0 +1,65 @@
{
"timestamp": "20260317-224958",
"generated_at": "2026-03-17T21:50:10Z",
"files": {
"audit": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-224958.json",
"check": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-224958.log",
"test": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-224958.log",
"summary": "/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/outputs/deep-cycles/summary-20260317-224958.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/deep-audit-20260317-224958.json",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/check-v2-20260317-224958.log",
"/Users/electron/Documents/Projets_Creatifs/kxkm_clown/ops/v2/logs/test-v2-20260317-224958.log"
],
"counts": {
"security": 0,
"performance": 0,
"large_files": 9
},
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
},
"hotspots": [
"packages/persona-domain/src/index.ts",
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx"
],
"open_findings": [],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/ui-contract",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "f407098d6b9eefa41e87481fce8160d6c61a56c07b52ffc4ec2cd676aad1dda5",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "728b305c4d165132e9f34b52b32a9eeab9480970dba9de96ef76cc49f3c51b59"
}
}
@@ -0,0 +1,65 @@
{
"timestamp": "20260317-230624",
"generated_at": "2026-03-17T22:06:31Z",
"files": {
"audit": "/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230624.json",
"check": "/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230624.log",
"test": "/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230624.log",
"summary": "/home/kxkm/KXKM_Clown/ops/v2/outputs/deep-cycles/summary-20260317-230624.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-230624.json",
"/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-230624.log",
"/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-230624.log"
],
"counts": {
"security": 0,
"performance": 0,
"large_files": 9
},
"debt": {
"score": 40,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 9,
"mediumFiles": 10,
"deps": 3
}
},
"hotspots": [
"packages/persona-domain/src/index.ts",
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx"
],
"open_findings": [],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/ui-contract",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "f407098d6b9eefa41e87481fce8160d6c61a56c07b52ffc4ec2cd676aad1dda5",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "7b4a779c787c08a64f3e9c9de7e65b4e50583d8dfde4da4cb33313f9165ec7eb"
}
}
@@ -0,0 +1,65 @@
{
"timestamp": "20260317-231254",
"generated_at": "2026-03-17T22:13:03Z",
"files": {
"audit": "/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-231254.json",
"check": "/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-231254.log",
"test": "/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-231254.log",
"summary": "/home/kxkm/KXKM_Clown/ops/v2/outputs/deep-cycles/summary-20260317-231254.md"
},
"checks": {
"check:v2": "ok",
"test:v2": "ok"
},
"retained_logs": [
"/home/kxkm/KXKM_Clown/ops/v2/logs/deep-audit-20260317-231254.json",
"/home/kxkm/KXKM_Clown/ops/v2/logs/check-v2-20260317-231254.log",
"/home/kxkm/KXKM_Clown/ops/v2/logs/test-v2-20260317-231254.log"
],
"counts": {
"security": 0,
"performance": 0,
"large_files": 8
},
"debt": {
"score": 38,
"level": "medium",
"components": {
"security": {
"P0": 0,
"P1": 0,
"P2": 0
},
"performance": {
"P0": 0,
"P1": 0,
"P2": 0
},
"tsErrors": 0,
"largeFiles": 8,
"mediumFiles": 11,
"deps": 3
}
},
"hotspots": [
"packages/node-engine/src/index.ts",
"packages/storage/src/index.test.ts",
"packages/node-engine/src/index.test.ts",
"apps/web/src/components/Chat.tsx",
"apps/web/src/components/ChatHistory.tsx"
],
"open_findings": [],
"backlog_links": [
"lot-12-deep-audit/pipeline-canonique",
"lot-12-deep-audit/deep-cycle-operator",
"lot-12-deep-audit/ws-chat-seams",
"lot-12-deep-audit/ui-contract",
"lot-12-deep-audit/web-shell-convergence"
],
"retention_days": 7,
"hashes": {
"audit_sha256": "e6ccf31c539079d2d9b75f8486dbc3d6e8515f1de0024266acded0e414cb2c46",
"check_sha256": "5c767e1d04becf6a85d57d3ef05b5625120a47ee1fc7d1d0332bfb9c0aab83c4",
"test_sha256": "7d564c8c3c34d7757f09f8c4eed722095ace379bfb80d158e28df56959866a83"
}
}
@@ -0,0 +1,20 @@
# Deep Cycle Summary 20260317-212918
- Generated: 2026-03-17T20:29:31Z
- Retention policy: 7 days
- check:v2: ok
- test:v2: ok
- security findings: 0
- performance findings: 2
- debt score: 44/100 (medium)
## Hotspots
- packages/persona-domain/src/index.ts
- packages/node-engine/src/index.ts
- packages/storage/src/index.test.ts
- packages/node-engine/src/index.test.ts
- apps/web/src/components/Chat.tsx
## Open Findings
- P2 apps/api/src/ws-commands.ts:221 — console.log in production
- P2 apps/worker/src/index.ts:57 — console.log in production
@@ -0,0 +1,19 @@
# Deep Cycle Summary 20260317-213137
- Generated: 2026-03-17T20:31:50Z
- Retention policy: 7 days
- check:v2: ok
- test:v2: ok
- security findings: 0
- performance findings: 0
- debt score: 40/100 (medium)
## Hotspots
- packages/persona-domain/src/index.ts
- packages/node-engine/src/index.ts
- packages/storage/src/index.test.ts
- packages/node-engine/src/index.test.ts
- apps/web/src/components/Chat.tsx
## Open Findings
- none
@@ -0,0 +1,19 @@
# Deep Cycle Summary 20260317-214953
- Generated: 2026-03-17T20:50:06Z
- Retention policy: 7 days
- check:v2: ok
- test:v2: ok
- security findings: 0
- performance findings: 1
- debt score: 42/100 (medium)
## Hotspots
- packages/persona-domain/src/index.ts
- packages/node-engine/src/index.ts
- packages/storage/src/index.test.ts
- packages/node-engine/src/index.test.ts
- apps/web/src/components/Chat.tsx
## Open Findings
- P2 apps/api/src/ws-chat-smoke.test.ts:82 — JSON.parse without try/catch

Some files were not shown because too many files have changed in this diff Show More