feat(04): OpenClaw Embutido — PatternDetector + Skills Emergentes

- PatternDetector (cron horário): analisa skill_executions, detecta padrões
  (≥3x, 30 dias, ≥80% confiança), grava em detected_patterns
- Geração de DRAFT skills via llama3.1:8b ao atingir threshold
- API routes: GET /api/openclaw/suggestions, POST accept/reject, GET /api/openclaw/patterns
- OpenClawPanel.tsx: painel de visualização com tabs Sugestões/Padrões
- Aba "Skills Emergentes" (ícone Sparkles) na sidebar do /development
- Migration SQL para detected_patterns + skill_suggestions
- fix: corrige import @/lib/db → ../../db/index em versioning.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jonas Pacheco 2026-03-26 15:16:59 -03:00
parent 7528d4c36e
commit c73b9d6658
10 changed files with 801 additions and 18 deletions

View File

@ -7,8 +7,8 @@ Evolução do Arcádia Suite de ERP tradicional para Sistema Agêntico Orientado
## Phases
- [x] **Phase 1: Fundação** - Infraestrutura base: submodules MiroFlow/OpenClaw, tabelas skills, Neo4j, ReferenceParser
- [~] **Phase 2: Skills Engine** - Skills criáveis e executáveis com editor Monaco e Marketplace
- [ ] **Phase 3: MiroFlow Embutido** - Análises científicas via agentes especializados + bridge Superset
- [x] **Phase 2: Skills Engine** - Skills criáveis e executáveis com editor Monaco e Marketplace
- [x] **Phase 3: MiroFlow Embutido** - Análises científicas via agentes especializados + bridge Superset
- [ ] **Phase 4: OpenClaw Embutido** - Skills emergentes com detecção de padrões
- [ ] **Phase 5: Automation Fabric** - Automações unificadas (XOS + Central)
- [ ] **Phase 6: Dev Center Completo** - Fábrica de agentes: Design → Assemble → Deploy
@ -43,7 +43,7 @@ Plans:
- [x] 02-01: SkillEngine + API REST
- [x] 02-02: Editor Monaco + autocomplete + rota /skills
- [x] 02-03: Skill Marketplace (Biblioteca)
- [ ] 02-04: Versionamento Git-like de skills
- [x] 02-04: Versionamento Git-like de skills
### Phase 3: MiroFlow Embutido
**Goal**: Análises científicas disponíveis via agentes especializados integrados ao Superset
@ -62,7 +62,7 @@ Plans:
Plans:
- [x] 03-01-PLAN.md — Setup (ollama pull llama3.1:8b) + miroflow_service.py FastAPI porta 8006 com 3 agentes
- [x] 03-02-PLAN.md — Node bridge (engine-proxy.ts + routes.ts) + KG logging SHA-256
- [ ] 03-03-PLAN.md — Frontend MiroFlowControl.tsx + tab "Científico" em BiWorkspace.tsx
- [x] 03-03-PLAN.md — Frontend MiroFlowControl.tsx + tab "Científico" em BiWorkspace.tsx
### Phase 4: OpenClaw Embutido
**Goal**: Skills emergentes criadas automaticamente a partir de padrões detectados
@ -99,8 +99,18 @@ Plans:
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Fundação | 3/3 | Complete | 2026-03-25 |
| 2. Skills Engine | 3/4 | In progress | - |
| 3. MiroFlow Embutido | 2/3 | In Progress| |
| 2. Skills Engine | 4/4 | Complete | 2026-03-26 |
| 3. MiroFlow Embutido | 3/3 | Complete | 2026-03-26 |
| 4. OpenClaw Embutido | 0/TBD | Not started | - |
| 5. Automation Fabric | 0/TBD | Not started | - |
| 6. Dev Center Completo | 0/TBD | Not started | - |
### Phase 7: Skill Fabric Expandido: Compiladores, Sandbox Executor, Visual/Code/Markdown Editors com Validation Pipeline
**Goal:** [To be planned]
**Requirements**: TBD
**Depends on:** Phase 6
**Plans:** 0 plans
Plans:
- [ ] TBD (run /gsd:plan-phase 7 to break down)

View File

@ -1,16 +1,17 @@
# State
## Current Phase: 3 — MiroFlow Embutido
## Current Phase: 4 — OpenClaw Embutido
## Completed
- Phase 1: submodules, tabelas, Neo4j, ReferenceParser
- Phase 2: SkillEngine, API REST, Monaco Editor, /skills, autocomplete, Marketplace
## Completed Plans
- Phase 3, Plan 01: miroflow_service.py + tests (60f1c5c, 76e1d34)
- Phase 2: SkillEngine, API REST, Monaco Editor, /skills, autocomplete, Marketplace, versionamento Git-like
- Phase 3: miroflow_service.py, bridge TS + KG logging, MiroFlowControl.tsx + tab Científico
## In Progress
- Phase 2 pendente: versionamento Git-like de skills (pode ser feito em paralelo ou movido para backlog)
- (nenhum — iniciar Phase 4)
## Roadmap Evolution
- Phase 7 added: Skill Fabric Expandido (compiladores, sandbox, 3 editor modes, validation pipeline)
## Notes
- Superset em produção com RLS configurado — não alterar sem confirmação

View File

@ -0,0 +1,97 @@
# Phase 4 Context — OpenClaw Embutido
> Generated in --auto mode on 2026-03-26. All decisions auto-selected with recommended defaults.
## Phase Goal
Skills emergentes criadas automaticamente a partir de padrões detectados em `skill_executions`.
## Prior Context Applied
- **Stack de IA:** Ollama local via LiteLLM — usar `llama3.1:8b` para geração de drafts (leve, rápido)
- **Backend-first:** API definida antes do frontend
- **Auditoria imutável:** todas as execuções registradas com SHA-256
- **Schema já existe:** `detected_patterns` e `skill_suggestions` em `shared/schema.ts` — usar sem alterar
---
## Decisions
### 1. O que constitui um "padrão"?
**[auto] Mesma skill executada ≥3 vezes em 30 dias com confiança ≥80%**
- Fonte de dados: tabela `skill_executions`
- Agrupamento: `skillId + userId` (por usuário, não global)
- Confiança calculada como: `(frequency / max_frequency_in_window) * 100`
- Janela: `lastSeenAt - firstSeenAt ≤ 30 dias`
- Thresholds alinhados com o roadmap: min 3 ocorrências, 30 dias, 80% confiança
### 2. Trigger do PatternDetector
**[auto] Cron job a cada hora (setInterval no servidor Node.js)**
- Sem dependência de infraestrutura externa (sem Redis, sem Bull)
- Ao iniciar, PatternDetector registra no startup do servidor
- Execuções recentes analisadas em lote; padrões gravados em `detected_patterns`
- Se padrão já existe (mesmo skillId + userId): atualiza frequency + lastSeenAt
### 3. Geração do body da skill emergente
**[auto] AI via llama3.1:8b (LiteLLM) gera o body do DRAFT**
- Ao atingir threshold: PatternDetector cria entrada em `skill_suggestions` (status: `pending`)
- Skill DRAFT gerada automaticamente em `arcadia_skills` (status: `draft`, source: `openclaw`)
- Body gerado via prompt para llama3.1:8b descrevendo o padrão detectado
- DRAFT aguarda aprovação — não é executável até ser `published`
### 4. Widget de notificação
**[auto] Badge no header global + painel slide-in**
- Badge no ícone de notificações existente no header (sem criar novo componente de header)
- Ao clicar: slide-in panel lateral mostrando lista de sugestões pendentes
- Cada sugestão mostra: nome sugerido, padrão detectado, frequência, confiança, body preview
- Ações inline: "Aceitar" → promove para skill publicada | "Rejeitar" → status `rejected`
### 5. Aprovação — onde?
**[auto] Tab "Sugestões" em `/skills` (página existente) — Dev Center completo na Phase 6**
- Sem criar nova rota — adicionar tab na página `/skills` já existente
- Tab lista `skill_suggestions` com status `pending`
- Fluxo: aceitar → cria skill publicada a partir do DRAFT | rejeitar → arquiva sugestão
- Dev Center completo (Phase 6) herdará esse fluxo
---
## Reusable Assets Identified
- `shared/schema.ts``detectedPatterns`, `skillSuggestions` já definidos (Phase 1)
- `server/skills/engine.ts` — SkillEngine para criar DRAFT skills programaticamente
- `server/skills/routes.ts` — rota `/api/skills` existente, adicionar sub-rotas de sugestões
- `server/skills/versioning.ts` — SHA-256 audit logging (reusar padrão)
- `client/src/pages/BiWorkspace.tsx` — referência de como adicionar tabs a uma página existente
- `docker/litellm-config.yaml` — llama3.1:8b já configurado como Tier 2
---
## Scope Boundary
**Incluído nesta fase:**
- PatternDetector backend (cron + análise de skill_executions)
- Geração de DRAFT via AI
- Widget de notificação + slide-in
- Tab "Sugestões" em /skills com aprovação/rejeição
**Fora de escopo (phases futuras):**
- Dev Center completo (Phase 6)
- Detecção de padrões em outras fontes além de skill_executions
- Pattern sharing entre tenants
---
## Plan Breakdown Suggested
- **04-01:** PatternDetector service (backend) — cron, análise, gravação em detected_patterns + skill_suggestions + DRAFT creation via AI
- **04-02:** API routes + frontend widget (badge + slide-in) + tab "Sugestões" em /skills

View File

@ -0,0 +1,245 @@
/**
* OpenClawPanel Phase 4
*
* Painel de visualização de Skills Emergentes detectadas pelo PatternDetector.
* Exibido como aba na sidebar do /development.
*/
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Sparkles, CheckCircle, XCircle, ChevronDown, ChevronRight,
Loader2, Activity, Clock, BarChart2
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/hooks/use-toast";
interface Suggestion {
id: string;
suggestedSkillName: string;
suggestedDescription: string | null;
estimatedAutomation: string | null;
confidence: string;
draftBody: string | null;
createdAt: string;
}
interface Pattern {
id: string;
actionType: string;
description: string | null;
frequency: number;
confidence: string;
firstSeenAt: string;
lastSeenAt: string;
status: string;
}
export default function OpenClawPanel() {
const [tab, setTab] = useState<"suggestions" | "patterns">("suggestions");
const [expanded, setExpanded] = useState<string | null>(null);
const { toast } = useToast();
const qc = useQueryClient();
const { data: sugData, isLoading: sugLoading } = useQuery<{ suggestions: Suggestion[]; total: number }>({
queryKey: ["/api/openclaw/suggestions"],
refetchInterval: 2 * 60 * 1000,
});
const { data: patData, isLoading: patLoading } = useQuery<{ patterns: Pattern[]; total: number }>({
queryKey: ["/api/openclaw/patterns"],
enabled: tab === "patterns",
});
const accept = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/openclaw/suggestions/${id}/accept`, { method: "POST" });
if (!res.ok) throw new Error();
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["/api/openclaw/suggestions"] });
qc.invalidateQueries({ queryKey: ["/api/skills"] });
toast({ title: "Skill publicada!" });
},
onError: () => toast({ title: "Erro ao aceitar", variant: "destructive" }),
});
const reject = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/openclaw/suggestions/${id}/reject`, { method: "POST" });
if (!res.ok) throw new Error();
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["/api/openclaw/suggestions"] });
toast({ title: "Sugestão rejeitada" });
},
onError: () => toast({ title: "Erro ao rejeitar", variant: "destructive" }),
});
const suggestions = sugData?.suggestions ?? [];
const patterns = patData?.patterns ?? [];
return (
<div className="h-full flex flex-col bg-gray-900 text-white">
{/* Header */}
<div className="px-5 py-4 border-b border-white/10 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-yellow-400" />
<div>
<h2 className="text-sm font-semibold">Skills Emergentes</h2>
<p className="text-[11px] text-white/40">OpenClaw detecção automática de padrões</p>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-white/10 text-xs">
<button
onClick={() => setTab("suggestions")}
className={`px-4 py-2.5 flex items-center gap-1.5 transition-colors border-b-2 ${tab === "suggestions" ? "border-yellow-400 text-yellow-400" : "border-transparent text-white/50 hover:text-white"}`}
>
<Sparkles className="w-3 h-3" />
Sugestões
{suggestions.length > 0 && (
<span className="ml-1 rounded-full bg-yellow-400 text-black text-[9px] font-bold w-4 h-4 flex items-center justify-center">
{suggestions.length}
</span>
)}
</button>
<button
onClick={() => setTab("patterns")}
className={`px-4 py-2.5 flex items-center gap-1.5 transition-colors border-b-2 ${tab === "patterns" ? "border-yellow-400 text-yellow-400" : "border-transparent text-white/50 hover:text-white"}`}
>
<Activity className="w-3 h-3" />
Padrões
</button>
</div>
<ScrollArea className="flex-1">
{/* Sugestões */}
{tab === "suggestions" && (
<div>
{sugLoading && (
<div className="flex justify-center py-10">
<Loader2 className="w-5 h-5 animate-spin text-white/30" />
</div>
)}
{!sugLoading && suggestions.length === 0 && (
<div className="px-5 py-12 text-center">
<Sparkles className="w-8 h-8 text-white/10 mx-auto mb-3" />
<p className="text-sm text-white/30">Nenhuma sugestão pendente</p>
<p className="text-xs text-white/20 mt-1">O detector roda a cada hora</p>
</div>
)}
<div className="divide-y divide-white/5">
{suggestions.map((s) => {
const conf = Math.round(Number(s.confidence) * 100);
const isExp = expanded === s.id;
const isAccepting = accept.isPending && accept.variables === s.id;
const isRejecting = reject.isPending && reject.variables === s.id;
return (
<div key={s.id} className="px-4 py-4 space-y-2.5">
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{s.suggestedSkillName}</p>
{s.suggestedDescription && (
<p className="text-xs text-white/50 mt-0.5 line-clamp-2">{s.suggestedDescription}</p>
)}
</div>
<Badge className="shrink-0 bg-yellow-400/10 text-yellow-400 border-yellow-400/20 text-[10px]">
{conf}%
</Badge>
</div>
{s.draftBody && (
<div>
<button
onClick={() => setExpanded(isExp ? null : s.id)}
className="flex items-center gap-1 text-[11px] text-white/40 hover:text-white/60 transition-colors"
>
{isExp ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
{isExp ? "Ocultar body" : "Ver body gerado"}
</button>
{isExp && (
<pre className="mt-2 rounded bg-black/40 p-3 text-[11px] text-white/60 overflow-x-auto whitespace-pre-wrap font-mono max-h-40 border border-white/5">
{s.draftBody}
</pre>
)}
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
className="h-7 text-xs bg-emerald-900/40 text-emerald-400 border border-emerald-700/40 hover:bg-emerald-900/60"
onClick={() => accept.mutate(s.id)}
disabled={isAccepting || isRejecting}
>
{isAccepting ? <Loader2 className="w-3 h-3 animate-spin" /> : <CheckCircle className="w-3 h-3" />}
<span className="ml-1">Aceitar</span>
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => reject.mutate(s.id)}
disabled={isAccepting || isRejecting}
>
{isRejecting ? <Loader2 className="w-3 h-3 animate-spin" /> : <XCircle className="w-3 h-3" />}
<span className="ml-1">Rejeitar</span>
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Padrões */}
{tab === "patterns" && (
<div>
{patLoading && (
<div className="flex justify-center py-10">
<Loader2 className="w-5 h-5 animate-spin text-white/30" />
</div>
)}
{!patLoading && patterns.length === 0 && (
<div className="px-5 py-12 text-center">
<BarChart2 className="w-8 h-8 text-white/10 mx-auto mb-3" />
<p className="text-sm text-white/30">Nenhum padrão detectado</p>
<p className="text-xs text-white/20 mt-1">Execute skills manualmente para gerar dados</p>
</div>
)}
<div className="divide-y divide-white/5">
{patterns.map((p) => {
const conf = Math.round(Number(p.confidence) * 100);
const last = new Date(p.lastSeenAt).toLocaleDateString("pt-BR");
return (
<div key={p.id} className="px-4 py-3 space-y-1">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-mono text-white/70 truncate">{p.actionType}</p>
<Badge className="shrink-0 bg-white/5 text-white/50 border-white/10 text-[10px]">
{conf}%
</Badge>
</div>
{p.description && (
<p className="text-[11px] text-white/40">{p.description}</p>
)}
<div className="flex items-center gap-3 text-[10px] text-white/30">
<span className="flex items-center gap-1"><Activity className="w-3 h-3" />{p.frequency}x</span>
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{last}</span>
</div>
</div>
);
})}
</div>
</div>
)}
</ScrollArea>
</div>
);
}

View File

@ -25,8 +25,9 @@ import PageBuilder from "./PageBuilder";
import WorkflowBuilder from "./WorkflowBuilder";
import IDE from "./IDE";
import DevAgent from "@/components/lowcode/DevAgent";
import OpenClawPanel from "@/components/OpenClawPanel";
type ActiveTool = "home" | "doctypes" | "pages" | "workflows" | "dashboards" | "reports" | "scripts" | "ide" | "agent";
type ActiveTool = "home" | "doctypes" | "pages" | "workflows" | "dashboards" | "reports" | "scripts" | "ide" | "agent" | "openclaw";
interface Dashboard {
id: number;
@ -250,15 +251,24 @@ export default function DevelopmentModule() {
count: 0,
category: "dev"
},
{
id: "agent" as ActiveTool,
name: "Manus AI",
{
id: "agent" as ActiveTool,
name: "Manus AI",
description: "Agente autônomo de IA",
icon: Bot,
icon: Bot,
color: "bg-violet-600",
count: 0,
category: "dev"
},
{
id: "openclaw" as ActiveTool,
name: "Skills Emergentes",
description: "Padrões detectados automaticamente",
icon: Sparkles,
color: "bg-yellow-500",
count: 0,
category: "dev"
},
];
const [, navigate] = useLocation();
@ -705,6 +715,7 @@ export default function DevelopmentModule() {
{activeTool === "scripts" && renderScriptEditor()}
{activeTool === "ide" && <IDE />}
{activeTool === "agent" && <DevAgent />}
{activeTool === "openclaw" && <OpenClawPanel />}
</div>
<Dialog open={showNewDashboardDialog} onOpenChange={setShowNewDashboardDialog}>

View File

@ -0,0 +1,41 @@
-- Phase 4: OpenClaw — Tabelas de detecção de padrões e sugestões de skills emergentes
-- Executar se as tabelas ainda não existirem (schema já definido em shared/schema.ts)
CREATE TABLE IF NOT EXISTS detected_patterns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id INTEGER REFERENCES tenants(id),
user_id VARCHAR REFERENCES users(id),
action_type VARCHAR(100) NOT NULL,
description TEXT,
frequency INTEGER NOT NULL DEFAULT 0,
confidence NUMERIC(4,3) NOT NULL DEFAULT 0,
first_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
metadata JSONB DEFAULT '{}',
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS skill_suggestions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pattern_id UUID REFERENCES detected_patterns(id) ON DELETE CASCADE,
tenant_id INTEGER REFERENCES tenants(id),
user_id VARCHAR REFERENCES users(id),
suggested_skill_name VARCHAR(200) NOT NULL,
suggested_description TEXT,
estimated_automation TEXT,
confidence NUMERIC(4,3) NOT NULL DEFAULT 0,
generated_skill_id UUID REFERENCES arcadia_skills(id),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
source VARCHAR(50) NOT NULL DEFAULT 'openclaw',
reviewed_by VARCHAR REFERENCES users(id),
reviewed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_detected_patterns_status ON detected_patterns(status);
CREATE INDEX IF NOT EXISTS idx_detected_patterns_user ON detected_patterns(user_id, action_type);
CREATE INDEX IF NOT EXISTS idx_skill_suggestions_status ON skill_suggestions(status);
CREATE INDEX IF NOT EXISTS idx_skill_suggestions_pattern ON skill_suggestions(pattern_id);

View File

@ -0,0 +1,223 @@
/**
* PatternDetector OpenClaw Phase 4
*
* Analisa skill_executions a cada hora, detecta padrões por (skillId + userId),
* e gera DRAFT skills emergentes via llama3.1:8b quando threshold atingido.
*
* Thresholds: min 3 execuções, janela 30 dias, confiança 0.80
*/
import crypto from "crypto";
import OpenAI from "openai";
import { db } from "../../db/index";
import {
arcadiaSkills,
skillExecutions,
detectedPatterns,
skillSuggestions,
} from "@shared/schema";
import { eq, and, gte, sql, count } from "drizzle-orm";
const MIN_FREQUENCY = 3;
const WINDOW_DAYS = 30;
const MIN_CONFIDENCE = 0.80;
const CRON_INTERVAL_MS = 60 * 60 * 1000; // 1 hora
const openai = new OpenAI({
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
});
// ─── Análise de padrões ────────────────────────────────────────────────────────
async function detectPatterns(): Promise<void> {
const windowStart = new Date(Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000);
// Agrupa execuções bem-sucedidas por skillId + userId na janela
const grouped = await db
.select({
skillId: skillExecutions.skillId,
userId: skillExecutions.userId,
tenantId: skillExecutions.tenantId,
execCount: count(skillExecutions.id),
firstSeen: sql<Date>`MIN(${skillExecutions.startedAt})`,
lastSeen: sql<Date>`MAX(${skillExecutions.startedAt})`,
})
.from(skillExecutions)
.where(
and(
gte(skillExecutions.startedAt, windowStart),
eq(skillExecutions.status, "success")
)
)
.groupBy(skillExecutions.skillId, skillExecutions.userId, skillExecutions.tenantId);
// Frequência máxima no período (para normalizar confiança)
const maxFreq = grouped.reduce((m, r) => Math.max(m, Number(r.execCount)), 1);
for (const row of grouped) {
const frequency = Number(row.execCount);
if (frequency < MIN_FREQUENCY) continue;
const confidence = Math.min(frequency / maxFreq, 1);
if (confidence < MIN_CONFIDENCE) continue;
// Busca skill para obter nome
const [skill] = await db
.select({ name: arcadiaSkills.name, slug: arcadiaSkills.slug, description: arcadiaSkills.description })
.from(arcadiaSkills)
.where(eq(arcadiaSkills.id, row.skillId))
.limit(1);
if (!skill) continue;
const actionType = `skill:${skill.slug}`;
// Upsert em detected_patterns
const existing = await db
.select({ id: detectedPatterns.id })
.from(detectedPatterns)
.where(
and(
eq(detectedPatterns.actionType, actionType),
eq(detectedPatterns.userId, row.userId ?? "")
)
)
.limit(1);
let patternId: string;
if (existing.length > 0) {
patternId = existing[0].id;
await db
.update(detectedPatterns)
.set({
frequency,
confidence: String(confidence.toFixed(3)),
lastSeenAt: row.lastSeen,
updatedAt: new Date(),
})
.where(eq(detectedPatterns.id, patternId));
} else {
const [created] = await db
.insert(detectedPatterns)
.values({
tenantId: row.tenantId,
userId: row.userId,
actionType,
description: `Skill "${skill.name}" executada repetidamente`,
frequency,
confidence: String(confidence.toFixed(3)),
firstSeenAt: row.firstSeen,
lastSeenAt: row.lastSeen,
metadata: { skillId: row.skillId, skillName: skill.name },
status: "active",
})
.returning({ id: detectedPatterns.id });
patternId = created.id;
}
// Cria sugestão se ainda não existe sugestão pendente para este padrão
const existingSuggestion = await db
.select({ id: skillSuggestions.id })
.from(skillSuggestions)
.where(
and(
eq(skillSuggestions.patternId, patternId),
eq(skillSuggestions.status, "pending")
)
)
.limit(1);
if (existingSuggestion.length === 0) {
await createEmergentSkillDraft(patternId, skill, frequency, confidence, row.tenantId, row.userId);
}
}
}
// ─── Geração de DRAFT via AI ───────────────────────────────────────────────────
async function createEmergentSkillDraft(
patternId: string,
skill: { name: string; slug: string; description: string | null },
frequency: number,
confidence: number,
tenantId: number | null,
userId: string | null
): Promise<void> {
const prompt = `Você é um assistente de automação. Um usuário executou a skill "${skill.name}" (${skill.description ?? "sem descrição"}) ${frequency} vezes nos últimos 30 dias.
Gere um corpo (body) em Markdown para uma nova skill chamada "Auto: ${skill.name}" que automatize este fluxo de forma mais inteligente.
O body deve usar blocos /skill/${skill.slug} para reutilizar a skill original e adicionar lógica de orquestração.
Responda apenas com o body em Markdown, sem explicações.`;
let body = `# Auto: ${skill.name}\n\nEsta skill emergiu de ${frequency} execuções detectadas.\n\n\`\`\`\n/skill/${skill.slug}\n\`\`\`\n`;
try {
const completion = await openai.chat.completions.create({
model: "llama3.1:8b",
messages: [{ role: "user", content: prompt }],
max_tokens: 512,
temperature: 0.4,
});
const generated = completion.choices[0]?.message?.content?.trim();
if (generated) body = generated;
} catch {
// AI indisponível — usa body padrão
}
// Cria DRAFT skill
const draftSlug = `auto-${skill.slug}-${crypto.randomBytes(4).toString("hex")}`;
const [draftSkill] = await db
.insert(arcadiaSkills)
.values({
name: `Auto: ${skill.name}`,
slug: draftSlug,
description: `Skill emergente detectada automaticamente. Executada ${frequency} vezes com confiança ${(confidence * 100).toFixed(0)}%.`,
body,
status: "draft",
namespace: "tenant",
tenantId,
userId,
tags: ["emergente", "openclaw"],
author: "openclaw",
})
.returning({ id: arcadiaSkills.id });
// Registra sugestão
await db.insert(skillSuggestions).values({
patternId,
tenantId,
userId,
suggestedSkillName: `Auto: ${skill.name}`,
suggestedDescription: `Automatização detectada com base em ${frequency} execuções.`,
estimatedAutomation: body,
confidence: String(confidence.toFixed(3)),
generatedSkillId: draftSkill.id,
status: "pending",
source: "openclaw",
});
}
// ─── Inicialização do cron ─────────────────────────────────────────────────────
let cronHandle: ReturnType<typeof setInterval> | null = null;
export function startPatternDetector(): void {
if (cronHandle) return;
// Executa imediatamente na inicialização, depois a cada hora
detectPatterns().catch(console.error);
cronHandle = setInterval(() => {
detectPatterns().catch(console.error);
}, CRON_INTERVAL_MS);
console.log("[OpenClaw] PatternDetector iniciado (intervalo: 1h)");
}
export function stopPatternDetector(): void {
if (cronHandle) {
clearInterval(cronHandle);
cronHandle = null;
}
}

149
server/openclaw/routes.ts Normal file
View File

@ -0,0 +1,149 @@
/**
* OpenClaw Routes Fase 4
*
* GET /api/openclaw/suggestions lista sugestões pendentes
* POST /api/openclaw/suggestions/:id/accept aceita sugestão (publica DRAFT)
* POST /api/openclaw/suggestions/:id/reject rejeita sugestão
* GET /api/openclaw/patterns lista padrões detectados
*/
import type { Express, Request, Response } from "express";
import { db } from "../../db/index";
import { arcadiaSkills, detectedPatterns, skillSuggestions } from "@shared/schema";
import { eq, and, desc } from "drizzle-orm";
export function registerOpenClawRoutes(app: Express): void {
// ── Listar sugestões pendentes ──────────────────────────────────────────────
app.get("/api/openclaw/suggestions", async (req: Request, res: Response) => {
try {
const suggestions = await db
.select()
.from(skillSuggestions)
.where(eq(skillSuggestions.status, "pending"))
.orderBy(desc(skillSuggestions.createdAt))
.limit(50);
// Enriquecer com body do DRAFT
const enriched = await Promise.all(
suggestions.map(async (s) => {
let draftBody: string | null = null;
if (s.generatedSkillId) {
const [draft] = await db
.select({ body: arcadiaSkills.body, name: arcadiaSkills.name })
.from(arcadiaSkills)
.where(eq(arcadiaSkills.id, s.generatedSkillId))
.limit(1);
draftBody = draft?.body ?? null;
}
return { ...s, draftBody };
})
);
res.json({ suggestions: enriched, total: enriched.length });
} catch (err) {
res.status(500).json({ error: "Erro ao listar sugestões" });
}
});
// ── Aceitar sugestão ────────────────────────────────────────────────────────
app.post("/api/openclaw/suggestions/:id/accept", async (req: Request, res: Response) => {
const { id } = req.params;
const userId = (req as any).user?.id ?? null;
try {
const [suggestion] = await db
.select()
.from(skillSuggestions)
.where(and(eq(skillSuggestions.id, id), eq(skillSuggestions.status, "pending")))
.limit(1);
if (!suggestion) {
return res.status(404).json({ error: "Sugestão não encontrada" });
}
// Publica o DRAFT
if (suggestion.generatedSkillId) {
await db
.update(arcadiaSkills)
.set({ status: "active", updatedAt: new Date() })
.where(eq(arcadiaSkills.id, suggestion.generatedSkillId));
}
// Atualiza sugestão e padrão
await db
.update(skillSuggestions)
.set({ status: "accepted", reviewedBy: userId, reviewedAt: new Date(), updatedAt: new Date() })
.where(eq(skillSuggestions.id, id));
if (suggestion.patternId) {
await db
.update(detectedPatterns)
.set({ status: "converted", updatedAt: new Date() })
.where(eq(detectedPatterns.id, suggestion.patternId));
}
res.json({ ok: true, skillId: suggestion.generatedSkillId });
} catch (err) {
res.status(500).json({ error: "Erro ao aceitar sugestão" });
}
});
// ── Rejeitar sugestão ───────────────────────────────────────────────────────
app.post("/api/openclaw/suggestions/:id/reject", async (req: Request, res: Response) => {
const { id } = req.params;
const userId = (req as any).user?.id ?? null;
try {
const [suggestion] = await db
.select()
.from(skillSuggestions)
.where(and(eq(skillSuggestions.id, id), eq(skillSuggestions.status, "pending")))
.limit(1);
if (!suggestion) {
return res.status(404).json({ error: "Sugestão não encontrada" });
}
// Remove o DRAFT
if (suggestion.generatedSkillId) {
await db
.update(arcadiaSkills)
.set({ status: "archived", updatedAt: new Date() })
.where(eq(arcadiaSkills.id, suggestion.generatedSkillId));
}
await db
.update(skillSuggestions)
.set({ status: "rejected", reviewedBy: userId, reviewedAt: new Date(), updatedAt: new Date() })
.where(eq(skillSuggestions.id, id));
if (suggestion.patternId) {
await db
.update(detectedPatterns)
.set({ status: "archived", updatedAt: new Date() })
.where(eq(detectedPatterns.id, suggestion.patternId));
}
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: "Erro ao rejeitar sugestão" });
}
});
// ── Listar padrões detectados ───────────────────────────────────────────────
app.get("/api/openclaw/patterns", async (_req: Request, res: Response) => {
try {
const patterns = await db
.select()
.from(detectedPatterns)
.where(eq(detectedPatterns.status, "active"))
.orderBy(desc(detectedPatterns.updatedAt))
.limit(100);
res.json({ patterns, total: patterns.length });
} catch (err) {
res.status(500).json({ error: "Erro ao listar padrões" });
}
});
}

View File

@ -65,6 +65,8 @@ import { loadModuleRoutes } from "./modules/loader";
import graphRoutes from "./graph/routes";
import { initSocketIO } from "./socket-io";
import { startXosScheduler } from "./xos/scheduler";
import { registerOpenClawRoutes } from "./openclaw/routes";
import { startPatternDetector } from "./openclaw/pattern-detector";
export async function registerRoutes(
httpServer: Server,
@ -160,6 +162,10 @@ export async function registerRoutes(
// XOS Scheduler: SLA breach checker + supervisor stats broadcaster
startXosScheduler();
// OpenClaw — PatternDetector (Phase 4)
registerOpenClawRoutes(app);
startPatternDetector();
// Central de Protocolos (MCP, A2A, AP2, UCP)
app.use("/api", protocolsRoutes);

View File

@ -1,5 +1,5 @@
import crypto from 'crypto';
import { db } from '@/lib/db';
import { db } from '../../db/index';
import { Skill } from './engine';
export interface SkillVersion {