From cfb6b06a08a137014c28ed6eb06356c0854b0ea1 Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Sat, 9 May 2026 20:53:15 +0200 Subject: [PATCH] feat: add AI idea placement triage --- server/aiTriage.mjs | 188 +++++++++++ server/index.mjs | 20 ++ src/App.tsx | 431 +++++++++++++++++++++++- src/features/export/exporters.ts | 21 ++ src/features/project/projectDefaults.ts | 3 +- src/index.css | 78 +++++ src/store/remote.ts | 15 +- src/store/storage.ts | 90 ++++- src/store/types.ts | 42 ++- 9 files changed, 859 insertions(+), 29 deletions(-) create mode 100644 server/aiTriage.mjs diff --git a/server/aiTriage.mjs b/server/aiTriage.mjs new file mode 100644 index 0000000..6dcc3e0 --- /dev/null +++ b/server/aiTriage.mjs @@ -0,0 +1,188 @@ +const PLACEMENTS = ['now', 'next', 'later', 'parking_lot', 'rejected', 'duplicate', 'needs_clarification'] +const RISKS = ['low', 'medium', 'high', 'dangerous'] + +const systemInstruction = `You are BuildPulse Scope Guardian. +Your job is to classify new ideas for a solo AI-assisted builder. +You must protect the current project scope. +You are not trying to make the project bigger. +You are trying to decide where the idea safely belongs. +Classify the idea into one of: +- now +- next +- later +- parking_lot +- rejected +- duplicate +- needs_clarification +Rules: +- If the idea is required for the current goal, suggest now. +- If useful soon but not required now, suggest next. +- If valid but not soon, suggest later. +- If exciting but distracting, risky, or too large, suggest parking_lot. +- If not useful, unsafe, or contrary to the product goal, suggest rejected. +- If it overlaps existing features or parked ideas, suggest duplicate. +- If there is not enough information, suggest needs_clarification. +Always provide: +- suggested_placement +- scope_risk +- confidence_score +- reason +- smallest_safe_version +- suggested_title +- suggested_description +If suggesting a feature, include acceptance criteria. +If suggesting parking_lot, include parking reason. +If duplicate, identify likely duplicate. +If needs_clarification, ask one clear question. +Return strict JSON only. +Do not include markdown. +Do not include prose outside JSON.` + +const responseShape = { + suggested_placement: 'parking_lot', + scope_risk: 'high', + confidence_score: 0.82, + reason: 'This requires real agent integration, which is out of scope for v0.2.', + smallest_safe_version: 'Add a manual source/agent dropdown to Pulse events.', + suggested_title: 'OpenClaw live agent tracking', + suggested_description: 'Display live agent status from OpenClaw inside BuildPulse.', + suggested_acceptance_criteria: [], + suggested_parking_reason: 'Belongs to Agent Pulse v1.0+.', + duplicate_check: { + is_duplicate: true, + duplicate_type: 'parking_lot', + duplicate_id: 'parked_real_agent_pulse', + reason: 'Real agent ingestion already exists in Parking Lot.', + }, + clarifying_question: null, +} + +const cleanJsonText = (text) => + String(text || '') + .trim() + .replace(/^```json\s*/i, '') + .replace(/^```\s*/i, '') + .replace(/```$/i, '') + .trim() + +const asString = (value, fallback = '') => (typeof value === 'string' ? value.trim() : fallback) +const clampConfidence = (value) => { + const number = Number(value) + if (!Number.isFinite(number)) return 0.5 + return Math.max(0, Math.min(1, number)) +} +const stringArray = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean) : []) + +export const validateRecommendation = (raw) => { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('AI response was not a JSON object.') + } + + const suggestedPlacement = asString(raw.suggested_placement) + const scopeRisk = asString(raw.scope_risk) + + if (!PLACEMENTS.includes(suggestedPlacement)) { + throw new Error(`AI response used invalid placement: ${suggestedPlacement || 'missing'}`) + } + if (!RISKS.includes(scopeRisk)) { + throw new Error(`AI response used invalid scope risk: ${scopeRisk || 'missing'}`) + } + + const duplicate = raw.duplicate_check && typeof raw.duplicate_check === 'object' && !Array.isArray(raw.duplicate_check) ? raw.duplicate_check : {} + + return { + suggested_placement: suggestedPlacement, + scope_risk: scopeRisk, + confidence_score: clampConfidence(raw.confidence_score), + reason: asString(raw.reason, 'No reason provided.'), + smallest_safe_version: asString(raw.smallest_safe_version, 'Define the smallest manual version before building.'), + suggested_title: asString(raw.suggested_title, 'Untitled idea'), + suggested_description: asString(raw.suggested_description, ''), + suggested_acceptance_criteria: stringArray(raw.suggested_acceptance_criteria), + suggested_parking_reason: asString(raw.suggested_parking_reason, ''), + duplicate_check: { + is_duplicate: Boolean(duplicate.is_duplicate), + duplicate_type: ['feature', 'parking_lot'].includes(duplicate.duplicate_type) ? duplicate.duplicate_type : null, + duplicate_id: asString(duplicate.duplicate_id) || null, + reason: asString(duplicate.reason) || null, + }, + clarifying_question: asString(raw.clarifying_question) || null, + } +} + +export const buildTriagePrompt = ({ rawIdea, optionalContext, appContext }) => + [ + systemInstruction, + '', + 'Current compact BuildPulse context:', + JSON.stringify(appContext, null, 2), + '', + 'Raw idea:', + rawIdea, + optionalContext ? `\nOptional user context:\n${optionalContext}` : '', + '', + 'Return JSON matching this shape exactly. Use only allowed placement and risk values:', + JSON.stringify(responseShape, null, 2), + ].join('\n') + +const callGemini = async ({ prompt }) => { + const apiKey = process.env.BUILDPULSE_AI_API_KEY || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY + if (!apiKey) { + throw new Error('No Gemini API key configured. Set GEMINI_API_KEY or BUILDPULSE_AI_API_KEY on the server.') + } + + const model = process.env.BUILDPULSE_AI_MODEL || 'gemini-2.5-flash' + const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent` + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), Number(process.env.BUILDPULSE_AI_TIMEOUT_MS || 20000)) + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-goog-api-key': apiKey, + }, + body: JSON.stringify({ + contents: [ + { + role: 'user', + parts: [{ text: prompt }], + }, + ], + generationConfig: { + temperature: 0.2, + responseMimeType: 'application/json', + }, + }), + signal: controller.signal, + }) + + const payload = await response.json().catch(() => null) + if (!response.ok) { + throw new Error(payload?.error?.message || `Gemini request failed with ${response.status}`) + } + + const text = payload?.candidates?.[0]?.content?.parts?.map((part) => part.text || '').join('') + if (!text) throw new Error('Gemini returned no text.') + + return JSON.parse(cleanJsonText(text)) + } finally { + clearTimeout(timeout) + } +} + +export const triageIdea = async ({ rawIdea, optionalContext, appContext }) => { + const idea = asString(rawIdea) + if (!idea) throw new Error('raw_idea is required.') + + const prompt = buildTriagePrompt({ rawIdea: idea, optionalContext: asString(optionalContext), appContext: appContext || {} }) + const provider = process.env.BUILDPULSE_AI_PROVIDER || 'gemini' + + if (provider !== 'gemini') { + throw new Error(`Unsupported BuildPulse AI provider: ${provider}. v0.2 ships with gemini only.`) + } + + const rawRecommendation = await callGemini({ prompt }) + return validateRecommendation(rawRecommendation) +} diff --git a/server/index.mjs b/server/index.mjs index fecf5af..94fc3f8 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -2,6 +2,7 @@ import express from 'express' import path from 'node:path' import { fileURLToPath } from 'node:url' import { checkBackendHealth, fetchStoredAppState, persistAppState } from './appwriteBackend.mjs' +import { triageIdea } from './aiTriage.mjs' const app = express() const __filename = fileURLToPath(import.meta.url) @@ -45,6 +46,25 @@ app.put('/api/state', async (req, res) => { } }) +app.post('/api/ai/triage-idea', async (req, res) => { + const rawIdea = req.body?.raw_idea + if (typeof rawIdea !== 'string' || !rawIdea.trim()) { + res.status(400).json({ ok: false, error: 'raw_idea is required.' }) + return + } + + try { + const recommendation = await triageIdea({ + rawIdea, + optionalContext: typeof req.body?.optional_context === 'string' ? req.body.optional_context : '', + appContext: req.body?.app_context && typeof req.body.app_context === 'object' ? req.body.app_context : {}, + }) + res.json({ ok: true, provider: process.env.BUILDPULSE_AI_PROVIDER || 'gemini', recommendation }) + } catch (error) { + res.status(502).json({ ok: false, error: error?.message || 'AI triage failed.' }) + } +}) + app.use(express.static(distDir)) app.get('/{*path}', (_req, res) => { diff --git a/src/App.tsx b/src/App.tsx index a09ea19..14ddf9a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,10 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ChangeEvent } from 'react' import './index.css' import { createAgentSessionPrompt, createMarkdownPackage, createJsonExport, createPulseJsonl } from './features/export/exporters' -import { loadAppState, replaceAppState, saveAppState, validateAppState } from './store/storage' -import { fetchBackendHealth, fetchRemoteState, pushRemoteState } from './store/remote' -import { FEATURE_COLUMNS, FEATURE_PRIORITIES, FEATURE_STATUSES, PULSE_TYPES, RISK_LEVELS } from './store/types' -import type { AppState, Feature, FeatureColumn, ParkingLotItem, PulseEvent, RiskLevel, TabKey } from './store/types' +import { loadAppState, normalizeAppState, replaceAppState, saveAppState } from './store/storage' +import { fetchBackendHealth, fetchRemoteState, pushRemoteState, triageIdeaWithAi } from './store/remote' +import { AI_PLACEMENTS, FEATURE_COLUMNS, FEATURE_PRIORITIES, FEATURE_STATUSES, PULSE_TYPES, RISK_LEVELS } from './store/types' +import type { AiPlacement, AiRecommendation, AppState, Feature, FeatureColumn, ParkingLotItem, PulseEvent, RiskLevel, TabKey } from './store/types' import { arrayToLines, formatDateTime, linesToArray, nowIso, slugify } from './utils/format' const TABS: Array<{ key: TabKey; label: string }> = [ @@ -46,6 +46,27 @@ const initialPulseDraft = { traceId: '', } +const initialTriageDraft = { + rawIdea: '', + optionalContext: '', +} + +const placementLabels: Record = { + now: 'Now', + next: 'Next', + later: 'Later', + parking_lot: 'Parking Lot', + rejected: 'Rejected', + duplicate: 'Duplicate', + needs_clarification: 'Needs Clarification', +} + +const placementToFeatureColumn = (placement: AiPlacement): FeatureColumn => + placement === 'now' || placement === 'next' || placement === 'later' ? placement : 'next' + +const riskToPriority = (risk: RiskLevel): (typeof FEATURE_PRIORITIES)[number] => + risk === 'low' ? 'should' : risk === 'medium' ? 'could' : 'later' + const columnLabels: Record = { now: 'Now', next: 'Next', @@ -83,6 +104,19 @@ function App() { const [lastSyncedAt, setLastSyncedAt] = useState(null) const [syncAction, setSyncAction] = useState<'idle' | 'refreshing' | 'pushing'>('idle') const [selectedStatusCardTitle, setSelectedStatusCardTitle] = useState('Project Cockpit') + const [triageOpen, setTriageOpen] = useState(false) + const [triageDraft, setTriageDraft] = useState(initialTriageDraft) + const [triageRecommendation, setTriageRecommendation] = useState(null) + const [triageEditable, setTriageEditable] = useState({ + placement: 'parking_lot' as AiPlacement, + risk: 'medium' as RiskLevel, + title: '', + description: '', + acceptanceCriteria: '', + parkingReason: '', + }) + const [triageStatus, setTriageStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle') + const [triageError, setTriageError] = useState('') const hasHydratedRemote = useRef(false) const initialLocalStateRef = useRef(appState) @@ -98,9 +132,10 @@ function App() { const remoteState = await fetchRemoteState() if (cancelled) return - if (remoteState && validateAppState(remoteState)) { - setAppState(remoteState) - saveAppState(remoteState) + const normalizedRemoteState = normalizeAppState(remoteState) + if (normalizedRemoteState) { + setAppState(normalizedRemoteState) + saveAppState(normalizedRemoteState) setStatusMessage('Loaded state from Appwrite on the Unraid server.') } else { await pushRemoteState(initialLocalStateRef.current) @@ -330,9 +365,10 @@ function App() { if (!health.ok) throw new Error('Backend health check failed.') - if (remoteState && validateAppState(remoteState)) { - setAppState(remoteState) - saveAppState(remoteState) + const normalizedRemoteState = normalizeAppState(remoteState) + if (normalizedRemoteState) { + setAppState(normalizedRemoteState) + saveAppState(normalizedRemoteState) setBackendMode('appwrite') setSyncStatus('synced') setLastSyncedAt(nowIso()) @@ -370,6 +406,241 @@ function App() { } } + const openTriage = (seed = '') => { + setTriageOpen(true) + setTriageError('') + setTriageStatus(triageRecommendation ? 'ready' : 'idle') + if (seed) { + setTriageDraft((current) => ({ ...current, rawIdea: seed })) + } + } + + const buildAiTriageContext = () => ({ + project: { + name: appState.project.name, + one_line_pitch: appState.project.one_line_pitch, + current_goal: appState.project.current_goal, + }, + current_scope: { + in_scope: [ + 'AI classifies new ideas into Now, Next, Later, Parking Lot, Rejected, Duplicate, or Needs Clarification.', + 'User accepts, edits, or rejects the recommendation.', + 'Every accepted or rejected decision is logged as a DECISION pulse.', + ], + out_of_scope: [ + 'phases', + 'releases', + 'agent integration', + 'OpenClaw or Hermes integration', + 'local/cloud model router', + 'multi-project support', + 'automatic feature building', + ], + }, + features: FEATURE_COLUMNS.reduce>>((acc, column) => { + acc[column] = groupedFeatures[column].map((feature) => ({ + id: feature.id, + title: feature.title, + description: feature.description, + scope_notes: feature.scope_notes, + })) + return acc + }, { now: [], next: [], later: [], done: [] }), + parking_lot: appState.parking_lot.map((item) => ({ + id: item.id, + title: item.title, + description: item.description, + reason_parked: item.reason_parked, + })), + recent_decisions: [...appState.pulses] + .filter((pulse) => pulse.pulse_type === 'DECISION') + .sort((a, b) => b.timestamp.localeCompare(a.timestamp)) + .slice(0, 5) + .map((pulse) => ({ message: pulse.message, evidence_refs: pulse.evidence_refs })), + }) + + const runAiTriage = async () => { + const rawIdea = triageDraft.rawIdea.trim() + if (!rawIdea) { + setTriageError('Write the idea first. The scope goblin needs bait.') + setTriageStatus('error') + return + } + + setTriageStatus('loading') + setTriageError('') + + try { + const recommendation = await triageIdeaWithAi({ + raw_idea: rawIdea, + optional_context: triageDraft.optionalContext.trim(), + app_context: buildAiTriageContext(), + }) + const timestamp = nowIso() + const fullRecommendation: AiRecommendation = { + id: `rec_${slugify(rawIdea)}_${timestamp.replace(/[^0-9]/g, '')}`, + created_at: timestamp, + raw_idea: rawIdea, + optional_context: triageDraft.optionalContext.trim(), + context_summary: `${appState.project.name}: ${appState.project.current_goal}`, + ...recommendation, + user_decision: 'pending', + created_feature_id: null, + created_parking_item_id: null, + decision_pulse_id: null, + } + + setTriageRecommendation(fullRecommendation) + saveTriageRecommendation(fullRecommendation) + setTriageEditable({ + placement: fullRecommendation.suggested_placement, + risk: fullRecommendation.scope_risk, + title: fullRecommendation.suggested_title, + description: fullRecommendation.suggested_description, + acceptanceCriteria: fullRecommendation.suggested_acceptance_criteria.join('\n'), + parkingReason: fullRecommendation.suggested_parking_reason || fullRecommendation.reason, + }) + setTriageStatus('ready') + setStatusMessage(`AI suggested ${placementLabels[fullRecommendation.suggested_placement]} with ${fullRecommendation.scope_risk} scope risk.`) + } catch (error) { + setTriageStatus('error') + setTriageError(error instanceof Error ? error.message : 'AI triage failed.') + setStatusMessage('AI triage failed. Manual planning still works.') + } + } + + const createTriageDecisionPulse = (recommendation: AiRecommendation, decision: string, featureId?: string) => { + const timestamp = nowIso() + return { + id: `pulse_${Date.now().toString(36)}`, + timestamp, + project_id: appState.project.id, + feature_id: featureId, + source: 'ai_triage', + agent_id: 'buildpulse_ai', + pulse_type: 'DECISION' as const, + message: `AI triaged idea “${recommendation.raw_idea}” as ${placementLabels[recommendation.suggested_placement]} with ${recommendation.scope_risk} scope risk. User decision: ${decision}.`, + confidence_score: recommendation.confidence_score, + evidence_refs: [ + 'BuildPulse v0.2 AI Idea Placement', + appState.project.current_goal || 'Current project goal', + recommendation.reason, + `Smallest safe version: ${recommendation.smallest_safe_version}`, + ], + trace_id: recommendation.id, + } + } + + const saveTriageRecommendation = (recommendation: AiRecommendation) => { + setAppState((current) => ({ + ...current, + ai_recommendations: [recommendation, ...current.ai_recommendations.filter((entry) => entry.id !== recommendation.id)], + })) + } + + const acceptTriageAsFeature = () => { + if (!triageRecommendation) return + const title = triageEditable.title.trim() || triageRecommendation.suggested_title + const timestamp = nowIso() + const featureId = `feature_${slugify(title)}_${Date.now().toString(36)}` + const pulse = createTriageDecisionPulse(triageRecommendation, 'accepted_as_feature', featureId) + const updatedRecommendation: AiRecommendation = { + ...triageRecommendation, + suggested_placement: triageEditable.placement, + scope_risk: triageEditable.risk, + suggested_title: title, + suggested_description: triageEditable.description.trim(), + suggested_acceptance_criteria: linesToArray(triageEditable.acceptanceCriteria), + user_decision: 'accepted_as_feature', + created_feature_id: featureId, + created_parking_item_id: null, + decision_pulse_id: pulse.id, + } + + setAppState((current) => ({ + ...current, + features: [ + { + id: featureId, + title, + description: triageEditable.description.trim(), + column: placementToFeatureColumn(triageEditable.placement), + priority: riskToPriority(triageEditable.risk), + status: 'idea', + acceptance_criteria: linesToArray(triageEditable.acceptanceCriteria), + scope_notes: [`AI triage: ${triageRecommendation.reason}`, `Smallest safe version: ${triageRecommendation.smallest_safe_version}`].join('\n'), + created_at: timestamp, + updated_at: timestamp, + }, + ...current.features, + ], + pulses: [pulse, ...current.pulses], + ai_recommendations: [updatedRecommendation, ...current.ai_recommendations.filter((entry) => entry.id !== updatedRecommendation.id)], + })) + setSelectedFeatureId(featureId) + setActiveTab('feature-plan') + setStatusMessage(`Accepted AI triage as feature “${title}”. DECISION pulse logged.`) + } + + const acceptTriageAsParkingLot = () => { + if (!triageRecommendation) return + const title = triageEditable.title.trim() || triageRecommendation.suggested_title + const timestamp = nowIso() + const parkingId = `parked_${slugify(title)}_${Date.now().toString(36)}` + const pulse = createTriageDecisionPulse(triageRecommendation, 'accepted_as_parking_lot') + const updatedRecommendation: AiRecommendation = { + ...triageRecommendation, + suggested_placement: 'parking_lot', + scope_risk: triageEditable.risk, + suggested_title: title, + suggested_description: triageEditable.description.trim(), + suggested_parking_reason: triageEditable.parkingReason.trim(), + user_decision: 'accepted_as_parking_lot', + created_feature_id: null, + created_parking_item_id: parkingId, + decision_pulse_id: pulse.id, + } + + setAppState((current) => ({ + ...current, + parking_lot: [ + { + id: parkingId, + title, + description: triageEditable.description.trim(), + reason_parked: triageEditable.parkingReason.trim() || triageRecommendation.reason, + possible_future_placement: triageEditable.placement === 'later' ? 'Later' : 'v0.3+', + risk_level: triageEditable.risk, + created_at: timestamp, + updated_at: timestamp, + }, + ...current.parking_lot, + ], + pulses: [pulse, ...current.pulses], + ai_recommendations: [updatedRecommendation, ...current.ai_recommendations.filter((entry) => entry.id !== updatedRecommendation.id)], + })) + setSelectedParkingId(parkingId) + setActiveTab('parking-lot') + setStatusMessage(`Accepted AI triage as Parking Lot item “${title}”. DECISION pulse logged.`) + } + + const rejectTriageRecommendation = () => { + if (!triageRecommendation) return + const pulse = createTriageDecisionPulse(triageRecommendation, 'rejected') + const updatedRecommendation: AiRecommendation = { + ...triageRecommendation, + user_decision: 'rejected', + decision_pulse_id: pulse.id, + } + + setAppState((current) => ({ + ...current, + pulses: [pulse, ...current.pulses], + ai_recommendations: [updatedRecommendation, ...current.ai_recommendations.filter((entry) => entry.id !== updatedRecommendation.id)], + })) + setStatusMessage('Rejected AI triage recommendation. DECISION pulse logged; no feature created.') + } + const openFeatureHandoff = (featureId: string) => { setPromptFeatureId(featureId) setPromptTarget('OpenClaw') @@ -597,11 +868,12 @@ function App() { try { const text = await file.text() const parsed = JSON.parse(text) - if (!validateAppState(parsed)) { + const normalized = normalizeAppState(parsed) + if (!normalized) { setStatusMessage('Import failed: invalid BuildPulse schema or unsupported version.') return } - setAppState(replaceAppState(parsed)) + setAppState(replaceAppState(normalized)) setStatusMessage('Import complete. State replaced cleanly.') } catch { setStatusMessage('Import failed: invalid JSON.') @@ -728,6 +1000,135 @@ function App() { + {triageOpen && ( +
+
+
+

BuildPulse v0.2

+

AI Idea Placement

+

AI advises where a raw idea belongs. You decide what gets saved.

+
+ +
+ +
+
+