lot-11-E: deep analyse, corrections P0/P1, docs refonte, veille OSS
## Corrections P0 (critiques) - node-engine-runner.js: JSON.parse try-catch sur JSONL (skip lignes invalides) - storage.js: write queue par clé (Promise chain) anti-race condition - ollama.js: cleanup event listener dans tous les chemins (success/error/abort) ## Corrections P1 (importants) - server.js: nodeEngineQueue.stop() dans gracefulShutdown - chat-routing.js: hard cap 500 entrées userRateLimits (LRU eviction) - commands.js + http-api.js: timingSafeEqual pour ADMIN_TOKEN ## Documentation mise à jour - ARCHITECTURE.md: RBAC ops→operator, mermaid persona state machine - FEATURE_MAP.md: historique messages + DOM pruning marqués OK - NODE_ENGINE_ARCHITECTURE.md: training adapters opérationnels (TRL/Unsloth) - SPEC.md: table commandes slash (11 commandes) - PROJECT_MEMORY.md: référence manifeste culturel - OSS_WATCH: veille enrichie (20+ projets: Dify, CrewAI, Drizzle, vLLM, MCP...) - PLAN.md: Phase C/D complétées, Phase E en cours ## Autoresearch (nouveau) - scripts/v2-autoresearch-loop.js + config exemple + documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -171,7 +171,8 @@ Corrigé:
|
||||
- P1 BUG-02: Timeout promise leak (AbortSignal cancel)
|
||||
- P1 SEC-03: Attachment endpoints unauthenticated (requireAdminNetwork)
|
||||
|
||||
### Phase C — Feature parity V2 `[en cours — 8/10 fait]`
|
||||
### Phase C — Feature parity V2 `[complété — 10/10]`
|
||||
|
||||
Livré:
|
||||
- Recovery on crash (worker recoverStaleRuns + shouldCancel)
|
||||
- Cancel support (requestCancel repo + API endpoint + worker callback)
|
||||
@@ -181,11 +182,43 @@ Livré:
|
||||
- Subnet gate V2 (CIDR middleware ADMIN_SUBNET)
|
||||
- Retention sweep V2 (deleteOlderThan + POST /api/v2/admin/retention-sweep)
|
||||
- Export HTML V2 (GET /api/v2/export/html)
|
||||
|
||||
- Upload fichiers V2 (bouton upload base64 Chat.tsx)
|
||||
- Tab completion chat V2 (nicks + slash commands, Tab cycling)
|
||||
- Historique messages V2 (ArrowUp/Down, 100 items max)
|
||||
- DOM pruning V2 (élagage automatique à 500 messages)
|
||||
|
||||
### Phase D — Optimisation & polish
|
||||
- [ ] Docker / docker-compose pour déploiement
|
||||
- [ ] Documentation utilisateur
|
||||
- [ ] Performance profiling hot paths
|
||||
### Phase D — Déploiement & polish `[complété]`
|
||||
|
||||
Livré:
|
||||
- Docker Compose reconfiguré (Ollama natif via extra_hosts, profils v1/v2/ollama)
|
||||
- .env.example avec toutes les variables documentées
|
||||
- .gitignore sécurisé (protection .env)
|
||||
- Déploiement V2 sur kxkm-ai (API healthy port 4180, worker actif, Postgres container)
|
||||
- README complet (démarrage dev/Docker, admin, architecture, variables)
|
||||
|
||||
### Phase E — Refonte globale `[en cours]`
|
||||
|
||||
#### E.1 — Deep analyse code V1+V2
|
||||
|
||||
- [x] Analyse systématique (25 findings: 5 P0, 10 P1, 10 P2)
|
||||
- [ ] Corrections P0 chirurgicales (JSON crash, storage race, ollama leak)
|
||||
- [ ] Corrections P1 chirurgicales (shutdown, rate limits, timingSafeEqual)
|
||||
|
||||
#### E.2 — Veille OSS mise à jour
|
||||
|
||||
- [x] Recherche web complète (20+ projets analysés)
|
||||
- [x] docs/OSS_WATCH_2026-03-16.md enrichi (chat UI, orchestration, training, libs)
|
||||
|
||||
#### E.3 — Documentation & specs
|
||||
|
||||
- [ ] Mermaid persona editorial state machine ajouté
|
||||
- [ ] FEATURE_MAP.md matrice de parité mise à jour
|
||||
- [ ] ARCHITECTURE.md RBAC terminology fix (ops → operator)
|
||||
- [ ] NODE_ENGINE_ARCHITECTURE.md training status mis à jour
|
||||
- [ ] SPEC.md table commandes slash ajoutée
|
||||
- [ ] PROJECT_MEMORY.md référence manifeste ajoutée
|
||||
|
||||
#### E.4 — Redéploiement
|
||||
|
||||
- [ ] Commit corrections + docs
|
||||
- [ ] Push et redéployer sur kxkm-ai
|
||||
|
||||
@@ -294,6 +294,16 @@ function createChatRouter({
|
||||
}
|
||||
}
|
||||
|
||||
// Hard cap: if still above 500 after stale pruning, evict oldest entries
|
||||
if (userRateLimits.size > 500) {
|
||||
const sorted = [...userRateLimits.entries()]
|
||||
.sort((a, b) => a[1].windowStart - b[1].windowStart);
|
||||
const toRemove = sorted.slice(0, userRateLimits.size - 500);
|
||||
for (const [key] of toRemove) {
|
||||
userRateLimits.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
bucket.count++;
|
||||
return bucket.count <= RATE_LIMIT_MAX;
|
||||
}
|
||||
|
||||
+10
-1
@@ -1,3 +1,12 @@
|
||||
const { timingSafeEqual } = require("crypto");
|
||||
function safeCompare(a, b) {
|
||||
if (typeof a !== "string" || typeof b !== "string") return false;
|
||||
const bufA = Buffer.from(a);
|
||||
const bufB = Buffer.from(b);
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
function createCommandHandler({
|
||||
adminBootstrapToken,
|
||||
admins,
|
||||
@@ -884,7 +893,7 @@ function createCommandHandler({
|
||||
if (typeof isAdminNetworkAllowed === "function" && !isAdminNetworkAllowed(info.clientIp)) {
|
||||
return send(ws, "system", "*** Bootstrap admin refuse depuis ce reseau");
|
||||
}
|
||||
if (args[0] !== adminBootstrapToken) {
|
||||
if (!safeCompare(args[0], adminBootstrapToken)) {
|
||||
return send(ws, "system", "*** Token admin invalide");
|
||||
}
|
||||
claimOwnerNick(ws, info);
|
||||
|
||||
+17
-1
@@ -247,6 +247,22 @@ flowchart LR
|
||||
Proposals -- "revert" --> Overrides
|
||||
```
|
||||
|
||||
### 4.4 Persona editorial state machine
|
||||
|
||||
Pipeline editorial de renforcement des personas via Pharmacius.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> idle
|
||||
idle --> collecting : feedback reçu
|
||||
collecting --> generating : seuil atteint
|
||||
generating --> review : proposals générées
|
||||
review --> applied : admin approuve
|
||||
review --> reverted : admin rejette
|
||||
applied --> idle : cycle terminé
|
||||
reverted --> idle : cycle terminé
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Stockage
|
||||
@@ -312,7 +328,7 @@ Le systeme distingue quatre niveaux de privileges, configures via `config.js` :
|
||||
| Role | Description | Source de configuration |
|
||||
|------------|------------------------------------------------|----------------------------|
|
||||
| `admin` | Acces complet, gestion personas et node-engine | `ADMINS` (liste de nicks) |
|
||||
| `ops` | Operations, monitoring, acces TUI | `OPS` (liste de nicks) |
|
||||
| `operator` | Operations, monitoring, acces TUI | `OPS` (liste de nicks) |
|
||||
| `editor` | Modification personas (via admin UI) | Session admin authentifiee |
|
||||
| `viewer` | Chat public, lecture seule | Tout client connecte |
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Autoresearch Mode V2
|
||||
|
||||
Ce document decrit une integration minimale de la logique autoresearch dans KXKM_Clown V2.
|
||||
|
||||
## Objectif
|
||||
|
||||
Automatiser des cycles d'experiences Node Engine avec un budget fixe par run, puis appliquer une decision keep/discard basee sur une politique de score deterministe.
|
||||
|
||||
Le mode actuel automatise l'orchestration des runs et leur selection. Il ne modifie pas le code des nodes.
|
||||
|
||||
## Ce qui est implemente
|
||||
|
||||
- script: scripts/v2-autoresearch-loop.js
|
||||
- config exemple: ops/v2/autoresearch.example.json
|
||||
- sortie TSV append-only: data/node-engine/autoresearch/results.tsv
|
||||
|
||||
Boucle executee:
|
||||
1. creer un run queued sur un graph existant
|
||||
2. attendre un statut terminal
|
||||
3. calculer un score
|
||||
4. marquer keep/discard par rapport au meilleur score courant
|
||||
5. journaliser la ligne dans results.tsv
|
||||
|
||||
## Prerequis
|
||||
|
||||
- Postgres accessible via DATABASE_URL
|
||||
- migrations V2 executees (tables node_graphs et node_runs)
|
||||
- worker V2 actif (npm run dev:v2:worker) pour consommer la queue
|
||||
- un graph existant dans node_graphs
|
||||
|
||||
## Utilisation
|
||||
|
||||
1. definir graphId dans ops/v2/autoresearch.example.json
|
||||
2. lancer:
|
||||
|
||||
```bash
|
||||
npm run v2:autoresearch
|
||||
```
|
||||
|
||||
Execution unique:
|
||||
|
||||
```bash
|
||||
node scripts/v2-autoresearch-loop.js --config ops/v2/autoresearch.example.json --once
|
||||
```
|
||||
|
||||
## Politique de score
|
||||
|
||||
Le score par defaut est derive du statut terminal, avec bonus de vitesse pour les runs completes:
|
||||
|
||||
- completed: 1 + bonus
|
||||
- failed: 0
|
||||
- cancelled, blocked, not_configured: -1
|
||||
|
||||
La table statusScores du JSON permet d'ajuster le comportement sans changer le script.
|
||||
|
||||
## Limites actuelles
|
||||
|
||||
- pas encore de metriques metier (qualite persona, cout tokens, latence p95)
|
||||
- keep/discard est une decision de session, pas encore un alias model registry
|
||||
- pas de mutation automatique des graphes ou hyperparametres
|
||||
|
||||
## Etape suivante recommandee
|
||||
|
||||
Ajouter un node d'evaluation canonique qui produit un score metier dans un artefact persiste, puis brancher ce score comme source principale de decision dans la boucle autoresearch.
|
||||
+2
-2
@@ -153,8 +153,8 @@ flowchart TD
|
||||
| Multi-canaux | OK | OK | haute |
|
||||
| Streaming LLM | OK | OK | haute |
|
||||
| Tab completion | OK | OK | moyenne |
|
||||
| Historique messages | OK | prevu | basse |
|
||||
| DOM pruning | OK | prevu | basse |
|
||||
| Historique messages | OK | OK | basse |
|
||||
| DOM pruning | OK | OK | basse |
|
||||
| Upload fichiers | OK | OK | moyenne |
|
||||
| Commandes slash | OK | OK | moyenne |
|
||||
| **Admin** | | | |
|
||||
|
||||
@@ -153,9 +153,8 @@ Règles:
|
||||
- exécution réelle des nodes dataset/processing/evaluation/registry/deploy
|
||||
- queue persistée avec reprise automatique des runs `queued/running`
|
||||
- annulation coopérative au step boundary
|
||||
- training via adaptateurs externes ou statut `not_configured`
|
||||
- training via adaptateurs réels TRL + Unsloth (Lot 10) — opérationnel
|
||||
- persistance des runs, étapes, artefacts et modèles enregistrés
|
||||
- prochaine étape: adaptateurs training réels et runtimes distants branchés
|
||||
|
||||
## Intégration V2
|
||||
|
||||
@@ -279,10 +278,11 @@ Invariants:
|
||||
- statuts `queued`, `running`, `completed`, `failed`
|
||||
|
||||
État:
|
||||
- partiellement livré
|
||||
- livré
|
||||
- runner local réel, queue async persistée et runtimes déclarés en place
|
||||
- training encore dépendant d'adaptateurs externes
|
||||
- adaptateurs training réels TRL + Unsloth livrés (Lot 10)
|
||||
- asynchronie de base livrée; orchestration avancée encore ouverte
|
||||
- runtimes `remote_gpu`, `cluster`, `cloud_api` déclarés mais pas encore opérationnels
|
||||
|
||||
### V3 — Runtimes distants et déploiement
|
||||
- `remote_gpu`, `cluster`, `cloud_api`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Scope
|
||||
|
||||
Veille ciblee sur des projets open source proches de KXKM_Clown (chat local LLM, orchestration DAG, editeur nodal, queue jobs, inference locale).
|
||||
Veille ciblee sur des projets open source proches de KXKM_Clown (chat local LLM, orchestration DAG, editeur nodal, queue jobs, inference locale, training, persona/agent management).
|
||||
|
||||
## Sources
|
||||
|
||||
@@ -12,21 +12,93 @@ Veille ciblee sur des projets open source proches de KXKM_Clown (chat local LLM,
|
||||
- https://github.com/FlowiseAI/Flowise
|
||||
- https://github.com/mudler/LocalAI
|
||||
- https://nodered.org/
|
||||
- https://github.com/open-webui/open-webui
|
||||
- https://github.com/danny-avila/LibreChat
|
||||
- https://github.com/lobehub/lobe-chat
|
||||
- https://github.com/langgenius/dify
|
||||
- https://github.com/langchain-ai/langgraph
|
||||
- https://github.com/langflow-ai/langflow
|
||||
- https://github.com/crewAIInc/crewAI
|
||||
- https://github.com/microsoft/autogen
|
||||
- https://github.com/unslothai/unsloth
|
||||
- https://github.com/axolotl-ai-cloud/axolotl
|
||||
- https://github.com/hiyouga/LLaMA-Factory
|
||||
- https://github.com/drizzle-team/drizzle-orm
|
||||
- https://github.com/vllm-project/vllm
|
||||
- https://modelcontextprotocol.io/
|
||||
|
||||
## Findings
|
||||
|
||||
### Chat UI & Multi-Persona
|
||||
|
||||
| Projet | Signal observe | Usage potentiel |
|
||||
|---|---|---|
|
||||
| ollama-js | release stable v0.6.3, streaming AsyncGenerator, tools/embed/abort | deja aligne avec le repo, a conserver |
|
||||
| BullMQ | v5.71.0, parent-child flow, rate-limits, retries, ecosystem mature | candidat pour scaling queue Node Engine |
|
||||
| Rete.js | v2.0.6, dataflow + control flow | candidat si NodeEditor devient plus complexe |
|
||||
| Flowise | v3.0.13, UX agent/workflow builder mature | source d inspiration UX produit |
|
||||
| LocalAI | v4.0.0 recent, multimodal + API drop-in style OpenAI | plan B/extension multimodale locale |
|
||||
| Node-RED | v4.1.7, patterns event-driven industrialises | patterns d orchestration/reliability |
|
||||
| --- | --- | --- |
|
||||
| Open WebUI | 126K stars, RLHF annotation, multi-user | inspiration UI/UX, feedback collection |
|
||||
| LibreChat | 34K stars, multi-provider, conversation forking | inspiration API multi-provider |
|
||||
| LobeChat | UI tres polie, animations, production-ready | reference design frontend |
|
||||
| Dify | DAG workflow + agents, 1.4M deployments, $30M funding | inspiration Node Engine + agent workflows |
|
||||
|
||||
### Orchestration DAG
|
||||
|
||||
| Projet | Signal observe | Usage potentiel |
|
||||
| --- | --- | --- |
|
||||
| ollama-js | v0.6.3 stable, streaming AsyncGenerator, tools/embed/abort | deja aligne, a conserver |
|
||||
| BullMQ | v5.71.0, parent-child flow, rate-limits, retries | candidat scaling queue Node Engine |
|
||||
| Rete.js | v2.0.6, dataflow + control flow | candidat si NodeEditor plus complexe |
|
||||
| Flowise | v3.0.13, Node.js, UX agent/workflow builder | source inspiration UX + architecture |
|
||||
| LocalAI | v4.0.0, multimodal + API OpenAI drop-in | plan B inference multimodale |
|
||||
| Node-RED | v4.1.7, patterns event-driven industrialises | patterns orchestration/reliability |
|
||||
| LangGraph | state machines, cycles, error handling | inspiration error recovery Node Engine |
|
||||
| Langflow | DAG visual, export JSON/Python, MCP deploy | inspiration UI/export workflows |
|
||||
| n8n | 34K stars, AI Agent builder, 400+ integrations | inspiration AI nodes + workflow |
|
||||
|
||||
### Persona / Agent Management
|
||||
|
||||
| Projet | Signal observe | Usage potentiel |
|
||||
| --- | --- | --- |
|
||||
| CrewAI | role-based agents, crew metaphor, task orchestration | inspiration architecture multi-persona |
|
||||
| AutoGen | Microsoft, multi-agent conversations, layered APIs | reference conversation multi-agent |
|
||||
|
||||
### Training / Fine-Tuning
|
||||
|
||||
| Projet | Signal observe | Usage potentiel |
|
||||
| --- | --- | --- |
|
||||
| TRL | HuggingFace, DPO/PPO/GRPO natif | deja dans la stack, continuer |
|
||||
| Unsloth | 2x plus rapide, 80% moins memoire, Triton | optimisation training pipeline |
|
||||
| Axolotl | config-driven, reproductibilite, DPO+SFT+PPO | complement reliability pipeline |
|
||||
| LLaMA Factory | LoRA, DPO, KTO, ORPO, UI incluse | alternative diversification training |
|
||||
|
||||
### Librairies Stack
|
||||
|
||||
| Projet | Signal observe | Usage potentiel |
|
||||
| --- | --- | --- |
|
||||
| Drizzle ORM | TypeScript-first, 10-20% overhead vs raw SQL | migration candidate vs pg brut |
|
||||
| React Flow | v12, deja utilise (@xyflow/react) | stack actuelle, conserver |
|
||||
| ws | v8.19, 17.7M users | stack actuelle, conserver |
|
||||
| uWebSockets.js | C++ core, haute concurrence | si scaling > 1000 connexions |
|
||||
| Blessed/neo-blessed | TUI ncurses en JS | stack actuelle TUI |
|
||||
| Ink | React-like TUI, JSX | alternative TUI pour nouveaux outils |
|
||||
| MCP | Anthropic, standard ouvert, SDK TypeScript | integration future personas ↔ outils |
|
||||
| vLLM | PagedAttention, 2-4x throughput | complement si multi-user concurrent |
|
||||
|
||||
## Recommandations
|
||||
|
||||
1. Court terme: garder la stack actuelle (React Flow + queue custom + ollama-js).
|
||||
2. Moyen terme: faire un spike BullMQ pour scenarios a charge/redistribution.
|
||||
3. Moyen terme: faire un spike Rete.js si besoin de control flow visuel avance.
|
||||
4. Veille continue: suivre LocalAI pour extension voix/image et fallback inference locale.
|
||||
### Court terme (maintenir)
|
||||
|
||||
1. Garder la stack actuelle (React Flow + queue custom + ollama-js + ws + blessed)
|
||||
2. Continuer TRL pour le training DPO
|
||||
|
||||
### Moyen terme (evaluer)
|
||||
|
||||
3. Spike BullMQ pour scenarios charge/redistribution queue Node Engine
|
||||
4. Spike Drizzle ORM pour remplacer les requetes pg brutes (meilleur type safety)
|
||||
5. Spike Unsloth pour accelerer le training pipeline (2x plus rapide)
|
||||
6. Etudier Axolotl pour reproductibilite des runs de training
|
||||
7. Spike Rete.js si besoin control flow visuel avance
|
||||
|
||||
### Long terme (surveiller)
|
||||
|
||||
8. Implementer MCP pour integration standardisee personas ↔ outils externes
|
||||
9. Evaluer vLLM si scaling multi-user concurrent depasse Ollama
|
||||
10. Suivre Dify pour inspiration Node Engine + workflow agent
|
||||
11. Suivre LocalAI pour extension voix/image et fallback inference locale
|
||||
|
||||
@@ -19,6 +19,13 @@ Produit prive multi-utilisateur, architecture locale, identite IRC forte, Node E
|
||||
- Validation operee: lot-2-domaines relance et passe en done
|
||||
- Hygiene appliquee: logs lot-2 analyses puis purges, outputs CSV conserves
|
||||
|
||||
## Fondation culturelle — Manifeste
|
||||
|
||||
Le projet repose sur deux fichiers manifeste qui definissent l'univers de references et les easter eggs:
|
||||
|
||||
- `data/manifeste.md` — Easter eggs, références culturelles (Hitchhiker's Guide, Blade Runner, SF, musique concrète, demoscene, funk)
|
||||
- `data/manifeste_references_nouvelles.md` — Références étendues en 9 catégories (crypto-anarchisme, afrofuturisme, noise/industrial, cyberfeminisme, situationnisme, open source, net.art, éco-anarchisme)
|
||||
|
||||
## Prochain focus
|
||||
|
||||
- lot-3-surfaces
|
||||
|
||||
@@ -61,3 +61,19 @@ sequenceDiagram
|
||||
- Pas de melange runtime editorial et exports training
|
||||
- Pas d ouverture internet par defaut
|
||||
- Toute mutation admin doit etre auditable
|
||||
|
||||
## Commandes slash
|
||||
|
||||
| Commande | Description | Admin |
|
||||
|----------|-------------|-------|
|
||||
| `/help` | Aide | non |
|
||||
| `/clear` | Effacer le chat | non |
|
||||
| `/nick` | Changer pseudo | non |
|
||||
| `/join` | Rejoindre un canal | non |
|
||||
| `/msg` | Message privé | non |
|
||||
| `/web` | Recherche web | non |
|
||||
| `/status` | Statut système | non |
|
||||
| `/model` | Changer modèle | oui |
|
||||
| `/persona` | Gérer personas | oui |
|
||||
| `/reload` | Recharger config | oui |
|
||||
| `/export` | Exporter données | oui |
|
||||
|
||||
+10
-1
@@ -1,6 +1,15 @@
|
||||
const path = require("path");
|
||||
const { timingSafeEqual } = require("crypto");
|
||||
const { MAX_UPLOAD_BYTES } = require("./attachment-pipeline");
|
||||
|
||||
function safeCompare(a, b) {
|
||||
if (typeof a !== "string" || typeof b !== "string") return false;
|
||||
const bufA = Buffer.from(a);
|
||||
const bufB = Buffer.from(b);
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
function registerApiRoutes(app, {
|
||||
adminBootstrapToken,
|
||||
host,
|
||||
@@ -274,7 +283,7 @@ function registerApiRoutes(app, {
|
||||
}
|
||||
|
||||
const token = String(req.body?.token || "").trim();
|
||||
if (token !== adminBootstrapToken) {
|
||||
if (!safeCompare(token, adminBootstrapToken)) {
|
||||
return res.status(403).json({ error: "invalid admin bootstrap token" });
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,15 @@ function readFileDataset(rootDir, inputPath) {
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
.map((line, index) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch (e) {
|
||||
console.warn(`[readFileDataset] Skipping invalid JSONL line ${index + 1}: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
} else if (format === "json") {
|
||||
const parsed = JSON.parse(text);
|
||||
if (Array.isArray(parsed)) items = parsed;
|
||||
|
||||
@@ -48,14 +48,22 @@ function createOllamaClient({
|
||||
|
||||
async function ollamaChat(model, messages, onToken, abortSignal, tokenLimit) {
|
||||
const controller = new AbortController();
|
||||
let onAbort;
|
||||
if (abortSignal) {
|
||||
const onAbort = () => controller.abort();
|
||||
onAbort = () => controller.abort();
|
||||
abortSignal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
// Global timeout: abort if chat takes too long overall
|
||||
const chatTimer = setTimeout(() => controller.abort(), OLLAMA_CHAT_TIMEOUT_MS);
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(chatTimer);
|
||||
if (abortSignal && onAbort) {
|
||||
abortSignal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
const numPredict = tokenLimit || (model.includes("mistral") ? maxResponseTokensSmall : maxResponseTokens);
|
||||
|
||||
let fullResponse = "";
|
||||
@@ -75,7 +83,7 @@ function createOllamaClient({
|
||||
if (fullResponse.length > maxResponseChars) {
|
||||
controller.abort();
|
||||
onToken("", true, null);
|
||||
clearTimeout(chatTimer);
|
||||
cleanup();
|
||||
return fullResponse.slice(0, maxResponseChars);
|
||||
}
|
||||
onToken(chunk.message.content, chunk.done || false);
|
||||
@@ -89,7 +97,7 @@ function createOllamaClient({
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
clearTimeout(chatTimer);
|
||||
cleanup();
|
||||
if (e.name === "AbortError") {
|
||||
onToken("", true, null);
|
||||
return fullResponse;
|
||||
@@ -97,7 +105,7 @@ function createOllamaClient({
|
||||
throw e;
|
||||
}
|
||||
|
||||
clearTimeout(chatTimer);
|
||||
cleanup();
|
||||
return fullResponse;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"graphId": "graph_replace_me",
|
||||
"maxExperiments": 12,
|
||||
"runTimeoutMs": 300000,
|
||||
"pollIntervalMs": 2000,
|
||||
"statusScores": {
|
||||
"completed": 1,
|
||||
"failed": 0,
|
||||
"cancelled": -1,
|
||||
"blocked": -1,
|
||||
"not_configured": -1
|
||||
},
|
||||
"outputFile": "data/node-engine/autoresearch/results.tsv",
|
||||
"tag": "persona-baseline"
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* V2 Autoresearch Loop (minimal, DB-driven)
|
||||
*
|
||||
* Runs fixed-budget experiment cycles on an existing Node Engine graph by:
|
||||
* - creating queued runs
|
||||
* - waiting for terminal status
|
||||
* - scoring outcomes with a deterministic policy
|
||||
* - tracking best candidate across the session
|
||||
*
|
||||
* This script does not mutate graph code. It automates run orchestration and
|
||||
* keep/discard decisions at the run level.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Pool } = require("pg");
|
||||
|
||||
function readArg(flag) {
|
||||
const i = process.argv.indexOf(flag);
|
||||
return i >= 0 ? process.argv[i + 1] : "";
|
||||
}
|
||||
|
||||
function hasFlag(flag) {
|
||||
return process.argv.includes(flag);
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function loadConfig(configPath) {
|
||||
const resolved = path.resolve(process.cwd(), configPath);
|
||||
const raw = fs.readFileSync(resolved, "utf8");
|
||||
const cfg = JSON.parse(raw);
|
||||
|
||||
if (!cfg || typeof cfg !== "object") {
|
||||
throw new Error("Invalid config: expected object");
|
||||
}
|
||||
if (typeof cfg.graphId !== "string" || !cfg.graphId) {
|
||||
throw new Error("Invalid config: graphId is required");
|
||||
}
|
||||
|
||||
return {
|
||||
graphId: cfg.graphId,
|
||||
maxExperiments: Number.isFinite(cfg.maxExperiments) ? cfg.maxExperiments : 12,
|
||||
runTimeoutMs: Number.isFinite(cfg.runTimeoutMs) ? cfg.runTimeoutMs : 5 * 60 * 1000,
|
||||
pollIntervalMs: Number.isFinite(cfg.pollIntervalMs) ? cfg.pollIntervalMs : 2000,
|
||||
statusScores: cfg.statusScores && typeof cfg.statusScores === "object"
|
||||
? cfg.statusScores
|
||||
: {
|
||||
completed: 1,
|
||||
failed: 0,
|
||||
cancelled: -1,
|
||||
blocked: -1,
|
||||
not_configured: -1,
|
||||
},
|
||||
outputFile: typeof cfg.outputFile === "string" && cfg.outputFile
|
||||
? cfg.outputFile
|
||||
: "data/node-engine/autoresearch/results.tsv",
|
||||
tag: typeof cfg.tag === "string" ? cfg.tag : "default",
|
||||
};
|
||||
}
|
||||
|
||||
function scoreRun(status, elapsedMs, statusScores) {
|
||||
const base = Number(statusScores[status]);
|
||||
const safeBase = Number.isFinite(base) ? base : -1;
|
||||
const speedBonus = safeBase > 0 ? Math.max(0, 1 - elapsedMs / (10 * 60 * 1000)) : 0;
|
||||
return safeBase + speedBonus;
|
||||
}
|
||||
|
||||
function ensureTsvHeader(filePath) {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
[
|
||||
"timestamp\texperiment\trun_id\tgraph_id\tstatus\telapsed_ms\tscore\tdecision\ttag",
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function appendTsv(filePath, row) {
|
||||
fs.appendFileSync(filePath, row.join("\t") + "\n", "utf8");
|
||||
}
|
||||
|
||||
async function ensureGraphExists(pool, graphId) {
|
||||
const result = await pool.query(
|
||||
"SELECT id, name FROM node_graphs WHERE id = $1 LIMIT 1",
|
||||
[graphId],
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error("Graph not found: " + graphId);
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function createQueuedRun(pool, graphId, params) {
|
||||
const runId = "run_" + Math.random().toString(36).slice(2, 10);
|
||||
const createdAt = nowIso();
|
||||
await pool.query(
|
||||
`INSERT INTO node_runs (id, graph_id, status, params, created_at, updated_at)
|
||||
VALUES ($1, $2, 'queued', $3::jsonb, $4, NOW())`,
|
||||
[runId, graphId, JSON.stringify(params || {}), createdAt],
|
||||
);
|
||||
return { id: runId, createdAt };
|
||||
}
|
||||
|
||||
async function getRunStatus(pool, runId) {
|
||||
const result = await pool.query(
|
||||
"SELECT id, status, created_at, updated_at FROM node_runs WHERE id = $1 LIMIT 1",
|
||||
[runId],
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error("Run not found: " + runId);
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function cancelRun(pool, runId) {
|
||||
await pool.query(
|
||||
`UPDATE node_runs
|
||||
SET status = 'cancelled', updated_at = NOW()
|
||||
WHERE id = $1 AND status IN ('queued', 'running')`,
|
||||
[runId],
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForTerminalStatus(pool, runId, timeoutMs, pollIntervalMs) {
|
||||
const terminal = new Set(["completed", "failed", "cancelled", "blocked", "not_configured"]);
|
||||
const started = Date.now();
|
||||
|
||||
while (true) {
|
||||
const row = await getRunStatus(pool, runId);
|
||||
const status = String(row.status);
|
||||
if (terminal.has(status)) {
|
||||
return {
|
||||
status,
|
||||
elapsedMs: Date.now() - started,
|
||||
timedOut: false,
|
||||
};
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - started;
|
||||
if (elapsed >= timeoutMs) {
|
||||
await cancelRun(pool, runId);
|
||||
return {
|
||||
status: "cancelled",
|
||||
elapsedMs: elapsed,
|
||||
timedOut: true,
|
||||
};
|
||||
}
|
||||
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const configPath = readArg("--config") || "ops/v2/autoresearch.example.json";
|
||||
const once = hasFlag("--once");
|
||||
|
||||
const config = loadConfig(configPath);
|
||||
const outputPath = path.resolve(process.cwd(), config.outputFile);
|
||||
ensureTsvHeader(outputPath);
|
||||
|
||||
const connectionString = process.env.DATABASE_URL || "postgres://localhost:5432/kxkm_clown_v2";
|
||||
const pool = new Pool({ connectionString });
|
||||
|
||||
let bestScore = Number.NEGATIVE_INFINITY;
|
||||
let bestRunId = "";
|
||||
|
||||
try {
|
||||
const graph = await ensureGraphExists(pool, config.graphId);
|
||||
console.log("[autoresearch] graph=" + graph.id + " name=" + graph.name);
|
||||
|
||||
const total = once ? 1 : config.maxExperiments;
|
||||
for (let i = 1; i <= total; i += 1) {
|
||||
const experimentTag = "exp_" + String(i).padStart(3, "0");
|
||||
|
||||
const run = await createQueuedRun(pool, config.graphId, {
|
||||
autoresearch: {
|
||||
tag: config.tag,
|
||||
experiment: experimentTag,
|
||||
createdBy: "scripts/v2-autoresearch-loop.js",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("[autoresearch] queued " + run.id + " (" + experimentTag + ")");
|
||||
|
||||
const outcome = await waitForTerminalStatus(
|
||||
pool,
|
||||
run.id,
|
||||
config.runTimeoutMs,
|
||||
config.pollIntervalMs,
|
||||
);
|
||||
|
||||
const score = scoreRun(outcome.status, outcome.elapsedMs, config.statusScores);
|
||||
const isBest = score > bestScore;
|
||||
const decision = isBest ? "keep" : "discard";
|
||||
|
||||
if (isBest) {
|
||||
bestScore = score;
|
||||
bestRunId = run.id;
|
||||
}
|
||||
|
||||
appendTsv(outputPath, [
|
||||
nowIso(),
|
||||
experimentTag,
|
||||
run.id,
|
||||
config.graphId,
|
||||
outcome.status,
|
||||
String(outcome.elapsedMs),
|
||||
score.toFixed(6),
|
||||
decision,
|
||||
config.tag,
|
||||
]);
|
||||
|
||||
const suffix = outcome.timedOut ? " timeout" : "";
|
||||
console.log(
|
||||
"[autoresearch] " + run.id + " status=" + outcome.status +
|
||||
" score=" + score.toFixed(4) + " decision=" + decision + suffix,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[autoresearch] done best_run=" + (bestRunId || "none") +
|
||||
" best_score=" + (Number.isFinite(bestScore) ? bestScore.toFixed(4) : "n/a"),
|
||||
);
|
||||
console.log("[autoresearch] results=" + outputPath);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("[autoresearch] fatal", err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -331,6 +331,7 @@ const retentionIntervalId = setInterval(() => {
|
||||
function gracefulShutdown(signal) {
|
||||
console.log(`\n[shutdown] ${signal} received, saving sessions...`);
|
||||
sessionManager.stop();
|
||||
nodeEngineQueue.stop();
|
||||
clearInterval(retentionIntervalId);
|
||||
sessionManager.saveAllSessions();
|
||||
|
||||
|
||||
+32
-10
@@ -11,6 +11,18 @@ function createStorage(dataDir, {
|
||||
const logsDir = path.join(dataDir, "logs");
|
||||
const MAX_SESSION_MESSAGES = 400;
|
||||
|
||||
// Per-key write queue to prevent concurrent file corruption
|
||||
const _writeQueues = new Map();
|
||||
function _enqueue(key, fn) {
|
||||
const prev = _writeQueues.get(key) || Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
_writeQueues.set(key, next);
|
||||
next.then(() => {
|
||||
if (_writeQueues.get(key) === next) _writeQueues.delete(key);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function cleanText(value, maxLength = 4000) {
|
||||
return String(value || "").trim().slice(0, maxLength);
|
||||
}
|
||||
@@ -75,12 +87,20 @@ function createStorage(dataDir, {
|
||||
const safeRole = String(role || "").replace(/[^a-z0-9_-]/gi, "_").slice(0, 20);
|
||||
const ts = new Date().toISOString();
|
||||
const line = `[${ts}] [${safeChan}] <${safeRole}> ${text.slice(0, 2000)}\n`;
|
||||
try {
|
||||
fs.appendFileSync(path.join(logsDir, `nick_${safe}.log`), line);
|
||||
fs.appendFileSync(path.join(logsDir, `${safeChan}.log`), `[${ts}] <${safeRole}> ${text.slice(0, 2000)}\n`);
|
||||
} catch (e) {
|
||||
console.error("[log] write error:", e.message);
|
||||
}
|
||||
const chanLine = `[${ts}] <${safeRole}> ${text.slice(0, 2000)}\n`;
|
||||
return _enqueue(`log:nick_${safe}`, () => {
|
||||
try {
|
||||
fs.appendFileSync(path.join(logsDir, `nick_${safe}.log`), line);
|
||||
} catch (e) {
|
||||
console.error("[log] write error:", e.message);
|
||||
}
|
||||
}).then(() => _enqueue(`log:${safeChan}`, () => {
|
||||
try {
|
||||
fs.appendFileSync(path.join(logsDir, `${safeChan}.log`), chanLine);
|
||||
} catch (e) {
|
||||
console.error("[log] write error:", e.message);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function logDPOPair(nick, prompt, chosen, rejected, chosenModel, rejectedModel) {
|
||||
@@ -225,11 +245,13 @@ function createStorage(dataDir, {
|
||||
}
|
||||
|
||||
function saveSession(id, session) {
|
||||
if (!session) return;
|
||||
if (!session) return Promise.resolve();
|
||||
const safeId = String(id || "").replace(/[^a-z0-9_#:-]/gi, "_").slice(0, 180);
|
||||
if (!safeId) return;
|
||||
const file = path.join(sessionsDir, `${safeId}.json`);
|
||||
fs.writeFileSync(file, JSON.stringify(session, (k, v) => k.startsWith("_") ? undefined : v, 2));
|
||||
if (!safeId) return Promise.resolve();
|
||||
return _enqueue(`session:${safeId}`, () => {
|
||||
const file = path.join(sessionsDir, `${safeId}.json`);
|
||||
fs.writeFileSync(file, JSON.stringify(session, (k, v) => k.startsWith("_") ? undefined : v, 2));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSessionSnapshot(session) {
|
||||
|
||||
Reference in New Issue
Block a user