diff --git a/favicon.png b/favicon.png new file mode 100644 index 0000000..bc3b6c4 Binary files /dev/null and b/favicon.png differ diff --git a/index.html b/index.html index db12640..e66a883 100644 --- a/index.html +++ b/index.html @@ -83,7 +83,7 @@ .tb-label{font-size:11.5px;color:var(--muted);font-family:var(--mono);flex:1} .tb-status{font-size:11px;color:var(--green);display:flex;align-items:center;gap:4px} .tb-live{width:5px;height:5px;background:var(--green);border-radius:50%;animation:pulse 1.4s ease infinite} -#demo-canvas{width:100%;height:100%;display:block} +#demo-canvas{width:100%;height:100%;position:relative;overflow:hidden} .demo-side{border-left:1px solid var(--border);display:flex;flex-direction:column} .side-header{font-size:10px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);padding:11px 14px;border-bottom:1px solid var(--border);font-family:var(--mono)} .feed-msgs{flex:1;overflow-y:auto;padding:4px 0} @@ -250,7 +250,7 @@

watch every agent think out loud.

Idle
- +
@@ -432,18 +432,7 @@

your next build starts
with one sentence.

document.querySelectorAll('.fi').forEach(el => io.observe(el)) // ── Demo Canvas ───────────────────────────────────────────────────────────── -const dpr = window.devicePixelRatio || 1 -const cv = document.getElementById('demo-canvas') -const ctx = cv.getContext('2d') - -function resizeCanvas(){ - const wrap = cv.parentElement - cv.width = wrap.offsetWidth * dpr - cv.height = wrap.offsetHeight * dpr -} -resizeCanvas() -window.addEventListener('resize', () => { resizeCanvas(); buildAgents() }, {passive:true}) - +// ── Three.js 3D Demo ────────────────────────────────────────────────────────── const ROLES_DEF = [ {role:'orchestrator', color:'#f97316', model:'claude-sonnet-4-6', provider:'Anthropic'}, {role:'researcher', color:'#3b82f6', model:'llama-3.3-70b', provider:'Groq'}, @@ -455,124 +444,182 @@

your next build starts
with one sentence.

] const EDGES_DEF = [[0,6],[0,1],[0,2],[1,3],[2,4],[3,5],[4,5]] -let agents = [], running = false, globalT = 0 +let agents = [], running = false function buildAgents(){ - const W = cv.width/dpr, H = cv.height/dpr - const cx = W/2, cy = H/2 - agents = ROLES_DEF.map((r, i) => { - const isCenter = i === 0 - const angle = (2*Math.PI*(i-1))/(ROLES_DEF.length-1) - Math.PI/2 - const radius = isCenter ? 0 : Math.min(W,H)*0.3 - const bx = isCenter ? cx : cx + radius*Math.cos(angle) - const by = isCenter ? cy : cy + radius*Math.sin(angle) - return { - ...r, bx, by, x:bx, y:by, - phase: Math.random()*Math.PI*2, speed: 0.4+Math.random()*0.4, - status: 'idle', progress: Math.random(), tokensOut: 0, - outputText: '', size: isCenter ? 32 : 24, pulse: 0, - } - }) + agents = ROLES_DEF.map((r) => ({ + ...r, phase: Math.random()*Math.PI*2, + status:'idle', progress:0, tokensOut:0, outputText:'', + })) } buildAgents() +window.addEventListener('resize', buildAgents, {passive:true}) + +// Init Three.js after layout is ready +requestAnimationFrame(function init3d(){ + const container3d = document.getElementById('demo-canvas') + if(!container3d || typeof THREE === 'undefined') { requestAnimationFrame(init3d); return } + const W = container3d.offsetWidth || 560 + const H = container3d.offsetHeight || 380 + + // Renderer + const renderer = new THREE.WebGLRenderer({antialias:true}) + renderer.setSize(W, H) + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) + renderer.setClearColor(0x0e0e0e, 1) + renderer.domElement.style.cssText = 'width:100%;height:100%;display:block' + container3d.appendChild(renderer.domElement) + + // Scene + camera + const scene = new THREE.Scene() + const camera = new THREE.PerspectiveCamera(50, W/H, 0.1, 100) + camera.position.set(0.5, 1.2, 5.8) + camera.lookAt(0, 0, 0) + + // Lights + scene.add(new THREE.AmbientLight(0xffffff, 0.4)) + const plight = new THREE.PointLight(0xa78bfa, 1.5, 20) + plight.position.set(0, 2, 3) + scene.add(plight) + + // Group for rotation + const group = new THREE.Group() + scene.add(group) + + // 3D positions + const POS3D = [ + new THREE.Vector3(0, 0, 0), + new THREE.Vector3(2.4, 0.5, 0.3), + new THREE.Vector3(0.8, -0.7, 2.1), + new THREE.Vector3(-2.0, 0.5, 1.0), + new THREE.Vector3(-2.2, -0.3, -0.8), + new THREE.Vector3(-0.4, 0.9, -2.1), + new THREE.Vector3(1.7, -0.6, -1.5), + ] -// Particles -const particles = EDGES_DEF.flatMap((e,_) => - Array.from({length:3}, () => ({edge:e, t:Math.random(), speed:0.003+Math.random()*0.005})) -) - -function hexPath(x,y,r){ - ctx.beginPath() - for(let i=0;i<6;i++){const a=(Math.PI/3)*i-Math.PI/6; i?ctx.lineTo(x+r*Math.cos(a),y+r*Math.sin(a)):ctx.moveTo(x+r*Math.cos(a),y+r*Math.sin(a))} - ctx.closePath() -} + // Parse colors + const nodeColors = ROLES_DEF.map(r => new THREE.Color(r.color)) -function drawFrame(){ - const W=cv.width/dpr, H=cv.height/dpr - ctx.clearRect(0,0,cv.width,cv.height) - ctx.save(); ctx.scale(dpr,dpr) - - // grid - ctx.strokeStyle='rgba(255,255,255,.018)';ctx.lineWidth=0.5 - for(let x=0;x { - a.x = a.bx + Math.sin(globalT*a.speed+a.phase)*5 - a.y = a.by + Math.cos(globalT*a.speed*.7+a.phase)*4 - a.pulse = (Math.sin(globalT*1.5+a.phase)+1)/2 - if(running && a.status==='working') { a.tokensOut = Math.min(a.tokensOut+0.4,100); a.progress=(a.progress+.005)%1 } + // Build nodes (core sphere + glow halo) + const nodeMeshes = ROLES_DEF.map((r, i) => { + const isCenter = i === 0 + const size = isCenter ? 0.28 : 0.18 + const col = nodeColors[i] + + const core = new THREE.Mesh( + new THREE.SphereGeometry(size, 32, 32), + new THREE.MeshStandardMaterial({color:col, emissive:col, emissiveIntensity:0.3, roughness:0.2, metalness:0.7}) + ) + core.position.copy(POS3D[i]) + group.add(core) + + const glow = new THREE.Mesh( + new THREE.SphereGeometry(size * 3, 16, 16), + new THREE.MeshBasicMaterial({color:col, transparent:true, opacity:isCenter?0.07:0.04, depthWrite:false}) + ) + glow.position.copy(POS3D[i]) + group.add(glow) + + return {core, glow} }) - // edges - EDGES_DEF.forEach(([f,t]) => { - const fa=agents[f], ta=agents[t] - const g=ctx.createLinearGradient(fa.x,fa.y,ta.x,ta.y) - g.addColorStop(0,fa.color+'35'); g.addColorStop(1,ta.color+'35') - ctx.beginPath();ctx.moveTo(fa.x,fa.y);ctx.lineTo(ta.x,ta.y) - ctx.strokeStyle=g;ctx.lineWidth=1;ctx.stroke() + // Build edges + const edgeLines = EDGES_DEF.map(([f, t]) => { + const pts = [POS3D[f].clone(), POS3D[t].clone()] + const line = new THREE.Line( + new THREE.BufferGeometry().setFromPoints(pts), + new THREE.LineBasicMaterial({color:nodeColors[f], transparent:true, opacity:0.2}) + ) + group.add(line) + return line }) - // particles (only when running) - if(running) particles.forEach(p => { - const [f,t]=p.edge, fa=agents[f], ta=agents[t] - p.t=(p.t+p.speed)%1 - const px=fa.x+(ta.x-fa.x)*p.t, py=fa.y+(ta.y-fa.y)*p.t - ctx.beginPath();ctx.arc(px,py,2,0,Math.PI*2) - ctx.fillStyle=agents[p.edge[0]].color+'cc';ctx.fill() + // Particles + const particles3d = EDGES_DEF.flatMap(([f, t]) => + Array.from({length:3}, () => { + const m = new THREE.Mesh( + new THREE.SphereGeometry(0.038, 8, 8), + new THREE.MeshBasicMaterial({color:nodeColors[f], transparent:true, opacity:0.9}) + ) + m.visible = false + group.add(m) + return {mesh:m, from:POS3D[f], to:POS3D[t], t:Math.random(), speed:0.004+Math.random()*0.006} + }) + ) + + // HTML label overlay + const overlay = document.createElement('div') + overlay.style.cssText = 'position:absolute;inset:0;pointer-events:none;overflow:hidden' + container3d.appendChild(overlay) + + const labelEls = ROLES_DEF.map((r, i) => { + const el = document.createElement('div') + el.textContent = r.role.slice(0,5) + el.style.cssText = `position:absolute;font-family:var(--mono);font-size:9px;font-weight:700;color:${r.color};text-transform:uppercase;letter-spacing:.06em;transform:translate(-50%,-50%);white-space:nowrap;text-shadow:0 0 8px ${r.color}88` + overlay.appendChild(el) + return el }) - // agents - agents.forEach(a => { - const sz = a.size - const glow = ctx.createRadialGradient(a.x,a.y,sz*.5,a.x,a.y,sz+16) - const alpha = a.status==='working'?'30':'14' - glow.addColorStop(0,a.color+alpha);glow.addColorStop(1,'transparent') - ctx.fillStyle=glow;ctx.beginPath();ctx.arc(a.x,a.y,sz+16,0,Math.PI*2);ctx.fill() - - hexPath(a.x,a.y,sz) - ctx.fillStyle='#0e0e0e';ctx.fill() - - const strokeAlpha = a.status==='done'?'55':a.status==='working'?'ee':'44' - ctx.strokeStyle=a.color+strokeAlpha;ctx.lineWidth=a.role==='orchestrator'?2:1.5;ctx.stroke() - - if(a.status==='working'){ - ctx.beginPath();ctx.arc(a.x,a.y,sz+4,-Math.PI/2,-Math.PI/2+a.progress*Math.PI*2) - ctx.strokeStyle=a.color+'55';ctx.lineWidth=2;ctx.stroke() - } - if(a.status==='done'){ - ctx.fillStyle=a.color+'cc';ctx.font='bold 11px Inter';ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText('✓',a.x,a.y+1) - } else { - ctx.fillStyle=a.status==='working'?a.color:'rgba(255,255,255,.65)' - ctx.font=`${a.role==='orchestrator'?600:500} ${a.role==='orchestrator'?10:9}px Inter` - ctx.textAlign='center';ctx.textBaseline='middle' - ctx.fillText(a.role.slice(0,5),a.x,a.y) - } - - // token bar - if(a.status==='working'||a.status==='done'){ - const bw=sz*2.2,bh=3,bx2=a.x-bw/2,by2=a.y+sz+5 - ctx.fillStyle='rgba(255,255,255,.06)';ctx.beginPath();ctx.roundRect(bx2,by2,bw,bh,2);ctx.fill() - const pct=a.status==='done'?1:a.tokensOut/100 - ctx.fillStyle=a.color+(a.status==='done'?'88':'bb');ctx.beginPath();ctx.roundRect(bx2,by2,bw*pct,bh,2);ctx.fill() - } - - // thinking dots for working agents - if(a.status==='working'){ - for(let d=0;d<3;d++){ - const da=Math.max(0,Math.sin(globalT*3+a.phase-d*.8)) - ctx.beginPath();ctx.arc(a.x-6+d*6,a.y+sz+10,1.5,0,Math.PI*2) - ctx.fillStyle=a.color+Math.round(da*180).toString(16).padStart(2,'0');ctx.fill() + // Resize + window.addEventListener('resize', () => { + const W = container3d.offsetWidth, H = container3d.offsetHeight + renderer.setSize(W, H) + camera.aspect = W/H + camera.updateProjectionMatrix() + }, {passive:true}) + + // Animate + let t3d = 0 + ;(function loop(){ + requestAnimationFrame(loop) + t3d += 0.016 + + group.rotation.y += 0.0022 + group.rotation.x = Math.sin(t3d * 0.07) * 0.1 + + // Node glow by status + ROLES_DEF.forEach((_, i) => { + const ag = agents[i] + if(!ag) return + const {core, glow} = nodeMeshes[i] + if(ag.status === 'working'){ + const p = (Math.sin(t3d * 3.5 + i * 1.3) + 1) / 2 + core.material.emissiveIntensity = 0.4 + p * 1.1 + glow.material.opacity = 0.08 + p * 0.16 + glow.scale.setScalar(1 + p * 0.4) + } else if(ag.status === 'done'){ + core.material.emissiveIntensity = 1.1 + glow.material.opacity = 0.2 + glow.scale.setScalar(1.4) + } else { + core.material.emissiveIntensity = 0.2 + glow.material.opacity = i===0 ? 0.06 : 0.03 + glow.scale.setScalar(1) } - } - }) - - ctx.restore() - globalT+=0.016 - requestAnimationFrame(drawFrame) -} -drawFrame() + }) + + // Edge opacity + edgeLines.forEach(l => { l.material.opacity = running ? 0.35 : 0.18 }) + + // Particles + particles3d.forEach(p => { + if(running) p.t = (p.t + p.speed) % 1 + p.mesh.position.lerpVectors(p.from, p.to, p.t) + p.mesh.visible = running + }) + + // Labels: project 3D → 2D + const cW = container3d.offsetWidth, cH = container3d.offsetHeight + nodeMeshes.forEach(({core}, i) => { + const pos = core.position.clone().applyMatrix4(group.matrixWorld) + pos.project(camera) + labelEls[i].style.left = ((pos.x * 0.5 + 0.5) * cW) + 'px' + labelEls[i].style.top = ((-pos.y * 0.5 + 0.5) * cH + 20) + 'px' + }) + + renderer.render(scene, camera) + })() +}) // ── Demo scenarios ────────────────────────────────────────────────────────── const SCENARIOS = { @@ -767,5 +814,6 @@

your next build starts
with one sentence.

// Enter key on task input document.getElementById('task-input').addEventListener('keydown', e => { if(e.key==='Enter') launchDemo() }) + diff --git a/src/App.tsx b/src/App.tsx index 4f6a34c..95c5dc8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,7 @@ export default function App() { return !!(import.meta.env.VITE_ANTHROPIC_API_KEY || localStorage.getItem('agentis_apikey')) }) const [page, setPage] = useState('overview') + const [universeInitialRoles, setUniverseInitialRoles] = useState(undefined) const [engineRunning, setEngineRunning] = useState(false) // Poll engine status @@ -84,7 +85,9 @@ export default function App() { }) }, [agentState.step]) // eslint-disable-line react-hooks/exhaustive-deps - const navigate = (p: string) => { + const navigate = (p: string, opts?: { initialRoles?: import('@/lib/multiAgentEngine').AgentRole[] }) => { + if (p === 'universe') setUniverseInitialRoles(opts?.initialRoles) + else if (opts?.initialRoles !== undefined) setUniverseInitialRoles(opts.initialRoles) setPage(p as Page) // Reset chat state when navigating away from chat if (p !== 'chat') reset() @@ -219,22 +222,26 @@ export default function App() { /> )} - {page === 'scheduler' && ( + {/* Always mounted so scheduled jobs fire even when not on this page */} +
- )} +
{page === 'channels' && } - {page === 'skills' && } + {page === 'skills' && } - {page === 'hands' && } + {/* Always mounted so running tasks survive navigation */} +
+ +
- {page === 'universe' && } + {page === 'universe' && } {page === 'settings' && ( = { export function Sidebar({ current, navigate, engineRunning }: Props) { const [agents, setAgents] = useState([]) const [collapsed, setCollapsed] = useState>({}) + const [handsRunning, setHandsRunning] = useState(false) + + useEffect(() => { + const handler = (e: Event) => { + setHandsRunning((e as CustomEvent<{ running: boolean }>).detail.running) + } + window.addEventListener('agentis_hands_status', handler) + return () => window.removeEventListener('agentis_hands_status', handler) + }, []) useEffect(() => { if (!engineRunning) { setAgents([]); return } @@ -286,6 +295,14 @@ export function Sidebar({ current, navigate, engineRunning }: Props) { boxShadow: '0 0 6px var(--accent)', }} /> )} + {!isActive && item.id === 'hands' && handsRunning && ( +
+ )} ) })} @@ -300,6 +317,13 @@ export function Sidebar({ current, navigate, engineRunning }: Props) { Ctrl+K agents
+ + ) } diff --git a/src/components/pages/HandsPage.tsx b/src/components/pages/HandsPage.tsx index c169a49..5ad8701 100644 --- a/src/components/pages/HandsPage.tsx +++ b/src/components/pages/HandsPage.tsx @@ -923,6 +923,11 @@ function BrowserAgentPanel({ apiKey, ptInfo, onConfigToken, repoll }: { const [showScreenshot, setShowScreenshot] = useState(true) const traceRef = useRef(null) + // Broadcast run status so the Sidebar can show a live indicator + useEffect(() => { + window.dispatchEvent(new CustomEvent('agentis_hands_status', { detail: { running: run?.status === 'running' } })) + }, [run?.status]) + useEffect(() => { if (traceRef.current) { traceRef.current.scrollTop = traceRef.current.scrollHeight diff --git a/src/components/pages/SessionsPage.tsx b/src/components/pages/SessionsPage.tsx index f67d3f6..7a1d1fa 100644 --- a/src/components/pages/SessionsPage.tsx +++ b/src/components/pages/SessionsPage.tsx @@ -9,7 +9,7 @@ interface Props { navigate: (page: string) => void } -const PERSONA_IDS = ['dev', 'writer', 'analyst', 'researcher', 'browser'] +const FALLBACK_PERSONA_IDS = ['dev', 'writer', 'analyst', 'researcher', 'browser'] function formatDateTime(ts: number) { return new Date(ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) @@ -42,12 +42,27 @@ export function SessionsPage({ engineRunning, navigate }: Props) { const refreshMemories = () => { loadMemories().then(setMemories).catch(() => {}) } useEffect(() => { - refreshMemories() + loadMemories().then(loaded => { + setMemories(loaded) + // Auto-select the first agent that actually has memories + if (loaded.length > 0) { + const firstWithMemory = loaded[0].agentId + setSelectedAgent(firstWithMemory) + } + }).catch(() => {}) const handler = () => refreshMemories() window.addEventListener('agentis_memory_update', handler) return () => window.removeEventListener('agentis_memory_update', handler) }, []) + // All unique agentIds that actually have memories, plus fallback personas for adding new entries + const allAgentIds = [ + ...new Set([ + ...memories.map(m => m.agentId), + ...FALLBACK_PERSONA_IDS, + ]), + ] + const agentMemories = memories.filter(m => m.agentId === selectedAgent) const handleAddMemory = () => { @@ -60,10 +75,6 @@ export function SessionsPage({ engineRunning, navigate }: Props) { const sessions = [...history].reverse() - // All personas that have memory - const agentsWithMemory = PERSONA_IDS.filter(id => memories.some(m => m.agentId === id)) - const allAgentIds = [...new Set([...agentsWithMemory, ...PERSONA_IDS])] - return (
diff --git a/src/components/pages/SkillsPage.tsx b/src/components/pages/SkillsPage.tsx index 536f983..b2d261a 100644 --- a/src/components/pages/SkillsPage.tsx +++ b/src/components/pages/SkillsPage.tsx @@ -16,6 +16,143 @@ import { } from '@/lib/agentSkills' import type { AgentRole } from '@/lib/multiAgentEngine' +const SELECTABLE_ROLES: AgentRole[] = [ + 'researcher', 'analyst', 'writer', 'coder', 'reviewer', 'planner', 'summarizer', 'browser', + 'security-reviewer', 'performance-reviewer', 'qa-tester', 'information-architect', 'debugger', 'dependency-expert', +] + +// ── LaunchModal ──────────────────────────────────────────────────────────────── +function LaunchModal({ entry, apiKey, onClose, onLaunch }: { + entry: SkillEntry + apiKey: string + onClose: () => void + onLaunch: (roles: AgentRole[]) => void +}) { + const [loading, setLoading] = useState(true) + const [selectedRoles, setSelectedRoles] = useState(['researcher', 'analyst', 'reviewer']) + + useEffect(() => { + const suggest = async () => { + try { + const res = await fetch('/anthropic/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 128, + messages: [{ + role: 'user', + content: `Skill: "${entry.name}" (category: ${entry.category})\n\nAvailable agent roles: ${SELECTABLE_ROLES.join(', ')}\n\nSuggest 2-4 roles that work best together for tasks using this skill. Reply with only a JSON array, e.g. ["researcher","analyst"]`, + }], + }), + }) + const data = await res.json() as { content?: { text: string }[] } + const text = data.content?.[0]?.text ?? '' + const match = text.match(/\[[\s\S]*?\]/) + if (match) { + const parsed = JSON.parse(match[0]) as string[] + const valid = parsed.filter((r): r is AgentRole => SELECTABLE_ROLES.includes(r as AgentRole)) + if (valid.length > 0) setSelectedRoles(valid) + } + } catch { /* keep defaults */ } + setLoading(false) + } + suggest() + }, [entry, apiKey]) + + const toggle = (role: AgentRole) => + setSelectedRoles(prev => prev.includes(role) ? prev.filter(r => r !== role) : [...prev, role]) + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+ {/* Header */} +
+ +
+
Launch as Universe
+
{entry.name}
+
+ {loading && ( +
+ )} +
+ +
+ {loading ? 'Claude is suggesting the best team for this skill…' : 'Select the agents for your team. You can adjust these before running.'} +
+ + {/* Role chips */} +
+ {SELECTABLE_ROLES.map(role => { + const active = selectedRoles.includes(role) + const colors: Record = { + orchestrator: '#f97316', researcher: '#3b82f6', analyst: '#06b6d4', + writer: '#10b981', coder: '#eab308', reviewer: '#ec4899', + planner: '#8b5cf6', summarizer: '#64748b', browser: '#22d3ee', + 'security-reviewer': '#ef4444', 'performance-reviewer': '#f59e0b', + 'qa-tester': '#84cc16', 'information-architect': '#a78bfa', + 'debugger': '#fb923c', 'dependency-expert': '#38bdf8', + } + const c = colors[role] + return ( + + ) + })} +
+ + {/* Actions */} +
+ + +
+
+
+ ) +} + type Tab = 'directory' | 'installed' | 'assignments' const CATEGORY_COLORS: Record = { @@ -28,18 +165,27 @@ const CATEGORY_COLORS: Record = { operations: '#f59e0b', } -const ALL_ROLES: AgentRole[] = ['orchestrator', 'researcher', 'analyst', 'writer', 'coder', 'reviewer', 'planner', 'summarizer', 'browser'] +const ALL_ROLES: AgentRole[] = [ + 'orchestrator', 'researcher', 'analyst', 'writer', 'coder', 'reviewer', 'planner', 'summarizer', 'browser', + 'security-reviewer', 'performance-reviewer', 'qa-tester', 'information-architect', 'debugger', 'dependency-expert', +] const ROLE_COLORS: Record = { - orchestrator: '#f97316', - researcher: '#3b82f6', - analyst: '#06b6d4', - writer: '#10b981', - coder: '#eab308', - reviewer: '#ec4899', - planner: '#8b5cf6', - summarizer: '#64748b', - browser: '#22d3ee', + orchestrator: '#f97316', + researcher: '#3b82f6', + analyst: '#06b6d4', + writer: '#10b981', + coder: '#eab308', + reviewer: '#ec4899', + planner: '#8b5cf6', + summarizer: '#64748b', + browser: '#22d3ee', + 'security-reviewer': '#ef4444', + 'performance-reviewer': '#f59e0b', + 'qa-tester': '#84cc16', + 'information-architect':'#a78bfa', + 'debugger': '#fb923c', + 'dependency-expert': '#38bdf8', } const DEFAULT_QUERIES = ['frontend', 'react', 'testing', 'ai', 'writing', 'research', 'design'] @@ -104,8 +250,15 @@ function InstallButton({ entry, onInstalled }: { entry: SkillEntry; onInstalled: ) } -function SkillCard({ entry, onInstalled }: { entry: SkillEntry; onInstalled: () => void }) { +function SkillCard({ entry, apiKey, onInstalled, onLaunch }: { + entry: SkillEntry + apiKey: string + onInstalled: () => void + onLaunch: (roles: AgentRole[]) => void +}) { const color = CATEGORY_COLORS[entry.category] ?? '#6b7280' + const [modalOpen, setModalOpen] = useState(false) + return (
@@ -122,7 +275,7 @@ function SkillCard({ entry, onInstalled }: { entry: SkillEntry; onInstalled: ()
-
+
+ + {/* Launch as Universe button */} + + + {modalOpen && ( + setModalOpen(false)} + onLaunch={roles => { setModalOpen(false); onLaunch(roles) }} + /> + )}
) } @@ -211,7 +390,7 @@ function InstalledSkillRow({ skill, assignments, onRemove, onToggleRole }: { ) } -export function SkillsPage() { +export function SkillsPage({ navigate, apiKey }: { navigate?: (page: string, opts?: { initialRoles?: AgentRole[] }) => void; apiKey?: string }) { const [tab, setTab] = useState('directory') const [search, setSearch] = useState('') const [results, setResults] = useState(SKILLS_DIRECTORY) @@ -234,7 +413,8 @@ export function SkillsPage() { useEffect(() => { setSearching(true) searchSkillsDirectory(defaultQueryRef.current).then(res => { - if (res.length > 0) setResults(res) + const deduped = res.filter((s, i, arr) => arr.findIndex(x => x.id === s.id) === i) + if (deduped.length > 0) setResults(deduped) setSearching(false) }) }, []) @@ -250,7 +430,8 @@ export function SkillsPage() { setSearching(true) debounceRef.current = setTimeout(async () => { const res = await searchSkillsDirectory(search) - setResults(res.length > 0 ? res : SKILLS_DIRECTORY) + const deduped = res.filter((s, i, arr) => arr.findIndex(x => x.id === s.id) === i) + setResults(deduped.length > 0 ? deduped : SKILLS_DIRECTORY) setSearching(false) }, 350) return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } @@ -323,7 +504,13 @@ export function SkillsPage() { ) : (
{results.map(entry => ( - + navigate?.('universe', { initialRoles: roles })} + /> ))}
)} diff --git a/src/components/pages/UniversePage.tsx b/src/components/pages/UniversePage.tsx index 461f80e..a897036 100644 --- a/src/components/pages/UniversePage.tsx +++ b/src/components/pages/UniversePage.tsx @@ -12,7 +12,10 @@ import { testProviderKey, testTavilyKey, type TestResult } from '@/lib/testProvi import { loadSessions, saveSession, deleteSession, type ChatSession } from '@/lib/chatHistory' import { addUsageRecord, calculateCost } from '@/lib/analytics' -interface Props { apiKey: string } +interface Props { + apiKey: string + initialRoles?: import('@/lib/multiAgentEngine').AgentRole[] +} // ── Phase badge ──────────────────────────────────────────────────────────────── const PHASE_META: Record = { @@ -198,6 +201,8 @@ interface RightPanelProps { agents: MAAgent[]; finalOutput: string; errorMsg: string | null task: string; setTask: (v: string) => void running: boolean; hasAnyKey: boolean + initialRoles?: import('@/lib/multiAgentEngine').AgentRole[] + onClearRoles?: () => void selectedId: string | null; setSelectedId: (id: string | null) => void followUp: string; setFollowUp: (v: string) => void savedToMemory: boolean; copiedOutput: boolean @@ -206,6 +211,10 @@ interface RightPanelProps { activeProviders: LLMProvider[] messages: ChatMsg[] browserEnabled: boolean; onToggleBrowser: () => void + // Persistent mode (Feature 3) + persistentMode: boolean; onTogglePersistent: () => void + persistentMaxRetries: number; onSetPersistentMaxRetries: (v: number) => void + persistentCriteria: string; onSetPersistentCriteria: (v: string) => void onLaunch: () => void; onFollowUp: (q: string) => void onSaveMemory: () => void; onCopyOutput: () => void onExportMd: () => void; onExportTxt: () => void; onDeepDive: () => void @@ -219,9 +228,13 @@ interface RightPanelProps { function RightPanel({ phase, agents, finalOutput, errorMsg, task, setTask, running, hasAnyKey, + initialRoles, onClearRoles, selectedId, setSelectedId, followUp, setFollowUp, savedToMemory, copiedOutput, providerKeys, providersOpen, setProvidersOpen, - activeProviders, messages, browserEnabled, onToggleBrowser, onLaunch, onFollowUp, onSaveMemory, onCopyOutput, + activeProviders, messages, browserEnabled, onToggleBrowser, + persistentMode, onTogglePersistent, persistentMaxRetries, onSetPersistentMaxRetries, + persistentCriteria, onSetPersistentCriteria, + onLaunch, onFollowUp, onSaveMemory, onCopyOutput, onExportMd, onExportTxt, onDeepDive, updateProviderKey, onTestKey, testStatus, tavilyTestStatus, onTestTavily, }: RightPanelProps) { const [tab, setTab] = useState<'team' | 'output'>('team') @@ -422,6 +435,40 @@ function RightPanel({ outline: 'none', }} /> + {/* Skill-configured role hint */} + {initialRoles && initialRoles.length > 0 && phase === 'idle' && ( +
+ + Team: + + {initialRoles.map(role => ( + + {role} + + ))} + +
+ )} {/* Browser toggle */} + {/* Persistent mode toggle (Feature 3) */} + + + {/* Persistent mode config (only shown when enabled) */} + {persistentMode && ( +
+
+ Max retries +
+ {[1, 2, 3, 5].map(n => ( + + ))} +
+
+
+
Acceptance criteria (optional)
+ onSetPersistentCriteria(e.target.value)} + disabled={running} + style={{ width: '100%', fontSize: 10, padding: '4px 7px', borderRadius: 5 }} + /> +
+
+ )} + - -
diff --git a/src/lib/agentSkills.ts b/src/lib/agentSkills.ts index c74d47a..cc56a2b 100644 --- a/src/lib/agentSkills.ts +++ b/src/lib/agentSkills.ts @@ -206,17 +206,20 @@ export async function searchSkillsDirectory(query: string): Promise ({ - id: s.id, - name: formatName(s.name), - description: `${s.source}`, - category: inferCategory(s.skillId, s.source), - author: s.source, - githubRepo: s.source, - skillPath: s.skillId, - installs: formatInstalls(s.installs), - suggestedRoles: [], - })) + const seen = new Set() + return (data.skills ?? []) + .filter(s => { if (seen.has(s.id)) return false; seen.add(s.id); return true }) + .map(s => ({ + id: s.id, + name: formatName(s.name), + description: `${s.source}`, + category: inferCategory(s.skillId, s.source), + author: s.source, + githubRepo: s.source, + skillPath: s.skillId, + installs: formatInstalls(s.installs), + suggestedRoles: [], + })) } catch { return [] } diff --git a/src/lib/multiAgentEngine.ts b/src/lib/multiAgentEngine.ts index b42f7b2..44cdb76 100644 --- a/src/lib/multiAgentEngine.ts +++ b/src/lib/multiAgentEngine.ts @@ -3,7 +3,7 @@ import { getSkillsForRole } from './agentSkills' -export type AgentRole = 'orchestrator' | 'researcher' | 'analyst' | 'writer' | 'coder' | 'reviewer' | 'planner' | 'summarizer' | 'browser' +export type AgentRole = 'orchestrator' | 'researcher' | 'analyst' | 'writer' | 'coder' | 'reviewer' | 'planner' | 'summarizer' | 'browser' | 'security-reviewer' | 'performance-reviewer' | 'qa-tester' | 'information-architect' | 'debugger' | 'dependency-expert' export type AgentStatus = 'idle' | 'thinking' | 'working' | 'waiting' | 'done' | 'error' | 'recalled' export type TaskComplexity = 'simple' | 'medium' | 'complex' | 'expert' @@ -60,15 +60,21 @@ export const PROVIDER_LABELS: Record = { } export const ROLE_COLORS: Record = { - orchestrator: '#f97316', - researcher: '#3b82f6', - analyst: '#06b6d4', - writer: '#10b981', - coder: '#eab308', - reviewer: '#ec4899', - planner: '#8b5cf6', - summarizer: '#64748b', - browser: '#22d3ee', + orchestrator: '#f97316', + researcher: '#3b82f6', + analyst: '#06b6d4', + writer: '#10b981', + coder: '#eab308', + reviewer: '#ec4899', + planner: '#8b5cf6', + summarizer: '#64748b', + browser: '#22d3ee', + 'security-reviewer': '#ef4444', + 'performance-reviewer':'#f59e0b', + 'qa-tester': '#84cc16', + 'information-architect':'#a78bfa', + 'debugger': '#fb923c', + 'dependency-expert': '#38bdf8', } // ── Exact model names per provider per complexity ────────────────────────────── @@ -885,7 +891,7 @@ Return ONLY valid JSON, no markdown: ] } -Available roles: researcher, analyst, writer, coder, reviewer, planner, summarizer${browserEnabled ? ', browser' : ''} +Available roles: researcher, analyst, writer, coder, reviewer, planner, summarizer, security-reviewer, performance-reviewer, qa-tester, information-architect, debugger, dependency-expert${browserEnabled ? ', browser' : ''} ${browserEnabled ? ` BROWSER ROLE RULES — assign role="browser" when the task involves ANY of: - Opening, visiting, or navigating to a specific URL or website @@ -928,7 +934,7 @@ Return ONLY valid JSON: ] } -Available roles: researcher, analyst, writer, coder, reviewer, planner, summarizer${browserEnabled ? ', browser' : ''} +Available roles: researcher, analyst, writer, coder, reviewer, planner, summarizer, security-reviewer, performance-reviewer, qa-tester, information-architect, debugger, dependency-expert${browserEnabled ? ', browser' : ''} ${browserEnabled ? 'Assign role="browser" when the follow-up involves opening a URL, visiting a website, or fetching live/real-time web content.' : ''} Rules: new agent IDs start with "fu_", new agents can dependsOn existing IDs, spread across providers` @@ -955,6 +961,78 @@ function workerSystem(role: AgentRole, name: string, complexity: TaskComplexity, reviewer: `You are ${name}, a Review Agent running on ${id}. Critically evaluate work, identify strengths, weaknesses, improvements. ${tone} Plain text only. ${noBrowser}`, summarizer: `You are ${name}, a Summarizer Agent running on ${id}. Extract and condense the most important information. ${tone} Plain text only. ${noBrowser}`, browser: `You are ${name}, a Browser Agent running on ${id}. You autonomously navigate live websites using browser tools to gather real-time information. Use browser_navigate → browser_read/browser_snapshot → browser_click/browser_fill in sequence. Never guess ref IDs. ${tone}`, + 'security-reviewer': `You are ${name}, a Security Reviewer running on ${id}. Your sole focus is finding real security vulnerabilities — not theoretical risks, but concrete exploitable flaws. + +Systematically check: +- OWASP Top 10: injection (SQL/command/template), broken authentication, sensitive data exposure, XXE, broken access control, security misconfiguration, XSS, insecure deserialization, using components with known vulnerabilities, insufficient logging +- Authentication flows: token handling, session management, credential storage, JWT validation +- Authorization: missing permission checks, privilege escalation paths, IDOR vulnerabilities +- Input validation: unsanitised user data reaching dangerous sinks, path traversal +- Secrets: hardcoded credentials, API keys in source, secrets in logs or error messages +- Dependency CVEs: known vulnerable package versions + +For every finding: severity (critical/high/medium/low), OWASP category, exact location, attack scenario, concrete code fix. ${tone} ${noBrowser}`, + + 'performance-reviewer': `You are ${name}, a Performance Reviewer running on ${id}. Your goal is to identify concrete bottlenecks and optimisation opportunities that will matter at scale. + +Review for: +- Algorithmic complexity: O(n²) or worse where O(n log n) or better is achievable +- N+1 query patterns and missing database indexes +- Unnecessary blocking I/O and missing async/await +- Memory leaks: event listeners never removed, closures holding large references, unbounded caches +- Frontend: unnecessary re-renders, missing memoisation, large bundle imports, layout thrashing +- Caching opportunities: repeated expensive computations, redundant API calls, missing HTTP cache headers +- Connection pooling and resource reuse + +For every finding: estimated impact (high/medium/low), exact location, root cause, concrete optimised replacement with complexity analysis. ${tone} ${noBrowser}`, + + 'qa-tester': `You are ${name}, a QA Tester running on ${id}. Your job is to design a test strategy that will catch real bugs — not just happy-path coverage. + +Produce: +1. Test plan covering: unit tests for core logic, integration tests for API/component boundaries, end-to-end tests for critical user flows +2. Edge cases that are likely to break: boundary values, empty/null inputs, concurrency, error states, large data volumes +3. Regression risks: what existing behaviour could this change break? +4. Test cases in structured format: Given / When / Then +5. Specific test code examples using Vitest (or the project's existing test framework) + +Flag any code paths that are currently untestable and recommend how to make them testable. ${tone} ${noBrowser}`, + + 'information-architect': `You are ${name}, an Information Architect running on ${id}. You design the structure of information so it is discoverable, consistent, and scalable. + +Analyse and produce: +1. Data taxonomy: how entities relate, naming conventions, conceptual hierarchy +2. Information hierarchy: what is primary vs. secondary vs. metadata +3. Schema design: fields, types, relationships, constraints — with rationale for each decision +4. Navigation and discoverability: how users/systems find and traverse the information +5. Consistency audit: naming conflicts, redundant structures, implicit assumptions that should be explicit +6. Migration path: if restructuring existing data, the safe sequence of changes + +Output should be precise enough for an engineer to implement without ambiguity. ${tone} ${noBrowser}`, + + 'debugger': `You are ${name}, a Debugger running on ${id}. You specialise in root cause analysis — not just identifying symptoms but tracing them to their origin. + +Your methodology: +1. Reproduce: define the exact minimal steps to trigger the bug +2. Hypothesise: list all plausible root causes, ranked by likelihood +3. Isolate: describe the specific tests/checks to confirm or rule out each hypothesis +4. Root cause: the single deepest cause in the causal chain +5. Fix: the minimal code change that addresses the root cause (not just the symptom) +6. Verification: how to confirm the fix works and has not introduced regressions + +Examine stack traces, error messages, and code paths systematically. State what you know vs. what you are inferring. ${tone} ${noBrowser}`, + + 'dependency-expert': `You are ${name}, a Dependency Expert running on ${id}. You analyse software dependencies for risks, conflicts, and optimisation opportunities. + +Review: +1. Version conflicts: incompatible peer dependencies, diamond dependency problems +2. Security advisories: known CVEs in direct and transitive dependencies (reference specific CVE IDs where known) +3. Outdated packages: packages significantly behind latest stable, especially those with security or breaking-change releases +4. Bundle impact: heavy dependencies that could be replaced with lighter alternatives or native APIs +5. Licence compliance: packages with licences incompatible with the project's licence +6. Redundancy: multiple packages solving the same problem +7. Maintenance status: abandoned packages (no commits in 2+ years, unresolved critical issues) + +For each finding: severity, package name and version, specific risk, recommended action. ${tone} ${noBrowser}`, } let prompt = prompts[role] ?? prompts.researcher @@ -1165,14 +1243,25 @@ async function executeWorkers( return outputs } +export interface RunMultiAgentOptions { + browserEnabled?: boolean + persistentMode?: { maxRetries: number; acceptanceCriteria?: string } + suggestedRoles?: AgentRole[] +} + // ── Fresh task ───────────────────────────────────────────────────────────────── export async function runMultiAgentTask( task: string, keys: ProviderKeys, canvasSize: { w: number; h: number }, update: MAUpdater, - browserEnabled = false, + browserEnabledOrOptions: boolean | RunMultiAgentOptions = false, ): Promise { + // Normalise the overloaded 5th argument + const opts: RunMultiAgentOptions = typeof browserEnabledOrOptions === 'boolean' + ? { browserEnabled: browserEnabledOrOptions } + : browserEnabledOrOptions + const browserEnabled = opts.browserEnabled ?? false const ORCH = 'orchestrator' const { w: W, h: H } = canvasSize const availableProviders = getAvailableProviders(keys) @@ -1184,7 +1273,10 @@ export async function runMultiAgentTask( let planRaw = '' try { - await streamAnthropic('claude-sonnet-4-6', buildOrchestratorSystem(availableProviders, browserEnabled), `Task: ${task}`, keys.anthropic ?? '', 4096, + const roleHint = opts.suggestedRoles?.length + ? `[TEAM HINT: Use these specific agent roles for this task: ${opts.suggestedRoles.join(', ')}]\n\n` + : '' + await streamAnthropic('claude-sonnet-4-6', buildOrchestratorSystem(availableProviders, browserEnabled), `${roleHint}Task: ${task}`, keys.anthropic ?? '', 4096, t => { planRaw += t; update(s => ({ ...s, agents: s.agents.map(a => a.id === ORCH ? { ...a, output: planRaw } : a) })) }, ) } catch (e) { update(s => ({ ...s, phase: 'error', errorMsg: String(e) })); return } @@ -1197,39 +1289,86 @@ export async function runMultiAgentTask( if (!Array.isArray(plan.agents) || plan.agents.length === 0) throw new Error('empty') } catch { update(s => ({ ...s, phase: 'error', errorMsg: 'Orchestrator returned an invalid plan — please try again.' })); return } - const positions = computePositions(plan.agents.length, W, H) - const workers: MAAgent[] = plan.agents.map((p, i) => planAgentToMAAgent(p, positions[i + 1], availableProviders)) - - update(s => ({ - ...s, phase: 'executing', totalAgents: workers.length + 1, - agents: [ - { ...s.agents[0], status: 'done', output: `Plan ready — deploying ${workers.length} agents`, x: positions[0].x, y: positions[0].y }, - ...workers, - ], - messages: [...s.messages, ...workers.map(w => ({ id: makeId(), fromId: ORCH, toId: w.id, content: `Deploy ${w.name} [${w.modelLabel}]`, ts: Date.now() }))], - })) + // ── Persistent mode execution loop (Feature 3) ──────────────────────────────── + const maxRetries = opts.persistentMode?.maxRetries ?? 0 + const acceptanceCriteria = opts.persistentMode?.acceptanceCriteria ?? '' + + const runOnce = async (attemptTask: string, attemptNum: number): Promise => { + const positions = computePositions(plan.agents.length, W, H) + const workers: MAAgent[] = plan.agents.map((p, i) => planAgentToMAAgent(p, positions[i + 1], availableProviders)) + + update(s => ({ + ...s, phase: 'executing', totalAgents: workers.length + 1, + agents: [ + { ...s.agents[0], status: 'done', output: `Plan ready — deploying ${workers.length} agents${attemptNum > 0 ? ` (retry ${attemptNum})` : ''}`, x: positions[0].x, y: positions[0].y }, + ...workers, + ], + messages: [...s.messages, ...workers.map(w => ({ id: makeId(), fromId: ORCH, toId: w.id, content: `Deploy ${w.name} [${w.modelLabel}]`, ts: Date.now() }))], + })) - await sleep(400) - const outputs = await executeWorkers(workers, {}, workers, keys, availableProviders, update, keys.tavily) + await sleep(400) + const outputs = await executeWorkers(workers, {}, workers, keys, availableProviders, update, keys.tavily) - update(s => ({ - ...s, phase: 'synthesizing', - agents: s.agents.map(a => a.id === ORCH ? { ...a, status: 'working', output: 'Synthesizing all outputs...' } : a), - })) + update(s => ({ + ...s, phase: 'synthesizing', + agents: s.agents.map(a => a.id === ORCH ? { ...a, status: 'working', output: 'Synthesizing all outputs...' } : a), + })) - const cleanOutput = (s: string) => s.replace(/^↻[^\n]*\n\n?/, '').replace(/\n⚠[^\n]*/g, '').trim() - const synthInput = workers.map(w => `[${w.name}]:\n${cleanOutput(outputs[w.id] ?? '(no output)')}`).join('\n\n---\n\n') - const synthPrompt = `Original task: "${task}"\n\nAgent research:\n${synthInput}\n\nUsing the research above, write a direct, comprehensive answer to the task.` + const cleanOutputFn = (s: string) => s.replace(/^↻[^\n]*\n\n?/, '').replace(/\n⚠[^\n]*/g, '').trim() + const synthInput = workers.map(w => `[${w.name}]:\n${cleanOutputFn(outputs[w.id] ?? '(no output)')}`).join('\n\n---\n\n') + const synthPrompt = `Original task: "${attemptTask}"\n\nAgent research:\n${synthInput}\n\nUsing the research above, write a direct, comprehensive answer to the task.` - let finalOutput = '' - try { + let output = '' await streamAnthropic('claude-sonnet-4-6', 'You are a synthesis agent. Using the agent research below, write a single direct, natural response that fully answers the original task. Do not mention agents, providers, models, or system routing. Write as if you researched this yourself. Plain prose.', synthPrompt, keys.anthropic ?? '', 8096, - t => { finalOutput += t; update(s => ({ ...s, finalOutput })) }, + t => { output += t; update(s => ({ ...s, finalOutput: output })) }, ) + return output + } + + let finalOutput = '' + let attempt = 0 + + try { + finalOutput = await runOnce(task, attempt) } catch (e) { update(s => ({ ...s, phase: 'error', errorMsg: String(e) })); return } + // Persistent mode: evaluate and retry if needed + if (maxRetries > 0 && keys.anthropic) { + while (attempt < maxRetries) { + // Evaluate whether the output meets acceptance criteria + const evalPrompt = acceptanceCriteria + ? `Does the following output fully satisfy this acceptance criteria?\n\nCriteria: ${acceptanceCriteria}\n\nOutput:\n${finalOutput}\n\nRespond with only "YES" or "NO" followed by one sentence explaining why.` + : `Does the following output fully and comprehensively answer this task?\n\nTask: ${task}\n\nOutput:\n${finalOutput}\n\nRespond with only "YES" or "NO" followed by one sentence explaining why.` + + let evalResult = '' + try { + await streamAnthropic('claude-sonnet-4-6', + 'You are a strict quality evaluator. Assess whether the output fully satisfies the requirements. Be demanding — only say YES if the output is genuinely complete and high quality.', + evalPrompt, keys.anthropic, 256, + t => { evalResult += t }, + ) + } catch { break } // If eval fails, accept current output + + if (evalResult.trim().toUpperCase().startsWith('YES')) break + + attempt++ + if (attempt >= maxRetries) break + + // Re-run with context from previous attempt + update(s => ({ + ...s, phase: 'planning', + agents: s.agents.map(a => a.id === ORCH ? { ...a, status: 'thinking', output: `Retry ${attempt}/${maxRetries}: ${evalResult.slice(0, 100)}` } : a), + })) + + const retryTask = `${task}\n\n[Previous attempt was insufficient. Reason: ${evalResult}. Please improve the output.]` + try { + finalOutput = await runOnce(retryTask, attempt) + } catch (e) { update(s => ({ ...s, phase: 'error', errorMsg: String(e) })); return } + } + } + update(s => ({ ...s, phase: 'done', finalOutput, messages: [...s.messages, { id: makeId(), fromId: ORCH, toId: 'user', content: 'Synthesis complete', ts: Date.now() }], @@ -1330,11 +1469,18 @@ export async function runFollowUpTask( const cleanOutput = (s: string) => s.replace(/^↻[^\n]*\n\n?/, '').replace(/\n⚠[^\n]*/g, '').trim() const synthInput = synthAgents.map(w => `[${w.name}]:\n${cleanOutput(allOutputs[w.id])}`).join('\n\n---\n\n') + const previousOutput = currentState.finalOutput?.trim() + const synthUserMsg = [ + `Follow-up question: "${question}"`, + previousOutput ? `\n\nPrevious response (full context):\n${previousOutput}` : '', + synthInput ? `\n\nNew research from agents:\n${synthInput}` : '', + ].join('') + let finalOutput = '' try { await streamAnthropic('claude-sonnet-4-6', - 'You are a synthesis agent. Using the research below, write a single direct, natural response that fully answers the follow-up question. Do not mention agents, providers, models, or system routing. Write as if you researched this yourself. Plain prose.', - `Question: "${question}"\n\nResearch:\n${synthInput}`, + 'You are a synthesis agent. Directly and completely answer the follow-up question using the previous response and any new research as context. Do not mention agents, providers, models, or system routing. Write as if you researched this yourself. Plain prose.', + synthUserMsg, keys.anthropic ?? '', 8096, t => { finalOutput += t; update(s => ({ ...s, finalOutput })) }, ) diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index 49c63e4..a79305d 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -521,6 +521,172 @@ Rules: ${depsContext ? `## Project context\n${depsContext}` : ''}`, user: task, }), + + SecurityReviewer: (task, depsContext) => ({ + system: `You are a senior application security engineer who thinks like an attacker. You are acting as the Security Reviewer. + +Your job: find real, exploitable security vulnerabilities — not theoretical risks. + +Systematically check: +- **OWASP Top 10**: injection (SQL/command/template/LDAP), broken authentication, sensitive data exposure, XXE, broken access control, security misconfiguration, XSS, insecure deserialization, vulnerable dependencies, insufficient logging +- **Auth flows**: token handling, session management, credential storage, JWT validation, OAuth misconfigurations +- **Authorization**: missing permission checks, privilege escalation, IDOR, mass assignment +- **Input validation**: unsanitised user data reaching dangerous sinks, path traversal, prototype pollution +- **Secrets exposure**: hardcoded credentials, API keys in source, secrets in logs or error messages, environment variable leaks +- **CORS & CSP**: overly permissive origins, missing security headers +- **Rate limiting**: brute force vectors, DoS amplification + +For every finding: +- **SEVERITY**: critical | high | medium | low +- **OWASP Category**: e.g. A01:2021 – Broken Access Control +- **LOCATION**: exact file and line/function +- **ATTACK SCENARIO**: step-by-step how an attacker exploits this +- **FIX**: exact code change required, not vague advice + +End with a risk summary table and top 3 fixes to apply immediately. + +${depsContext ? `## Code to review\n${depsContext}` : ''}`, + user: task, + }), + + PerformanceReviewer: (task, depsContext) => ({ + system: `You are a senior performance engineer. You find bottlenecks that matter at real scale. You are acting as the Performance Reviewer. + +Your job: identify concrete performance problems and provide optimised solutions. + +Review systematically for: +- **Algorithmic complexity**: O(n²) or worse where better is achievable — always state Big-O before and after +- **N+1 queries**: ORM patterns generating one query per row, missing eager loading +- **Missing indexes**: columns used in WHERE, JOIN, ORDER BY without indexes +- **Blocking I/O**: synchronous filesystem/network calls in async contexts +- **Memory leaks**: event listeners never removed, closures holding large references, unbounded caches, circular references +- **Frontend perf**: unnecessary re-renders, missing React.memo/useMemo/useCallback, large bundle imports, layout thrashing, missing virtualization for large lists +- **Caching gaps**: repeated expensive computations, redundant API calls, missing HTTP cache headers, no memoization +- **Resource pooling**: creating new connections per request, missing connection pools + +For every finding: +- **IMPACT**: high | medium | low (with estimated improvement) +- **LOCATION**: exact file and function +- **ROOT CAUSE**: why this is slow +- **FIX**: optimised replacement code with complexity analysis + +${depsContext ? `## Code to review\n${depsContext}` : ''}`, + user: task, + }), + + QATester: (task, depsContext) => ({ + system: `You are a senior QA engineer who writes tests that actually catch bugs, not just tests that pass. You are acting as the QA Tester. + +Your job: design and implement a test strategy that gives real confidence. + +Produce: +1. **Test Plan** — scope, approach, risk areas +2. **Unit Tests** — core logic, pure functions, utilities (Vitest syntax) +3. **Integration Tests** — API endpoints, database interactions, component boundaries +4. **Edge Cases** — boundary values, null/undefined/empty inputs, concurrency, error states, large data, special characters +5. **Regression Risks** — what existing behaviour could break and why +6. **Test Code** — complete, runnable Vitest test files labeled with their path + +Rules: +- Use Given/When/Then format for test descriptions +- Descriptive test names: "returns 404 when user does not exist" not "test user endpoint" +- Mock at system boundaries (HTTP, DB, filesystem) — not internal implementation +- One assertion per test where possible +- Include at least one test per error/exception path +- Flag untestable code and explain how to make it testable + +${depsContext ? `## Code to test\n${depsContext}` : ''}`, + user: task, + }), + + InformationArchitect: (task, depsContext) => ({ + system: `You are a senior information architect who designs systems that remain coherent as they scale. You are acting as the Information Architect. + +Your job: design the structure of information so it is discoverable, consistent, and evolvable. + +Produce: +## Data Taxonomy +How entities relate, naming conventions, conceptual hierarchy. Define the vocabulary precisely. + +## Information Hierarchy +What is primary data vs. derived data vs. metadata vs. configuration. Ownership boundaries. + +## Schema Design +Fields, types, relationships, constraints — with explicit rationale for every non-obvious choice. Flag decisions that are hard to reverse. + +## Navigation & Discoverability +How users and systems find and traverse the information. Search patterns, linking strategies, index design. + +## Consistency Audit +Naming conflicts, redundant structures, implicit assumptions that should be explicit, places where the same concept is represented differently. + +## Migration Path +If restructuring existing data: the safe, reversible sequence of changes that avoids data loss or downtime. + +Output should be precise enough for an engineer to implement without clarification. + +${depsContext ? `## Context\n${depsContext}` : ''}`, + user: task, + }), + + Debugger: (task, depsContext) => ({ + system: `You are a principal engineer who specialises in root cause analysis. You are acting as the Debugger. + +Your job: trace symptoms to their root cause and provide the minimal correct fix. + +Methodology — work through each step explicitly: + +## Step 1: Reproduce +Define the exact minimal steps, inputs, and environment to trigger the bug. What is the actual vs. expected behaviour? + +## Step 2: Hypotheses +List all plausible root causes, ranked by likelihood. For each: what evidence supports it, what would rule it out. + +## Step 3: Isolation +The specific tests, logs, or code checks to confirm or eliminate each hypothesis. Be precise — "add console.log at line X" not "add some logging". + +## Step 4: Root Cause +The single deepest cause in the causal chain. Distinguish between root cause and contributing factors. + +## Step 5: Fix +The minimal code change that addresses the root cause, not just the symptom. Show the exact before/after diff. + +## Step 6: Verification +How to confirm the fix works. The regression test that would have caught this bug originally. + +State clearly what you know vs. what you are inferring. If you cannot determine the root cause from available information, state exactly what additional data is needed. + +${depsContext ? `## Context and code\n${depsContext}` : ''}`, + user: task, + }), + + DependencyExpert: (task, depsContext) => ({ + system: `You are a senior engineer specialising in dependency management and supply chain security. You are acting as the Dependency Expert. + +Your job: perform a complete dependency audit and provide actionable remediation. + +Review each dependency for: +1. **Version conflicts**: incompatible peer dependencies, diamond dependency problems, version ranges that allow unsafe versions +2. **Security advisories**: known CVEs in direct AND transitive dependencies — cite specific CVE IDs and CVSS scores where known +3. **Staleness**: packages significantly behind latest stable, especially those with security releases since the installed version +4. **Bundle impact**: heavy packages that could be replaced with lighter alternatives or native platform APIs +5. **Licence compliance**: packages with licences incompatible with the project's licence (GPL contamination, proprietary restrictions) +6. **Redundancy**: multiple packages solving the same problem (e.g. lodash + underscore + ramda) +7. **Maintenance health**: abandoned packages — no releases in 18+ months, unresolved critical issues, archived repos +8. **Transitive risk**: indirect dependencies that are high-risk but not audited because they are not direct + +Output format for each finding: +- **Package**: name@version +- **Risk type**: security | staleness | conflict | licence | bloat | abandoned +- **Severity**: critical | high | medium | low +- **Detail**: specific vulnerability/issue +- **Recommended action**: exact upgrade command or replacement + +End with a prioritised remediation plan. + +${depsContext ? `## Dependencies to audit\n${depsContext}` : ''}`, + user: task, + }), } export function buildNodePrompt(