diff options
| author | soryu <soryu@soryu.co> | 2026-05-17 21:22:34 +0100 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-05-17 21:22:34 +0100 |
| commit | 857e717e6343fa5c2ae96664bdc64741d5ba6830 (patch) | |
| tree | 0f3898d9e2e2a3c312358dbf70c44f4ab1cf3648 | |
| parent | ce29ae801bcc5a0ba76d5a8d1565242ab267a47d (diff) | |
| download | soryu-857e717e6343fa5c2ae96664bdc64741d5ba6830.tar.gz soryu-857e717e6343fa5c2ae96664bdc64741d5ba6830.zip | |
chore: remove LLM module + all dependent surfacesremove-llm
Wholesale removal of the LLM integration layer. ~14,200 LOC deleted
across backend and frontend. All chat-driven UIs go with it.
## Backend
- Delete `src/llm/` (7,400 LOC): claude/groq clients, contract_tools,
contract_evaluator, discuss_tools, mesh_tools, phase_guidance,
task_output, templates, markdown round-trip, tools, transcript_analyzer.
- Delete handlers wholly dependent on LLM:
- `chat.rs` (file-level LLM chat at /files/{id}/chat)
- `mesh_chat.rs` (mesh & task LLM chat + history)
- `templates.rs` (/contract-types listing)
- Strip LLM uses from `mesh_daemon.rs`:
- `compute_action_directive` (used phase_guidance::check_deliverables_met
to nudge supervisors with "all tasks done" messages). The auto-PR
path below still fires when all tasks finish, so no behaviour lost.
- `crate::llm::markdown_to_body` → inline 1-line replacement that
wraps markdown content in a single BodyElement::Markdown. The
editor re-parses on display, so round-trip is preserved.
- Drop routes: /files/{id}/chat, /mesh/chat, /mesh/chat/history,
/mesh/tasks/{id}/chat, /contract-types.
- Drop the matching openapi registrations.
## Frontend
- Delete components that were LLM-only:
- `mesh/UnifiedMeshChatInput.tsx`
- `listen/DiscussContractModal.tsx`
- `listen/TranscriptAnalysisPanel.tsx`
- `listen/ContractPickerModal.tsx`
- `files/CliInput.tsx`
- Delete the entire /listen page (its primary value-add was
voice → LLM analysis → contract creation; without LLM the page is
just a transcript display with no obvious user purpose).
- Delete `hooks/useMeshChatHistory.ts` and `lib/listenApi.ts`
(transcript-analysis API client to the already-Phase-5-removed
listen handlers).
- Strip api.ts of LLM exports: LlmModel, ChatMessage/Request/Response,
UserQuestion/Answer, chatWithFile, MeshChat* types & functions,
getMeshChatHistory, clearMeshChatHistory, chatWithMeshContext,
ContractTypeTemplate, listContractTypes, chatWithContract,
getContractChatHistory, clearContractChatHistory, discussContract,
PhaseDefinition, DeliverableDefinition.
- mesh.tsx: drop UnifiedMeshChatInput render + the chatContext memo +
handleTaskUpdatedFromCli (only consumer was the input).
- files.tsx: drop CliInput render + handleGenerateFromElement +
handleBodyUpdate + handleClearFocus + suggestedPrompt state (all
CliInput-only).
- NavStrip: drop the /listen link.
- main.tsx: drop the /listen route.
## Net diff: 37 files changed, 58 insertions, 14,281 deletions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
37 files changed, 58 insertions, 14281 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 7c5dad1..36ced19 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -17,7 +17,6 @@ interface NavLink { } const NAV_LINKS: NavLink[] = [ - { label: "Listen", href: "/listen" }, { label: "Directives", href: "/directives", requiresAuth: true }, { label: "Orders", href: "/orders", requiresAuth: true }, // /contracts has been removed in Phase 5; the legacy nav entry is gone. diff --git a/makima/frontend/src/components/files/CliInput.tsx b/makima/frontend/src/components/files/CliInput.tsx deleted file mode 100644 index 47e7616..0000000 --- a/makima/frontend/src/components/files/CliInput.tsx +++ /dev/null @@ -1,483 +0,0 @@ -import { useState, useCallback, useRef, useEffect } from "react"; -import { - chatWithFile, - type BodyElement, - type LlmModel, - type ChatMessage, - type UserQuestion, - type UserAnswer, -} from "../../lib/api"; -import { SimpleMarkdown } from "../SimpleMarkdown"; -import type { FocusedElement } from "./FileDetail"; - -interface CliInputProps { - fileId: string; - onUpdate: (body: BodyElement[], summary: string | null) => void; - focusedElement?: FocusedElement | null; - onClearFocus?: () => void; - suggestedPrompt?: string | null; - onClearSuggestedPrompt?: () => void; -} - -interface Message { - id: string; - type: "user" | "assistant" | "error" | "question"; - content: string; - toolCalls?: { name: string; success: boolean; message: string }[]; - questions?: UserQuestion[]; -} - -const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [ - { value: "claude-opus", label: "Claude Opus" }, - { value: "claude-sonnet", label: "Claude Sonnet" }, - { value: "groq", label: "Groq Kimi" }, -]; - -export function CliInput({ fileId, onUpdate, focusedElement, onClearFocus, suggestedPrompt, onClearSuggestedPrompt }: CliInputProps) { - const [input, setInput] = useState(""); - const [loading, setLoading] = useState(false); - const [messages, setMessages] = useState<Message[]>([]); - const [expanded, setExpanded] = useState(false); - const [model, setModel] = useState<LlmModel>("claude-opus"); - // Track conversation history for context continuity - const [conversationHistory, setConversationHistory] = useState<ChatMessage[]>([]); - // Track pending questions from the LLM - const [pendingQuestions, setPendingQuestions] = useState<UserQuestion[] | null>(null); - // Track user's answers to questions - const [userAnswers, setUserAnswers] = useState<Map<string, string[]>>(new Map()); - // Track custom input for each question - const [customInputs, setCustomInputs] = useState<Map<string, string>>(new Map()); - - const inputRef = useRef<HTMLInputElement>(null); - const messagesRef = useRef<HTMLDivElement>(null); - - // Auto-scroll to bottom when messages change - useEffect(() => { - if (messagesRef.current) { - messagesRef.current.scrollTop = messagesRef.current.scrollHeight; - } - }, [messages]); - - // Auto-focus input when an element is focused - useEffect(() => { - if (focusedElement && inputRef.current) { - inputRef.current.focus(); - } - }, [focusedElement]); - - // Handle suggested prompt from generate actions - useEffect(() => { - if (suggestedPrompt) { - setInput(suggestedPrompt); - onClearSuggestedPrompt?.(); - } - }, [suggestedPrompt, onClearSuggestedPrompt]); - - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - if (!input.trim() || loading) return; - - const userMessage = input.trim(); - setInput(""); - setExpanded(true); - - // Add user message - const userMsgId = Date.now().toString(); - setMessages((prev) => [ - ...prev, - { id: userMsgId, type: "user", content: userMessage }, - ]); - - setLoading(true); - - try { - // Send request with conversation history for context - const response = await chatWithFile( - fileId, - userMessage, - model, - conversationHistory, - focusedElement?.index - ); - - // Add assistant response - const assistantMsgId = (Date.now() + 1).toString(); - setMessages((prev) => [ - ...prev, - { - id: assistantMsgId, - type: response.pendingQuestions?.length ? "question" : "assistant", - content: response.response, - toolCalls: response.toolCalls.map((tc) => ({ - name: tc.name, - success: tc.result.success, - message: tc.result.message, - })), - questions: response.pendingQuestions, - }, - ]); - - // Update conversation history for next request - setConversationHistory((prev) => [ - ...prev, - { role: "user", content: userMessage }, - { role: "assistant", content: response.response }, - ]); - - // Handle pending questions - if (response.pendingQuestions?.length) { - setPendingQuestions(response.pendingQuestions); - // Initialize answers map - const initialAnswers = new Map<string, string[]>(); - response.pendingQuestions.forEach((q) => { - initialAnswers.set(q.id, []); - }); - setUserAnswers(initialAnswers); - setCustomInputs(new Map()); - } - - // Update parent with new body/summary - onUpdate(response.updatedBody, response.updatedSummary); - } catch (err) { - const errorMsgId = (Date.now() + 1).toString(); - setMessages((prev) => [ - ...prev, - { - id: errorMsgId, - type: "error", - content: err instanceof Error ? err.message : "An error occurred", - }, - ]); - } finally { - setLoading(false); - inputRef.current?.focus(); - } - }, - [input, loading, fileId, model, onUpdate, conversationHistory, focusedElement] - ); - - // Handle option selection for a question - const handleOptionToggle = useCallback((questionId: string, option: string, allowMultiple: boolean) => { - setUserAnswers((prev) => { - const newMap = new Map(prev); - const currentAnswers = newMap.get(questionId) || []; - - if (allowMultiple) { - // Toggle option in array - if (currentAnswers.includes(option)) { - newMap.set(questionId, currentAnswers.filter((a) => a !== option)); - } else { - newMap.set(questionId, [...currentAnswers, option]); - } - } else { - // Single select - replace - newMap.set(questionId, [option]); - } - - return newMap; - }); - }, []); - - // Handle custom input change - const handleCustomInputChange = useCallback((questionId: string, value: string) => { - setCustomInputs((prev) => { - const newMap = new Map(prev); - newMap.set(questionId, value); - return newMap; - }); - }, []); - - // Submit answers to questions - const handleSubmitAnswers = useCallback(async () => { - if (!pendingQuestions || loading) return; - - // Build answers array, including custom inputs - const answers: UserAnswer[] = pendingQuestions.map((q) => { - const selectedOptions = userAnswers.get(q.id) || []; - const customInput = customInputs.get(q.id)?.trim(); - - // If there's a custom input, add it to answers - const finalAnswers = customInput - ? [...selectedOptions, customInput] - : selectedOptions; - - return { - id: q.id, - answers: finalAnswers, - }; - }); - - // Format answers as a message - const answerText = answers - .map((a) => { - const question = pendingQuestions.find((q) => q.id === a.id); - return `${question?.question || a.id}: ${a.answers.join(", ")}`; - }) - .join("\n"); - - // Clear pending questions - setPendingQuestions(null); - setUserAnswers(new Map()); - setCustomInputs(new Map()); - - // Add user answer message - const userMsgId = Date.now().toString(); - setMessages((prev) => [ - ...prev, - { id: userMsgId, type: "user", content: `[Answers]\n${answerText}` }, - ]); - - setLoading(true); - - try { - // Send answers as the next message - const response = await chatWithFile( - fileId, - answerText, - model, - conversationHistory, - focusedElement?.index - ); - - // Add assistant response - const assistantMsgId = (Date.now() + 1).toString(); - setMessages((prev) => [ - ...prev, - { - id: assistantMsgId, - type: response.pendingQuestions?.length ? "question" : "assistant", - content: response.response, - toolCalls: response.toolCalls.map((tc) => ({ - name: tc.name, - success: tc.result.success, - message: tc.result.message, - })), - questions: response.pendingQuestions, - }, - ]); - - // Update conversation history - setConversationHistory((prev) => [ - ...prev, - { role: "user", content: answerText }, - { role: "assistant", content: response.response }, - ]); - - // Handle more pending questions - if (response.pendingQuestions?.length) { - setPendingQuestions(response.pendingQuestions); - const initialAnswers = new Map<string, string[]>(); - response.pendingQuestions.forEach((q) => { - initialAnswers.set(q.id, []); - }); - setUserAnswers(initialAnswers); - setCustomInputs(new Map()); - } - - // Update parent with new body/summary - onUpdate(response.updatedBody, response.updatedSummary); - } catch (err) { - const errorMsgId = (Date.now() + 1).toString(); - setMessages((prev) => [ - ...prev, - { - id: errorMsgId, - type: "error", - content: err instanceof Error ? err.message : "An error occurred", - }, - ]); - } finally { - setLoading(false); - } - }, [pendingQuestions, userAnswers, customInputs, loading, fileId, model, conversationHistory, onUpdate, focusedElement]); - - // Cancel answering questions - const handleCancelQuestions = useCallback(() => { - setPendingQuestions(null); - setUserAnswers(new Map()); - setCustomInputs(new Map()); - }, []); - - const clearMessages = useCallback(() => { - setMessages([]); - setConversationHistory([]); - setPendingQuestions(null); - setUserAnswers(new Map()); - setCustomInputs(new Map()); - }, []); - - return ( - <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]"> - {/* Messages Panel (expandable) */} - {expanded && messages.length > 0 && ( - <div - ref={messagesRef} - className="max-h-48 overflow-y-auto p-3 space-y-2 border-b border-[rgba(117,170,252,0.2)]" - > - {messages.map((msg) => ( - <div key={msg.id} className="font-mono text-xs"> - {msg.type === "user" && ( - <div className="flex gap-2"> - <span className="text-[#9bc3ff]">></span> - <span className="text-white/80 whitespace-pre-wrap">{msg.content}</span> - </div> - )} - {(msg.type === "assistant" || msg.type === "question") && ( - <div className="pl-4 space-y-1"> - <SimpleMarkdown content={msg.content} className="text-[#75aafc]" /> - {msg.toolCalls && msg.toolCalls.length > 0 && ( - <div className="text-[#555] text-[10px] space-y-0.5"> - {msg.toolCalls.map((tc, i) => ( - <div key={i}> - <span - className={ - tc.success ? "text-green-500" : "text-red-400" - } - > - {tc.success ? "+" : "x"} - </span>{" "} - {tc.name}: {tc.message} - </div> - ))} - </div> - )} - </div> - )} - {msg.type === "error" && ( - <div className="pl-4 text-red-400">{msg.content}</div> - )} - </div> - ))} - </div> - )} - - {/* Pending Questions UI */} - {pendingQuestions && pendingQuestions.length > 0 && ( - <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3"> - <div className="text-[#9bc3ff] font-mono text-xs uppercase tracking-wide"> - Questions from AI - </div> - {pendingQuestions.map((q) => ( - <div key={q.id} className="space-y-2"> - <div className="text-white/90 font-mono text-sm">{q.question}</div> - <div className="flex flex-wrap gap-2"> - {q.options.map((option) => { - const isSelected = (userAnswers.get(q.id) || []).includes(option); - return ( - <button - key={option} - type="button" - onClick={() => handleOptionToggle(q.id, option, q.allowMultiple)} - className={`px-2 py-1 font-mono text-xs border transition-colors ${ - isSelected - ? "bg-[#3f6fb3] border-[#75aafc] text-white" - : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-[#3f6fb3]" - }`} - > - {q.allowMultiple && ( - <span className="mr-1">{isSelected ? "☑" : "☐"}</span> - )} - {option} - </button> - ); - })} - </div> - {q.allowCustom && ( - <input - type="text" - value={customInputs.get(q.id) || ""} - onChange={(e) => handleCustomInputChange(q.id, e.target.value)} - placeholder="Or type a custom answer..." - className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] placeholder-[#555]" - /> - )} - </div> - ))} - <div className="flex gap-2 pt-2"> - <button - type="button" - onClick={handleSubmitAnswers} - disabled={loading} - className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" - > - {loading ? "..." : "Submit Answers"} - </button> - <button - type="button" - onClick={handleCancelQuestions} - disabled={loading} - className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors" - > - Cancel - </button> - </div> - </div> - )} - - {/* Input Bar */} - <form onSubmit={handleSubmit} className="flex items-center gap-2 p-3"> - <select - value={model} - onChange={(e) => setModel(e.target.value as LlmModel)} - disabled={loading || !!pendingQuestions} - className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs px-2 py-1 rounded-none outline-none focus:border-[#3f6fb3] disabled:opacity-50" - > - {MODEL_OPTIONS.map((opt) => ( - <option key={opt.value} value={opt.value}> - {opt.label} - </option> - ))} - </select> - - {/* Focus Badge */} - {focusedElement && ( - <button - type="button" - onClick={onClearFocus} - className="flex items-center gap-1 px-2 py-0.5 font-mono text-[10px] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.3)] text-[#9bc3ff] hover:border-[#75aafc] transition-colors group" - title="Click to clear focus" - > - <span className="text-[#75aafc]">{focusedElement.type}</span> - <span className="text-[#555]">:</span> - <span>{focusedElement.index}</span> - <span className="text-[#555] group-hover:text-red-400 ml-1">×</span> - </button> - )} - - <span className="text-[#9bc3ff] font-mono text-sm">></span> - <input - ref={inputRef} - type="text" - value={input} - onChange={(e) => setInput(e.target.value)} - placeholder={ - loading - ? "Processing..." - : pendingQuestions - ? "Answer questions above first..." - : "Add a heading, chart, or summary..." - } - disabled={loading || !!pendingQuestions} - className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]" - /> - {messages.length > 0 && ( - <button - type="button" - onClick={clearMessages} - className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors" - > - clear - </button> - )} - <button - type="submit" - disabled={loading || !input.trim() || !!pendingQuestions} - className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" - > - {loading ? "..." : "Send"} - </button> - </form> - </div> - ); -} diff --git a/makima/frontend/src/components/listen/ContractPickerModal.tsx b/makima/frontend/src/components/listen/ContractPickerModal.tsx deleted file mode 100644 index f3c72d0..0000000 --- a/makima/frontend/src/components/listen/ContractPickerModal.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useEffect, useRef } from "react"; -import type { ContractOption } from "./ControlPanel"; - -interface ContractPickerModalProps { - isOpen: boolean; - onClose: () => void; - contracts: ContractOption[]; - selectedContractId: string | null; - onSelect: (contractId: string | null) => void; - onDiscussContract: () => void; - loading?: boolean; -} - -export function ContractPickerModal({ - isOpen, - onClose, - contracts, - selectedContractId, - onSelect, - onDiscussContract, - loading, -}: ContractPickerModalProps) { - const modalRef = useRef<HTMLDivElement>(null); - - useEffect(() => { - if (!isOpen) return; - - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - onClose(); - } - } - - function handleClickOutside(e: MouseEvent) { - if (modalRef.current && !modalRef.current.contains(e.target as Node)) { - onClose(); - } - } - - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("mousedown", handleClickOutside); - - return () => { - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [isOpen, onClose]); - - if (!isOpen) return null; - - const handleSelect = (contractId: string | null) => { - onSelect(contractId); - onClose(); - }; - - return ( - <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> - <div - ref={modalRef} - className="panel p-4 w-[300px] max-h-[400px] flex flex-col gap-3" - > - <div className="flex items-center justify-between"> - <h2 className="font-mono text-sm text-[#dbe7ff] uppercase tracking-wide"> - Select Contract - </h2> - <button - onClick={onClose} - className="font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" - > - [X] - </button> - </div> - - <div className="flex-1 overflow-y-auto flex flex-col gap-1 min-h-0"> - {loading ? ( - <div className="font-mono text-xs text-[#9bc3ff] text-center py-4"> - Loading... - </div> - ) : ( - <> - <button - onClick={() => handleSelect(null)} - className={`w-full text-left px-3 py-2 font-mono text-xs border transition-colors ${ - selectedContractId === null - ? "bg-[#0f3c78]/50 border-[#3f6fb3] text-[#dbe7ff]" - : "bg-[#0d1b2d] border-[#0f3c78] text-[#9bc3ff] hover:border-[#3f6fb3] hover:text-[#dbe7ff]" - }`} - > - <span className="uppercase tracking-wide">Ephemeral</span> - <span className="block text-[10px] text-[#75aafc] mt-0.5"> - Transcript not saved - </span> - </button> - - <button - onClick={() => { - onClose(); - onDiscussContract(); - }} - className="w-full text-left px-3 py-2 font-mono text-xs border bg-[#0d1b2d] border-[#3f6fb3] text-[#dbe7ff] hover:bg-[#153667] transition-colors" - > - <span className="uppercase tracking-wide">Discuss Contract</span> - <span className="block text-[10px] text-[#75aafc] mt-0.5"> - Chat with Makima to define a new contract - </span> - </button> - - {contracts.map((contract) => ( - <button - key={contract.id} - onClick={() => handleSelect(contract.id)} - className={`w-full text-left px-3 py-2 font-mono text-xs border transition-colors ${ - selectedContractId === contract.id - ? "bg-[#0f3c78]/50 border-[#3f6fb3] text-[#dbe7ff]" - : "bg-[#0d1b2d] border-[#0f3c78] text-[#9bc3ff] hover:border-[#3f6fb3] hover:text-[#dbe7ff]" - }`} - > - <span className="block truncate">{contract.name}</span> - </button> - ))} - - {contracts.length === 0 && ( - <div className="font-mono text-xs text-[#9bc3ff] text-center py-4"> - No contracts available - </div> - )} - </> - )} - </div> - </div> - </div> - ); -} diff --git a/makima/frontend/src/components/listen/ControlPanel.tsx b/makima/frontend/src/components/listen/ControlPanel.tsx deleted file mode 100644 index ab2bcee..0000000 --- a/makima/frontend/src/components/listen/ControlPanel.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useState } from "react"; -import { Logo } from "../Logo"; -import type { MicrophoneStatus } from "../../hooks/useMicrophone"; -import type { ConnectionStatus } from "../../hooks/useWebSocket"; -import { ContractPickerModal } from "./ContractPickerModal"; - -export interface ContractOption { - id: string; - name: string; -} - -interface ControlPanelProps { - isListening: boolean; - isConnected: boolean; - micStatus: MicrophoneStatus; - micVolume: number; - hasTranscripts: boolean; - onToggle: () => void; - onNew: () => void; - error?: string | null; - // Contract selection - contracts: ContractOption[]; - selectedContractId: string | null; - onContractChange: (contractId: string | null) => void; - onDiscussContract: () => void; - contractsLoading?: boolean; - // Connection status for loading state - connectionStatus?: ConnectionStatus; -} - -function getStatusText(isListening: boolean, micStatus: MicrophoneStatus): string { - if (isListening) return "Listening..."; - - switch (micStatus) { - case "requesting": - return "Requesting permission..."; - case "ready": - return "Click to start"; - case "denied": - return "Permission denied - click to retry"; - case "error": - return "Error - click to retry"; - default: - return "Click to start"; - } -} - -export function ControlPanel({ - isListening, - isConnected, - micStatus, - micVolume, - hasTranscripts, - onToggle, - onNew, - error, - contracts, - selectedContractId, - onContractChange, - onDiscussContract, - contractsLoading, - connectionStatus, -}: ControlPanelProps) { - const [isModalOpen, setIsModalOpen] = useState(false); - const statusText = getStatusText(isListening, micStatus); - const isRequesting = micStatus === "requesting"; - - const selectedContract = contracts.find((c) => c.id === selectedContractId); - - return ( - <div className="panel p-4 flex flex-col items-center justify-center gap-3"> - {/* Logo button */} - <div className="flex flex-col items-center gap-2"> - <Logo - size={100} - listening={isListening || isRequesting} - onClick={isRequesting ? undefined : onToggle} - className={isRequesting ? "opacity-50" : "cursor-pointer"} - noHoverAnimation - /> - <span className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase text-center"> - {statusText} - </span> - </div> - - {/* Status indicators */} - <div className="font-mono text-xs text-center flex flex-col gap-1"> - {/* Microphone status */} - <div - className={`inline-flex flex-col gap-1 px-2 py-1 border ${ - micStatus === "ready" || micStatus === "recording" - ? "border-[#3f6fb3] text-[#75aafc]" - : micStatus === "denied" || micStatus === "error" - ? "border-red-400/50 text-red-400" - : "border-[rgba(117,170,252,0.25)] text-[#9bc3ff]" - }`} - > - <div className="inline-flex items-center gap-1.5"> - <span - className={`w-1.5 h-1.5 rounded-full ${ - micStatus === "ready" || micStatus === "recording" - ? "bg-[#75aafc]" - : micStatus === "denied" || micStatus === "error" - ? "bg-red-400" - : "bg-[#3f6fb3]" - }`} - /> - {micStatus === "ready" || micStatus === "recording" - ? "MIC READY" - : micStatus === "requesting" - ? "REQUESTING..." - : micStatus === "denied" - ? "MIC DENIED" - : micStatus === "error" - ? "MIC ERROR" - : "MIC IDLE"} - </div> - {isListening && ( - <div className="w-full h-1.5 bg-[#0f1c2f] overflow-hidden"> - <div - className="h-full bg-[#75aafc] transition-all duration-75" - style={{ width: `${micVolume * 100}%` }} - /> - </div> - )} - </div> - - {/* Connection status */} - <div - className={`inline-flex flex-col gap-1 px-2 py-1 border ${ - isConnected - ? "border-[#3f6fb3] text-[#75aafc]" - : connectionStatus === "connecting" - ? "border-[#3f6fb3] text-[#9bc3ff]" - : "border-[rgba(117,170,252,0.25)] text-[#9bc3ff]" - }`} - > - <div className="inline-flex items-center gap-1.5"> - <span - className={`w-1.5 h-1.5 rounded-full ${ - isConnected ? "bg-[#75aafc]" : "bg-[#3f6fb3]" - }`} - /> - {isConnected - ? "CONNECTED" - : connectionStatus === "connecting" - ? "LOADING MODELS..." - : "DISCONNECTED"} - </div> - {connectionStatus === "connecting" && ( - <div className="w-full h-1.5 bg-[#0f1c2f] overflow-hidden"> - <div - className="h-full w-1/3 bg-[#75aafc]" - style={{ - animation: "loading-slide 1.5s ease-in-out infinite", - }} - /> - </div> - )} - </div> - </div> - - {/* Error display */} - {error && ( - <div className="font-mono text-xs text-red-400 text-center px-2 py-1 border border-red-400/50 bg-red-400/10 max-w-[250px]"> - {error} - </div> - )} - - {/* Buttons */} - <div className="flex gap-2"> - <button - onClick={onNew} - className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0d1b2d] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase tracking-wide" - title={hasTranscripts ? "Save and start new session" : "Start new session"} - > - New - </button> - <button - onClick={() => setIsModalOpen(true)} - disabled={isListening || contractsLoading} - className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0d1b2d] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors disabled:opacity-50 disabled:cursor-not-allowed uppercase tracking-wide" - title={selectedContract ? `Saving to: ${selectedContract.name}` : "Transcript not saved"} - > - {selectedContract ? "Contract" : "Ephemeral"} - </button> - </div> - - <ContractPickerModal - isOpen={isModalOpen} - onClose={() => setIsModalOpen(false)} - contracts={contracts} - selectedContractId={selectedContractId} - onSelect={onContractChange} - onDiscussContract={onDiscussContract} - loading={contractsLoading} - /> - </div> - ); -} diff --git a/makima/frontend/src/components/listen/DiscussContractModal.tsx b/makima/frontend/src/components/listen/DiscussContractModal.tsx deleted file mode 100644 index 984f505..0000000 --- a/makima/frontend/src/components/listen/DiscussContractModal.tsx +++ /dev/null @@ -1,287 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from "react"; -import { useSpeakWebSocket } from "../../hooks/useSpeakWebSocket"; -import { - discussContract, - type CreatedContractInfo, - type ContractToolCallInfo, - type ChatMessage, -} from "../../lib/api"; - -interface Message { - id: string; - role: "user" | "assistant"; - content: string; - timestamp: Date; - toolCalls?: ContractToolCallInfo[]; -} - -interface DiscussContractModalProps { - isOpen: boolean; - onClose: () => void; - transcriptContext?: string; - onContractCreated: (contract: CreatedContractInfo) => void; -} - -interface ChatBubbleProps { - message: Message; - onSpeak: () => void; - isSpeaking: boolean; -} - -function ChatBubble({ message, onSpeak, isSpeaking }: ChatBubbleProps) { - const isUser = message.role === "user"; - - return ( - <div className={`flex ${isUser ? "justify-end" : "justify-start"}`}> - <div - className={`max-w-[80%] p-3 font-mono text-sm ${ - isUser - ? "bg-[#0f3c78] text-[#dbe7ff]" - : "bg-[#0d1b2d] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff]" - }`} - > - <div className="whitespace-pre-wrap">{message.content}</div> - - {!isUser && ( - <div className="mt-2 flex items-center gap-2"> - <button - onClick={onSpeak} - className="text-[10px] text-[#9bc3ff] hover:text-[#dbe7ff] uppercase" - > - {isSpeaking ? "[Stop]" : "[Speak]"} - </button> - </div> - )} - - {message.toolCalls && message.toolCalls.length > 0 && ( - <div className="mt-2 pt-2 border-t border-[rgba(117,170,252,0.25)]"> - {message.toolCalls.map((tc, i) => ( - <div key={i} className="text-[10px] text-[#75aafc]"> - {tc.result.success ? "+" : "-"} {tc.name}: {tc.result.message} - </div> - ))} - </div> - )} - </div> - </div> - ); -} - -export function DiscussContractModal({ - isOpen, - onClose, - transcriptContext, - onContractCreated, -}: DiscussContractModalProps) { - const [messages, setMessages] = useState<Message[]>([]); - const [input, setInput] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState<string | null>(null); - const [createdContract, setCreatedContract] = useState<CreatedContractInfo | null>(null); - - const { speak, isSpeaking, cancel } = useSpeakWebSocket(); - const messagesEndRef = useRef<HTMLDivElement>(null); - const modalRef = useRef<HTMLDivElement>(null); - - // Auto-scroll to bottom on new messages - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); - - // Initial greeting when modal opens - useEffect(() => { - if (isOpen && messages.length === 0) { - const greeting = transcriptContext - ? "I've reviewed your session transcript. What would you like to build based on this discussion?" - : "Hello! I'm Makima. Tell me about what you'd like to build, and I'll help you create a contract for it."; - - setMessages([{ - id: crypto.randomUUID(), - role: "assistant", - content: greeting, - timestamp: new Date(), - }]); - } - }, [isOpen, transcriptContext, messages.length]); - - // Handle escape key and click outside - useEffect(() => { - if (!isOpen) return; - - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - onClose(); - } - } - - function handleClickOutside(e: MouseEvent) { - if (modalRef.current && !modalRef.current.contains(e.target as Node)) { - onClose(); - } - } - - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("mousedown", handleClickOutside); - - return () => { - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("mousedown", handleClickOutside); - }; - }, [isOpen, onClose]); - - // Reset state when modal closes - useEffect(() => { - if (!isOpen) { - setMessages([]); - setInput(""); - setIsLoading(false); - setError(null); - setCreatedContract(null); - } - }, [isOpen]); - - const handleSend = useCallback(async () => { - if (!input.trim() || isLoading) return; - - const userMessage: Message = { - id: crypto.randomUUID(), - role: "user", - content: input, - timestamp: new Date(), - }; - - setMessages(prev => [...prev, userMessage]); - setInput(""); - setIsLoading(true); - setError(null); - - try { - // Build history from existing messages (excluding the greeting) - const history: ChatMessage[] = messages.map(m => ({ - role: m.role, - content: m.content, - })); - - const response = await discussContract( - input, - undefined, // model - history, - transcriptContext - ); - - const assistantMessage: Message = { - id: crypto.randomUUID(), - role: "assistant", - content: response.response, - timestamp: new Date(), - toolCalls: response.toolCalls, - }; - - setMessages(prev => [...prev, assistantMessage]); - - if (response.createdContract) { - setCreatedContract(response.createdContract); - onContractCreated(response.createdContract); - } - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to get response"); - } finally { - setIsLoading(false); - } - }, [input, isLoading, messages, transcriptContext, onContractCreated]); - - const handleSpeak = useCallback((text: string) => { - if (isSpeaking) { - cancel(); - } else { - speak(text); - } - }, [isSpeaking, cancel, speak]); - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }, [handleSend]); - - if (!isOpen) return null; - - return ( - <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> - <div - ref={modalRef} - className="panel w-full max-w-2xl h-[600px] flex flex-col mx-4" - > - {/* Header */} - <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.25)]"> - <h2 className="font-mono text-sm text-[#dbe7ff] uppercase tracking-wide"> - Discuss Contract with Makima - </h2> - <button - onClick={onClose} - className="font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" - > - [X] - </button> - </div> - - {/* Messages */} - <div className="flex-1 overflow-y-auto p-4 space-y-4"> - {messages.map((message) => ( - <ChatBubble - key={message.id} - message={message} - onSpeak={() => handleSpeak(message.content)} - isSpeaking={isSpeaking} - /> - ))} - <div ref={messagesEndRef} /> - - {isLoading && ( - <div className="flex items-center gap-2 text-[#9bc3ff]"> - <span className="animate-pulse font-mono text-sm">Makima is thinking...</span> - </div> - )} - - {error && ( - <div className="font-mono text-xs text-red-400 px-2 py-2 border border-red-400/50 bg-red-400/10"> - {error} - </div> - )} - </div> - - {/* Contract Created Banner */} - {createdContract && ( - <div className="p-3 bg-green-400/10 border-t border-green-400/50"> - <div className="font-mono text-xs text-green-400"> - Contract "{createdContract.name}" created successfully! - </div> - </div> - )} - - {/* Input */} - <div className="p-4 border-t border-[rgba(117,170,252,0.25)]"> - <div className="flex gap-2"> - <input - type="text" - value={input} - onChange={(e) => setInput(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Describe your project..." - disabled={isLoading || !!createdContract} - className="flex-1 px-3 py-2 bg-[#0d1b2d] border border-[#0f3c78] text-[#dbe7ff] font-mono text-sm focus:border-[#3f6fb3] outline-none disabled:opacity-50" - /> - <button - onClick={handleSend} - disabled={isLoading || !input.trim() || !!createdContract} - className="px-4 py-2 bg-[#0f3c78] text-[#dbe7ff] font-mono text-xs uppercase hover:bg-[#153667] disabled:opacity-50 disabled:cursor-not-allowed transition-colors" - > - Send - </button> - </div> - </div> - </div> - </div> - ); -} diff --git a/makima/frontend/src/components/listen/SpeakerPanel.tsx b/makima/frontend/src/components/listen/SpeakerPanel.tsx deleted file mode 100644 index cb43992..0000000 --- a/makima/frontend/src/components/listen/SpeakerPanel.tsx +++ /dev/null @@ -1,61 +0,0 @@ -interface Speaker { - id: string; - label: string; - isActive: boolean; -} - -interface SpeakerPanelProps { - speakers: Speaker[]; -} - -const SPEAKER_SYMBOLS = ["///", ":::", "***", "###", "+++", "---", "===", "%%%"]; - -export function SpeakerPanel({ speakers }: SpeakerPanelProps) { - return ( - <div className="panel h-full p-4 flex flex-col"> - <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase mb-3 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)]"> - SPEAKERS// - </div> - - {speakers.length === 0 ? ( - <div className="flex-1 flex items-center justify-center text-[#9bc3ff] text-sm font-mono opacity-60"> - <span>Waiting for speech...</span> - </div> - ) : ( - <div className="flex-1 flex flex-col gap-3"> - {speakers.map((speaker, index) => ( - <div - key={speaker.id} - className={`flex items-center gap-3 p-3 border ${ - speaker.isActive - ? "border-[#3f6fb3] bg-[#0f1c2f]" - : "border-[rgba(117,170,252,0.25)] bg-[#0b1423]" - } transition-colors`} - > - <span - className={`font-mono text-2xl tracking-tighter ${ - speaker.isActive - ? "text-[#75aafc] animate-pulse" - : "text-[#3f6fb3]" - }`} - > - {SPEAKER_SYMBOLS[index % SPEAKER_SYMBOLS.length]} - </span> - <div className="flex-1"> - <div className="font-mono text-sm text-[#dbe7ff]"> - {speaker.label} - </div> - <div className="font-mono text-xs text-[#9bc3ff]"> - {speaker.isActive ? "speaking" : "idle"} - </div> - </div> - {speaker.isActive && ( - <div className="w-2 h-2 rounded-full bg-[#75aafc] animate-pulse" /> - )} - </div> - ))} - </div> - )} - </div> - ); -} diff --git a/makima/frontend/src/components/listen/TranscriptAnalysisPanel.tsx b/makima/frontend/src/components/listen/TranscriptAnalysisPanel.tsx deleted file mode 100644 index 89d56a8..0000000 --- a/makima/frontend/src/components/listen/TranscriptAnalysisPanel.tsx +++ /dev/null @@ -1,339 +0,0 @@ -import { useState, useCallback } from "react"; -import { - analyzeTranscript, - createContractFromTranscript, - updateContractFromTranscript, - type TranscriptAnalysisResult, - type CreateContractResponse, -} from "../../lib/listenApi"; - -interface TranscriptAnalysisPanelProps { - fileId: string; - contractId: string; - selectedContractId: string | null; - onContractCreated?: (response: CreateContractResponse) => void; - onContractUpdated?: (response: CreateContractResponse) => void; - onClose?: () => void; -} - -type AnalysisState = "idle" | "analyzing" | "analyzed" | "creating" | "updating"; - -export function TranscriptAnalysisPanel({ - fileId, - contractId, - selectedContractId, - onContractCreated, - onContractUpdated, - onClose, -}: TranscriptAnalysisPanelProps) { - const [state, setState] = useState<AnalysisState>("idle"); - const [analysis, setAnalysis] = useState<TranscriptAnalysisResult | null>(null); - const [error, setError] = useState<string | null>(null); - const [successMessage, setSuccessMessage] = useState<string | null>(null); - - const handleAnalyze = useCallback(async () => { - setState("analyzing"); - setError(null); - setSuccessMessage(null); - - try { - const response = await analyzeTranscript(fileId); - setAnalysis(response.analysis); - setState("analyzed"); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to analyze transcript"); - setState("idle"); - } - }, [fileId]); - - const handleCreateContract = useCallback(async () => { - if (!analysis) return; - - setState("creating"); - setError(null); - - try { - const response = await createContractFromTranscript(fileId, { - name: analysis.suggestedContractName, - description: analysis.suggestedDescription, - includeRequirements: true, - includeDecisions: true, - includeActionItems: true, - }); - setSuccessMessage(`Created contract "${response.contractName}"`); - onContractCreated?.(response); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create contract"); - setState("analyzed"); - } - }, [fileId, analysis, onContractCreated]); - - const handleUpdateContract = useCallback(async () => { - if (!analysis || !selectedContractId) return; - - setState("updating"); - setError(null); - - try { - const response = await updateContractFromTranscript(fileId, selectedContractId, { - description: analysis.suggestedDescription, - includeRequirements: true, - includeDecisions: true, - includeActionItems: true, - }); - setSuccessMessage(`Updated contract "${response.contractName}"`); - onContractUpdated?.(response); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to update contract"); - setState("analyzed"); - } - }, [fileId, analysis, selectedContractId, onContractUpdated]); - - // Group requirements by category - const groupedRequirements = analysis?.requirements.reduce((acc, req) => { - const category = req.category || "General"; - if (!acc[category]) { - acc[category] = []; - } - acc[category].push(req); - return acc; - }, {} as Record<string, typeof analysis.requirements>) || {}; - - return ( - <div className="panel p-4 flex flex-col gap-4"> - {/* Header */} - <div className="flex justify-between items-center border-b border-dashed border-[rgba(117,170,252,0.35)] pb-2"> - <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> - TRANSCRIPT ANALYSIS// - </div> - {onClose && ( - <button - onClick={onClose} - className="font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" - > - [X] - </button> - )} - </div> - - {/* Error display */} - {error && ( - <div className="font-mono text-xs text-red-400 px-2 py-2 border border-red-400/50 bg-red-400/10"> - {error} - </div> - )} - - {/* Success message */} - {successMessage && ( - <div className="font-mono text-xs text-green-400 px-2 py-2 border border-green-400/50 bg-green-400/10"> - {successMessage} - </div> - )} - - {/* Initial state - Show analyze button */} - {state === "idle" && ( - <div className="flex flex-col items-center gap-3 py-4"> - <p className="font-mono text-sm text-[#9bc3ff] text-center"> - Transcript saved. Analyze to extract requirements, decisions, and action items. - </p> - <button - onClick={handleAnalyze} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0d1b2d] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase tracking-wide" - > - Analyze Transcript - </button> - </div> - )} - - {/* Analyzing state */} - {state === "analyzing" && ( - <div className="flex flex-col items-center gap-3 py-8"> - <div className="w-6 h-6 border-2 border-[#3f6fb3] border-t-[#75aafc] rounded-full animate-spin" /> - <p className="font-mono text-sm text-[#9bc3ff]">Analyzing transcript...</p> - </div> - )} - - {/* Analysis results */} - {(state === "analyzed" || state === "creating" || state === "updating") && analysis && ( - <div className="flex flex-col gap-4 overflow-y-auto max-h-[60vh]"> - {/* Suggested Contract Info */} - {(analysis.suggestedContractName || analysis.suggestedDescription) && ( - <div className="border border-[rgba(117,170,252,0.25)] p-3"> - <div className="font-mono text-xs text-[#75aafc] uppercase tracking-wide mb-2"> - Suggested Contract - </div> - {analysis.suggestedContractName && ( - <div className="font-mono text-sm text-[#dbe7ff] mb-1"> - {analysis.suggestedContractName} - </div> - )} - {analysis.suggestedDescription && ( - <div className="font-mono text-xs text-[#9bc3ff]"> - {analysis.suggestedDescription} - </div> - )} - </div> - )} - - {/* Requirements */} - {Object.keys(groupedRequirements).length > 0 && ( - <div className="border border-[rgba(117,170,252,0.25)] p-3"> - <div className="font-mono text-xs text-[#75aafc] uppercase tracking-wide mb-2"> - Requirements ({analysis.requirements.length}) - </div> - {Object.entries(groupedRequirements).map(([category, reqs]) => ( - <div key={category} className="mb-3 last:mb-0"> - <div className="font-mono text-[10px] text-[#3f6fb3] uppercase tracking-wider mb-1"> - {category} - </div> - <ul className="space-y-1"> - {reqs.map((req, idx) => ( - <li key={idx} className="font-mono text-xs text-[#dbe7ff] flex gap-2"> - <span className="text-[#3f6fb3]">-</span> - <span>{req.text}</span> - {req.speaker && ( - <span className="text-[#9bc3ff] text-[10px]">({req.speaker})</span> - )} - </li> - ))} - </ul> - </div> - ))} - </div> - )} - - {/* Decisions */} - {analysis.decisions.length > 0 && ( - <div className="border border-[rgba(117,170,252,0.25)] p-3"> - <div className="font-mono text-xs text-[#75aafc] uppercase tracking-wide mb-2"> - Decisions ({analysis.decisions.length}) - </div> - <ul className="space-y-2"> - {analysis.decisions.map((decision, idx) => ( - <li key={idx} className="font-mono text-xs text-[#dbe7ff]"> - <div className="flex gap-2"> - <span className="text-[#3f6fb3]">-</span> - <span>{decision.text}</span> - </div> - {decision.context && ( - <div className="ml-4 text-[10px] text-[#9bc3ff] mt-1"> - Context: {decision.context} - </div> - )} - </li> - ))} - </ul> - </div> - )} - - {/* Action Items */} - {analysis.actionItems.length > 0 && ( - <div className="border border-[rgba(117,170,252,0.25)] p-3"> - <div className="font-mono text-xs text-[#75aafc] uppercase tracking-wide mb-2"> - Action Items ({analysis.actionItems.length}) - </div> - <ul className="space-y-2"> - {analysis.actionItems.map((item, idx) => ( - <li key={idx} className="font-mono text-xs text-[#dbe7ff]"> - <div className="flex gap-2 items-start"> - <span className="text-[#3f6fb3]">-</span> - <div className="flex-1"> - <span>{item.text}</span> - <div className="flex gap-2 mt-1"> - {item.assignee && ( - <span className="text-[10px] px-1.5 py-0.5 bg-[#0f1c2f] border border-[#3f6fb3] text-[#9bc3ff]"> - @{item.assignee} - </span> - )} - {item.priority && ( - <span - className={`text-[10px] px-1.5 py-0.5 border ${ - item.priority === "high" - ? "border-red-400/50 text-red-400 bg-red-400/10" - : item.priority === "medium" - ? "border-yellow-400/50 text-yellow-400 bg-yellow-400/10" - : "border-[#3f6fb3] text-[#9bc3ff] bg-[#0f1c2f]" - }`} - > - {item.priority} - </span> - )} - </div> - </div> - </div> - </li> - ))} - </ul> - </div> - )} - - {/* Key Topics */} - {analysis.keyTopics.length > 0 && ( - <div className="border border-[rgba(117,170,252,0.25)] p-3"> - <div className="font-mono text-xs text-[#75aafc] uppercase tracking-wide mb-2"> - Key Topics - </div> - <div className="flex flex-wrap gap-2"> - {analysis.keyTopics.map((topic, idx) => ( - <span - key={idx} - className="font-mono text-[10px] px-2 py-1 bg-[#0f1c2f] border border-[#3f6fb3] text-[#9bc3ff]" - > - {topic} - </span> - ))} - </div> - </div> - )} - - {/* Speaker Statistics */} - {analysis.speakerSummary.length > 0 && ( - <div className="border border-[rgba(117,170,252,0.25)] p-3"> - <div className="font-mono text-xs text-[#75aafc] uppercase tracking-wide mb-2"> - Speaker Statistics - </div> - <div className="space-y-2"> - {analysis.speakerSummary.map((speaker, idx) => ( - <div key={idx} className="flex items-center gap-3"> - <span className="font-mono text-xs text-[#dbe7ff] min-w-[100px]"> - {speaker.speaker} - </span> - <div className="flex-1 h-2 bg-[#0f1c2f] overflow-hidden"> - <div - className="h-full bg-[#3f6fb3]" - style={{ width: `${speaker.contributionPercentage}%` }} - /> - </div> - <span className="font-mono text-[10px] text-[#9bc3ff] min-w-[40px] text-right"> - {speaker.contributionPercentage.toFixed(0)}% - </span> - </div> - ))} - </div> - </div> - )} - - {/* Action Buttons */} - <div className="flex gap-2 pt-2 border-t border-dashed border-[rgba(117,170,252,0.35)]"> - <button - onClick={handleCreateContract} - disabled={state === "creating" || state === "updating"} - className="flex-1 px-3 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0d1b2d] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase tracking-wide disabled:opacity-50 disabled:cursor-not-allowed" - > - {state === "creating" ? "Creating..." : "Create New Contract"} - </button> - {selectedContractId && selectedContractId !== contractId && ( - <button - onClick={handleUpdateContract} - disabled={state === "creating" || state === "updating"} - className="flex-1 px-3 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0d1b2d] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase tracking-wide disabled:opacity-50 disabled:cursor-not-allowed" - > - {state === "updating" ? "Updating..." : "Add to Current Contract"} - </button> - )} - </div> - </div> - )} - </div> - ); -} diff --git a/makima/frontend/src/components/listen/TranscriptPanel.tsx b/makima/frontend/src/components/listen/TranscriptPanel.tsx deleted file mode 100644 index 662c94f..0000000 --- a/makima/frontend/src/components/listen/TranscriptPanel.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { useRef, useEffect, useState, useCallback } from "react"; -import type { TranscriptEntry } from "../../types/messages"; - -interface TranscriptPanelProps { - transcripts: TranscriptEntry[]; -} - -export function TranscriptPanel({ transcripts }: TranscriptPanelProps) { - const containerRef = useRef<HTMLDivElement>(null); - const [autoScroll, setAutoScroll] = useState(true); - - // Auto-scroll when new transcripts arrive - useEffect(() => { - if (autoScroll && containerRef.current) { - containerRef.current.scrollTop = containerRef.current.scrollHeight; - } - }, [transcripts, autoScroll]); - - // Detect manual scroll - const handleScroll = useCallback(() => { - if (!containerRef.current) return; - - const { scrollTop, scrollHeight, clientHeight } = containerRef.current; - const isAtBottom = scrollHeight - scrollTop - clientHeight < 50; - - setAutoScroll(isAtBottom); - }, []); - - const scrollToBottom = useCallback(() => { - if (containerRef.current) { - containerRef.current.scrollTop = containerRef.current.scrollHeight; - setAutoScroll(true); - } - }, []); - - return ( - <div className="panel h-full flex flex-col"> - <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)] flex justify-between items-center"> - <span>TRANSCRIPT//</span> - {!autoScroll && ( - <button - onClick={scrollToBottom} - className="px-2 py-1 text-[10px] bg-[#0f1c2f] border border-[#3f6fb3] hover:bg-[#153667] transition-colors" - > - Scroll to bottom - </button> - )} - </div> - - <div - ref={containerRef} - onScroll={handleScroll} - className="flex-1 overflow-y-auto p-4 space-y-3" - > - {transcripts.length === 0 ? ( - <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8"> - Transcriptions will appear here... - </div> - ) : ( - transcripts.map((entry) => ( - <div - key={entry.id} - className={`font-mono text-sm ${ - entry.isFinal ? "opacity-100" : "opacity-70" - }`} - > - <div className="flex items-baseline gap-2 mb-1"> - <span className="text-[#75aafc] text-xs"> - [{entry.start.toFixed(2)}s - {entry.end.toFixed(2)}s] - </span> - <span className="text-[#9bc3ff] text-xs font-bold"> - {entry.speaker} - </span> - {entry.isFinal && ( - <span className="text-[#3f6fb3] text-[10px]">[FINAL]</span> - )} - </div> - <p className="m-0 text-[#dbe7ff] leading-relaxed">{entry.text}</p> - </div> - )) - )} - </div> - </div> - ); -} diff --git a/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx b/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx deleted file mode 100644 index 5caa3c4..0000000 --- a/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx +++ /dev/null @@ -1,536 +0,0 @@ -import { useState, useCallback, useRef, useEffect } from "react"; -import { - type LlmModel, - type UserQuestion, - type UserAnswer, - type MeshChatContext, -} from "../../lib/api"; -import { useMeshChatHistory } from "../../hooks/useMeshChatHistory"; -import { SimpleMarkdown } from "../SimpleMarkdown"; - -interface UnifiedMeshChatInputProps { - context: MeshChatContext; - onUpdate?: () => void; -} - -const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [ - { value: "claude-opus", label: "Claude Opus" }, - { value: "claude-sonnet", label: "Claude Sonnet" }, - { value: "groq", label: "Groq Kimi" }, -]; - -const DEFAULT_MODEL: LlmModel = "claude-opus"; - -// LocalStorage keys -const STORAGE_KEY_MODEL = "makima-mesh-chat-model"; -const STORAGE_KEY_CMD_HISTORY = "makima-mesh-chat-cmd-history"; -const MAX_CMD_HISTORY = 100; - -function loadModel(): LlmModel { - try { - const modelStr = localStorage.getItem(STORAGE_KEY_MODEL); - return (modelStr as LlmModel) || DEFAULT_MODEL; - } catch { - return DEFAULT_MODEL; - } -} - -function saveModel(model: LlmModel): void { - try { - localStorage.setItem(STORAGE_KEY_MODEL, model); - } catch { - // Ignore storage errors - } -} - -function loadCommandHistory(): string[] { - try { - const historyJson = localStorage.getItem(STORAGE_KEY_CMD_HISTORY); - return historyJson ? JSON.parse(historyJson) : []; - } catch { - return []; - } -} - -function saveCommandHistory(history: string[]): void { - try { - localStorage.setItem( - STORAGE_KEY_CMD_HISTORY, - JSON.stringify(history.slice(-MAX_CMD_HISTORY)) - ); - } catch { - // Ignore storage errors - } -} - -function getPlaceholder(context: MeshChatContext): string { - switch (context.type) { - case "mesh": - return "Create task, list tasks, check status..."; - case "task": - return "Create subtask, run task, check status..."; - case "subtask": - return "Update plan, check siblings, merge..."; - default: - return "Ask anything..."; - } -} - -function getContextLabel(context: MeshChatContext): string { - switch (context.type) { - case "mesh": - return "mesh"; - case "task": - return `task:${context.taskId?.slice(0, 8)}`; - case "subtask": - return `subtask:${context.taskId?.slice(0, 8)}`; - default: - return "chat"; - } -} - -export function UnifiedMeshChatInput({ - context, - onUpdate, -}: UnifiedMeshChatInputProps) { - const { - messages, - loading: historyLoading, - error: historyError, - sending, - clearHistory, - sendMessage, - } = useMeshChatHistory(); - - const [input, setInput] = useState(""); - const [expanded, setExpanded] = useState(false); - const [model, setModel] = useState<LlmModel>(DEFAULT_MODEL); - - // Pending questions state - const [pendingQuestions, setPendingQuestions] = useState< - UserQuestion[] | null - >(null); - const [userAnswers, setUserAnswers] = useState<Map<string, string[]>>( - new Map() - ); - const [customInputs, setCustomInputs] = useState<Map<string, string>>( - new Map() - ); - - // Command history for arrow key navigation - const [commandHistory, setCommandHistory] = useState<string[]>([]); - const [historyIndex, setHistoryIndex] = useState(-1); - const [savedInput, setSavedInput] = useState(""); - - const inputRef = useRef<HTMLInputElement>(null); - const messagesRef = useRef<HTMLDivElement>(null); - - // Load model preference on mount - useEffect(() => { - setModel(loadModel()); - setCommandHistory(loadCommandHistory()); - }, []); - - // Expand when messages exist - useEffect(() => { - if (messages.length > 0) { - setExpanded(true); - } - }, [messages.length]); - - // Auto-scroll to bottom when messages change - useEffect(() => { - if (messagesRef.current) { - messagesRef.current.scrollTop = messagesRef.current.scrollHeight; - } - }, [messages]); - - // Handle model change - const handleModelChange = useCallback((newModel: LlmModel) => { - setModel(newModel); - saveModel(newModel); - }, []); - - // Handle keyboard navigation for command history - const handleKeyDown = useCallback( - (e: React.KeyboardEvent<HTMLInputElement>) => { - if (e.key === "ArrowUp") { - e.preventDefault(); - if (commandHistory.length === 0) return; - - if (historyIndex === -1) { - setSavedInput(input); - setHistoryIndex(commandHistory.length - 1); - setInput(commandHistory[commandHistory.length - 1]); - } else if (historyIndex > 0) { - setHistoryIndex(historyIndex - 1); - setInput(commandHistory[historyIndex - 1]); - } - } else if (e.key === "ArrowDown") { - e.preventDefault(); - if (historyIndex === -1) return; - - if (historyIndex < commandHistory.length - 1) { - setHistoryIndex(historyIndex + 1); - setInput(commandHistory[historyIndex + 1]); - } else { - setHistoryIndex(-1); - setInput(savedInput); - } - } - }, - [commandHistory, historyIndex, input, savedInput] - ); - - const handleSubmit = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - if (!input.trim() || sending) return; - - const userMessage = input.trim(); - - // Update command history - const newHistory = - commandHistory[commandHistory.length - 1] !== userMessage - ? [...commandHistory, userMessage] - : commandHistory; - setCommandHistory(newHistory); - saveCommandHistory(newHistory); - - // Reset navigation state - setHistoryIndex(-1); - setSavedInput(""); - - setInput(""); - setExpanded(true); - - // Send message via hook (uses DB-persisted history) - const response = await sendMessage(userMessage, context, model); - - if (response) { - // Handle pending questions - if (response.pendingQuestions?.length) { - setPendingQuestions(response.pendingQuestions); - const initialAnswers = new Map<string, string[]>(); - response.pendingQuestions.forEach((q) => { - initialAnswers.set(q.id, []); - }); - setUserAnswers(initialAnswers); - setCustomInputs(new Map()); - } - - // Notify parent that something may have been updated - // Always refresh when tool calls were made (state may have changed) - if (response.toolCalls && response.toolCalls.length > 0) { - onUpdate?.(); - } - } - - inputRef.current?.focus(); - }, - [input, sending, context, model, sendMessage, onUpdate, commandHistory] - ); - - // Handle option selection for a question - const handleOptionToggle = useCallback( - (questionId: string, option: string, allowMultiple: boolean) => { - setUserAnswers((prev) => { - const newMap = new Map(prev); - const currentAnswers = newMap.get(questionId) || []; - - if (allowMultiple) { - if (currentAnswers.includes(option)) { - newMap.set( - questionId, - currentAnswers.filter((a) => a !== option) - ); - } else { - newMap.set(questionId, [...currentAnswers, option]); - } - } else { - newMap.set(questionId, [option]); - } - - return newMap; - }); - }, - [] - ); - - // Handle custom input change - const handleCustomInputChange = useCallback( - (questionId: string, value: string) => { - setCustomInputs((prev) => { - const newMap = new Map(prev); - newMap.set(questionId, value); - return newMap; - }); - }, - [] - ); - - // Submit answers to questions - const handleSubmitAnswers = useCallback(async () => { - if (!pendingQuestions || sending) return; - - // Build answers array - const answers: UserAnswer[] = pendingQuestions.map((q) => { - const selectedOptions = userAnswers.get(q.id) || []; - const customInput = customInputs.get(q.id)?.trim(); - const finalAnswers = customInput - ? [...selectedOptions, customInput] - : selectedOptions; - - return { - id: q.id, - answers: finalAnswers, - }; - }); - - // Format answers as a message - const answerText = answers - .map((a) => { - const question = pendingQuestions.find((q) => q.id === a.id); - return `${question?.question || a.id}: ${a.answers.join(", ")}`; - }) - .join("\n"); - - // Clear pending questions - setPendingQuestions(null); - setUserAnswers(new Map()); - setCustomInputs(new Map()); - - // Send answers as the next message - const response = await sendMessage(answerText, context, model); - - if (response) { - // Handle more pending questions - if (response.pendingQuestions?.length) { - setPendingQuestions(response.pendingQuestions); - const initialAnswers = new Map<string, string[]>(); - response.pendingQuestions.forEach((q) => { - initialAnswers.set(q.id, []); - }); - setUserAnswers(initialAnswers); - setCustomInputs(new Map()); - } - - // Notify parent that something may have been updated - if (response.toolCalls && response.toolCalls.length > 0) { - onUpdate?.(); - } - } - }, [ - pendingQuestions, - userAnswers, - customInputs, - sending, - context, - model, - sendMessage, - onUpdate, - ]); - - // Cancel answering questions - const handleCancelQuestions = useCallback(() => { - setPendingQuestions(null); - setUserAnswers(new Map()); - setCustomInputs(new Map()); - }, []); - - const handleClearHistory = useCallback(async () => { - await clearHistory(); - setPendingQuestions(null); - setUserAnswers(new Map()); - setCustomInputs(new Map()); - }, [clearHistory]); - - const loading = sending || historyLoading; - - return ( - <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]"> - {/* Error Display */} - {historyError && ( - <div className="px-3 py-2 bg-red-900/20 text-red-400 text-xs font-mono"> - {historyError} - </div> - )} - - {/* Messages Panel (expandable) */} - {expanded && messages.length > 0 && ( - <div - ref={messagesRef} - className="max-h-48 overflow-y-auto p-3 space-y-2 border-b border-[rgba(117,170,252,0.2)]" - > - {messages.map((msg) => ( - <div key={msg.id} className="font-mono text-xs"> - {msg.role === "user" && ( - <div className="flex gap-2"> - <span className="text-[#9bc3ff]">></span> - <span className="text-white/80 whitespace-pre-wrap"> - {msg.content} - </span> - {msg.contextType !== "mesh" && ( - <span className="text-[#555] text-[10px]"> - [{msg.contextType}] - </span> - )} - </div> - )} - {msg.role === "assistant" && ( - <div className="pl-4 space-y-1"> - <SimpleMarkdown - content={msg.content} - className="text-[#75aafc]" - /> - {msg.toolCalls && msg.toolCalls.length > 0 && ( - <div className="text-[#555] text-[10px] space-y-0.5"> - {msg.toolCalls.map((tc, i) => ( - <div key={i}> - <span - className={ - tc.result.success - ? "text-green-500" - : "text-red-400" - } - > - {tc.result.success ? "+" : "x"} - </span>{" "} - {tc.name}: {tc.result.message} - </div> - ))} - </div> - )} - </div> - )} - {msg.role === "error" && ( - <div className="pl-4 text-red-400">{msg.content}</div> - )} - </div> - ))} - </div> - )} - - {/* Pending Questions UI */} - {pendingQuestions && pendingQuestions.length > 0 && ( - <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3"> - <div className="text-[#9bc3ff] font-mono text-xs uppercase tracking-wide"> - Questions from AI - </div> - {pendingQuestions.map((q) => ( - <div key={q.id} className="space-y-2"> - <div className="text-white/90 font-mono text-sm"> - {q.question} - </div> - <div className="flex flex-wrap gap-2"> - {q.options.map((option) => { - const isSelected = (userAnswers.get(q.id) || []).includes( - option - ); - return ( - <button - key={option} - type="button" - onClick={() => - handleOptionToggle(q.id, option, q.allowMultiple) - } - className={`px-2 py-1 font-mono text-xs border transition-colors ${ - isSelected - ? "bg-[#3f6fb3] border-[#75aafc] text-white" - : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-[#3f6fb3]" - }`} - > - {q.allowMultiple && ( - <span className="mr-1">{isSelected ? "+" : "-"}</span> - )} - {option} - </button> - ); - })} - </div> - {q.allowCustom && ( - <input - type="text" - value={customInputs.get(q.id) || ""} - onChange={(e) => handleCustomInputChange(q.id, e.target.value)} - placeholder="Or type a custom answer..." - className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] placeholder-[#555]" - /> - )} - </div> - ))} - <div className="flex gap-2 pt-2"> - <button - type="button" - onClick={handleSubmitAnswers} - disabled={loading} - className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" - > - {loading ? "..." : "Submit Answers"} - </button> - <button - type="button" - onClick={handleCancelQuestions} - disabled={loading} - className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors" - > - Cancel - </button> - </div> - </div> - )} - - {/* Input Bar */} - <form onSubmit={handleSubmit} className="flex items-center gap-2 p-3"> - <select - value={model} - onChange={(e) => handleModelChange(e.target.value as LlmModel)} - disabled={loading || !!pendingQuestions} - className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs px-2 py-1 rounded-none outline-none focus:border-[#3f6fb3] disabled:opacity-50" - > - {MODEL_OPTIONS.map((opt) => ( - <option key={opt.value} value={opt.value}> - {opt.label} - </option> - ))} - </select> - <span className="text-[#555] font-mono text-[10px]"> - [{getContextLabel(context)}] - </span> - <span className="text-[#9bc3ff] font-mono text-sm">></span> - <input - ref={inputRef} - type="text" - value={input} - onChange={(e) => setInput(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={ - loading - ? "Processing..." - : pendingQuestions - ? "Answer questions above first..." - : getPlaceholder(context) - } - disabled={loading || !!pendingQuestions} - className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]" - /> - {messages.length > 0 && ( - <button - type="button" - onClick={handleClearHistory} - className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors" - > - clear - </button> - )} - <button - type="submit" - disabled={loading || !input.trim() || !!pendingQuestions} - className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" - > - {loading ? "..." : "Send"} - </button> - </form> - </div> - ); -} diff --git a/makima/frontend/src/hooks/useMeshChatHistory.ts b/makima/frontend/src/hooks/useMeshChatHistory.ts deleted file mode 100644 index 82c576d..0000000 --- a/makima/frontend/src/hooks/useMeshChatHistory.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { useState, useCallback, useEffect } from "react"; -import { - getMeshChatHistory, - clearMeshChatHistory, - chatWithMeshContext, - type MeshChatMessageRecord, - type MeshChatContext, - type MeshChatResponse, - type LlmModel, -} from "../lib/api"; - -export interface MeshChatState { - conversationId: string | null; - messages: MeshChatMessageRecord[]; - loading: boolean; - error: string | null; - sending: boolean; -} - -export function useMeshChatHistory() { - const [state, setState] = useState<MeshChatState>({ - conversationId: null, - messages: [], - loading: true, - error: null, - sending: false, - }); - - const fetchHistory = useCallback(async () => { - setState((prev) => ({ ...prev, loading: true, error: null })); - try { - const response = await getMeshChatHistory(); - setState((prev) => ({ - ...prev, - conversationId: response.conversationId, - messages: response.messages, - loading: false, - })); - } catch (e) { - setState((prev) => ({ - ...prev, - error: e instanceof Error ? e.message : "Failed to fetch chat history", - loading: false, - })); - } - }, []); - - const clearHistory = useCallback(async (): Promise<boolean> => { - setState((prev) => ({ ...prev, loading: true, error: null })); - try { - const response = await clearMeshChatHistory(); - setState({ - conversationId: response.conversationId, - messages: [], - loading: false, - error: null, - sending: false, - }); - return true; - } catch (e) { - setState((prev) => ({ - ...prev, - error: e instanceof Error ? e.message : "Failed to clear chat history", - loading: false, - })); - return false; - } - }, []); - - const sendMessage = useCallback( - async ( - message: string, - context: MeshChatContext, - model?: LlmModel - ): Promise<MeshChatResponse | null> => { - setState((prev) => ({ ...prev, sending: true, error: null })); - - // Optimistically add user message (will be refetched after response) - const tempUserMessage: MeshChatMessageRecord = { - id: `temp-${Date.now()}`, - conversationId: state.conversationId || "", - role: "user", - content: message, - contextType: context.type, - contextTaskId: context.taskId || null, - toolCalls: null, - pendingQuestions: null, - createdAt: new Date().toISOString(), - }; - - setState((prev) => ({ - ...prev, - messages: [...prev.messages, tempUserMessage], - })); - - try { - const response = await chatWithMeshContext(message, context, model); - - // Refetch to get the actual saved messages (with proper IDs) - await fetchHistory(); - - setState((prev) => ({ ...prev, sending: false })); - return response; - } catch (e) { - // Remove optimistic message on error - setState((prev) => ({ - ...prev, - messages: prev.messages.filter((m) => m.id !== tempUserMessage.id), - error: e instanceof Error ? e.message : "Failed to send message", - sending: false, - })); - return null; - } - }, - [state.conversationId, fetchHistory] - ); - - // Initial fetch on mount - useEffect(() => { - fetchHistory(); - }, [fetchHistory]); - - return { - conversationId: state.conversationId, - messages: state.messages, - loading: state.loading, - error: state.error, - sending: state.sending, - fetchHistory, - clearHistory, - sendMessage, - }; -} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index b34f786..b7b904e 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -220,51 +220,9 @@ export class VersionConflictError extends Error { } } -// Available LLM models -export type LlmModel = "claude-sonnet" | "claude-opus" | "groq"; - -// Chat API types -export interface ChatMessage { - role: "user" | "assistant"; - content: string; -} - -export interface ChatRequest { - message: string; - model?: LlmModel; - history?: ChatMessage[]; - focusedElementIndex?: number; -} - -export interface ToolCallInfo { - name: string; - result: { - success: boolean; - message: string; - }; -} - -// User question types for interactive LLM tool -export interface UserQuestion { - id: string; - question: string; - options: string[]; - allowMultiple: boolean; - allowCustom: boolean; -} - -export interface UserAnswer { - id: string; - answers: string[]; -} - -export interface ChatResponse { - response: string; - toolCalls: ToolCallInfo[]; - updatedBody: BodyElement[]; - updatedSummary: string | null; - pendingQuestions?: UserQuestion[]; -} +// (LlmModel, ChatMessage, ChatRequest, UserQuestion, UserAnswer, +// ChatResponse, ToolCallInfo removed alongside the LLM module — +// see Chat API function block further down + the deleted /llm/ tree.) // File API functions export async function listFiles(): Promise<FileListResponse> { @@ -323,34 +281,7 @@ export async function deleteFile(id: string): Promise<void> { } } -// Chat API function -export async function chatWithFile( - id: string, - message: string, - model?: LlmModel, - history?: ChatMessage[], - focusedElementIndex?: number -): Promise<ChatResponse> { - const body: ChatRequest = { message }; - if (model) { - body.model = model; - } - if (history && history.length > 0) { - body.history = history; - } - if (focusedElementIndex !== undefined) { - body.focusedElementIndex = focusedElementIndex; - } - const res = await authFetch(`${API_BASE}/api/v1/files/${id}/chat`, { - method: "POST", - body: JSON.stringify(body), - }); - if (!res.ok) { - const errorText = await res.text(); - throw new Error(`Chat failed: ${errorText || res.statusText}`); - } - return res.json(); -} +// chatWithFile removed — handler `/api/v1/files/{id}/chat` is gone. // Version history types export type VersionSource = "user" | "llm" | "system"; @@ -1327,186 +1258,9 @@ export async function getDaemonReauthStatus( return res.json(); } -// ============================================================================= -// Mesh Chat Types for Task Orchestration -// ============================================================================= - -export interface MeshChatMessage { - role: "user" | "assistant"; - content: string; -} - -export interface MeshChatRequest { - message: string; - model?: LlmModel; - history?: MeshChatMessage[]; -} - -export interface MeshToolCallInfo { - name: string; - result: { - success: boolean; - message: string; - }; -} - -export interface MeshChatResponse { - response: string; - toolCalls: MeshToolCallInfo[]; - pendingQuestions?: UserQuestion[]; -} - -// Mesh Chat API functions - -// Top-level mesh chat (no specific task context) -export async function chatWithMesh( - message: string, - model?: LlmModel, - history?: MeshChatMessage[] -): Promise<MeshChatResponse> { - const body: MeshChatRequest = { message }; - if (model) { - body.model = model; - } - if (history && history.length > 0) { - body.history = history; - } - const res = await authFetch(`${API_BASE}/api/v1/mesh/chat`, { - method: "POST", - body: JSON.stringify(body), - }); - if (!res.ok) { - const errorText = await res.text(); - throw new Error(`Mesh chat failed: ${errorText || res.statusText}`); - } - return res.json(); -} - -// Task-scoped mesh chat -export async function chatWithTask( - taskId: string, - message: string, - model?: LlmModel, - history?: MeshChatMessage[] -): Promise<MeshChatResponse> { - const body: MeshChatRequest = { message }; - if (model) { - body.model = model; - } - if (history && history.length > 0) { - body.history = history; - } - const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/chat`, { - method: "POST", - body: JSON.stringify(body), - }); - if (!res.ok) { - const errorText = await res.text(); - throw new Error(`Mesh chat failed: ${errorText || res.statusText}`); - } - return res.json(); -} - -// ============================================================================= -// Mesh Chat History Types -// ============================================================================= - -export type MeshChatContextType = "mesh" | "task" | "subtask"; - -export interface MeshChatContext { - type: MeshChatContextType; - taskId?: string; - parentTaskId?: string; -} - -export interface MeshChatMessageRecord { - id: string; - conversationId: string; - role: "user" | "assistant" | "error"; - content: string; - contextType: MeshChatContextType; - contextTaskId: string | null; - toolCalls: MeshToolCallInfo[] | null; - pendingQuestions: UserQuestion[] | null; - createdAt: string; -} - -export interface MeshChatHistoryResponse { - conversationId: string; - messages: MeshChatMessageRecord[]; -} - -export interface MeshChatWithContextRequest { - message: string; - model?: LlmModel; - contextType?: MeshChatContextType; - contextTaskId?: string; -} - -// ============================================================================= -// Mesh Chat History API Functions -// ============================================================================= - -/** - * Get the current chat history from the database - */ -export async function getMeshChatHistory(): Promise<MeshChatHistoryResponse> { - const res = await authFetch(`${API_BASE}/api/v1/mesh/chat/history`); - if (!res.ok) { - throw new Error(`Failed to get chat history: ${res.statusText}`); - } - return res.json(); -} - -/** - * Clear chat history (archives current conversation, starts new one) - */ -export async function clearMeshChatHistory(): Promise<{ success: boolean; conversationId: string }> { - const res = await authFetch(`${API_BASE}/api/v1/mesh/chat/history`, { - method: "DELETE", - }); - if (!res.ok) { - throw new Error(`Failed to clear chat history: ${res.statusText}`); - } - return res.json(); -} - -/** - * Chat with mesh using context (new approach with DB history) - */ -export async function chatWithMeshContext( - message: string, - context: MeshChatContext, - model?: LlmModel -): Promise<MeshChatResponse> { - const body: MeshChatWithContextRequest = { - message, - contextType: context.type, - }; - - if (model) { - body.model = model; - } - - // Set contextTaskId based on context type - if (context.type === "task" && context.taskId) { - body.contextTaskId = context.taskId; - } else if (context.type === "subtask" && context.taskId) { - body.contextTaskId = context.taskId; - } - - // Use top-level endpoint (it now loads history from DB) - const res = await authFetch(`${API_BASE}/api/v1/mesh/chat`, { - method: "POST", - body: JSON.stringify(body), - }); - - if (!res.ok) { - const errorText = await res.text(); - throw new Error(`Mesh chat failed: ${errorText || res.statusText}`); - } - return res.json(); -} +// (Mesh chat types + chatWithMesh / chatWithTask / getMeshChatHistory / +// clearMeshChatHistory / chatWithMeshContext / MeshChatContext etc. +// removed alongside the LLM module — backend handlers gone.) // ============================================================================= // API Key Management @@ -1770,58 +1524,10 @@ export function getDefaultPhase(contractType: ContractType): ContractPhase { return "research"; } -// ============================================================================= -// Contract Type Templates -// ============================================================================= - -/** Contract type template returned by the API */ -export interface ContractTypeTemplate { - /** Template identifier (e.g., "simple", "specification") */ - id: string; - /** Display name */ - name: string; - /** Description of the contract type workflow */ - description: string; - /** Ordered list of phases for this contract type */ - phases: ContractPhase[]; - /** Default starting phase */ - defaultPhase: ContractPhase; - /** Whether this is a built-in type (always available) */ - isBuiltin: boolean; - /** Optional mapping from phase ID to display name */ - phaseNames?: Record<string, string>; -} - -/** Response from list contract types endpoint */ -export interface ListContractTypesResponse { - contractTypes: ContractTypeTemplate[]; -} - -/** Phase definition for custom templates */ -export interface PhaseDefinition { - id: string; - name: string; - order: number; -} - -/** Deliverable definition for custom templates */ -export interface DeliverableDefinition { - id: string; - name: string; - priority: "required" | "recommended" | "optional"; -} - -/** - * List available contract types. - * Returns built-in types only (simple, specification, execute). - */ -export async function listContractTypes(): Promise<ListContractTypesResponse> { - const res = await authFetch(`${API_BASE}/api/v1/contract-types`); - if (!res.ok) { - throw new Error(`Failed to list contract types: ${res.statusText}`); - } - return res.json(); -} +// (ContractTypeTemplate / ListContractTypesResponse / PhaseDefinition / +// DeliverableDefinition / listContractTypes removed alongside the LLM +// module — the templates handler was the only consumer and the +// endpoint /api/v1/contract-types is gone.) export interface ContractRepository { id: string; @@ -2194,153 +1900,9 @@ export async function removeTaskFromContract( } } -// ============================================================================= -// Contract Chat Types and API -// ============================================================================= - -export interface ContractChatRequest { - message: string; - model?: LlmModel; - history?: ChatMessage[]; -} - -export interface ContractToolCallInfo { - name: string; - result: { - success: boolean; - message: string; - }; -} - -export interface ContractChatResponse { - response: string; - toolCalls: ContractToolCallInfo[]; - pendingQuestions?: UserQuestion[]; -} - -/** - * Chat with a contract using LLM-powered management tools. - */ -export async function chatWithContract( - contractId: string, - message: string, - model?: LlmModel, - history?: ChatMessage[] -): Promise<ContractChatResponse> { - const body: ContractChatRequest = { message }; - if (model) { - body.model = model; - } - if (history && history.length > 0) { - body.history = history; - } - const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}/chat`, { - method: "POST", - body: JSON.stringify(body), - }); - if (!res.ok) { - const errorText = await res.text(); - throw new Error(`Contract chat failed: ${errorText || res.statusText}`); - } - return res.json(); -} - -// Contract chat history types -export interface ContractChatMessage { - id: string; - conversationId: string; - role: "user" | "assistant" | "error"; - content: string; - toolCalls?: unknown; - pendingQuestions?: unknown; - createdAt: string; -} - -export interface ContractChatHistoryResponse { - contractId: string; - conversationId: string; - messages: ContractChatMessage[]; -} - -/** Get contract chat history */ -export async function getContractChatHistory( - contractId: string -): Promise<ContractChatHistoryResponse> { - const res = await authFetch( - `${API_BASE}/api/v1/contracts/${contractId}/chat/history` - ); - if (!res.ok) { - throw new Error(`Failed to fetch contract chat history: ${res.statusText}`); - } - return res.json(); -} - -/** Clear contract chat history (starts a new conversation) */ -export async function clearContractChatHistory( - contractId: string -): Promise<void> { - const res = await authFetch( - `${API_BASE}/api/v1/contracts/${contractId}/chat/history`, - { method: "DELETE" } - ); - if (!res.ok) { - throw new Error(`Failed to clear contract chat history: ${res.statusText}`); - } -} - -// ============================================================================= -// Contract Discussion Types and API -// ============================================================================= - -export interface DiscussContractRequest { - message: string; - model?: LlmModel; - history?: ChatMessage[]; - transcriptContext?: string; -} - -export interface CreatedContractInfo { - id: string; - name: string; - description: string | null; - contractType: string; - initialPhase: string; -} - -export interface DiscussContractResponse { - response: string; - toolCalls: ContractToolCallInfo[]; - createdContract?: CreatedContractInfo; - pendingQuestions?: UserQuestion[]; -} - -/** - * Discuss a potential contract with Makima. - * This is an ephemeral conversation that can result in contract creation. - */ -export async function discussContract( - message: string, - model?: LlmModel, - history?: ChatMessage[], - transcriptContext?: string -): Promise<DiscussContractResponse> { - const body: DiscussContractRequest = { message }; - if (model) body.model = model; - if (history && history.length > 0) body.history = history; - if (transcriptContext) body.transcriptContext = transcriptContext; - - const res = await authFetch(`${API_BASE}/api/v1/contracts/discuss`, { - method: "POST", - body: JSON.stringify(body), - }); - - if (!res.ok) { - const errorText = await res.text(); - throw new Error(`Discussion failed: ${errorText || res.statusText}`); - } - - return res.json(); -} +// (Contract Chat / Contract Discussion API removed alongside the LLM +// module — legacy contracts chat handlers were already Phase-5 +// removed; these were the dead frontend half.) // ============================================================================= // Template Types and API diff --git a/makima/frontend/src/lib/listenApi.ts b/makima/frontend/src/lib/listenApi.ts deleted file mode 100644 index 187ebe0..0000000 --- a/makima/frontend/src/lib/listenApi.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { API_BASE } from './api'; -import { supabase } from './supabase'; - -// ============================================================================= -// Authentication helper (same pattern as api.ts) -// ============================================================================= - -/** Storage key for API key */ -const API_KEY_STORAGE_KEY = "makima_api_key"; - -/** Get stored API key from localStorage */ -function getStoredApiKey(): string | null { - if (typeof window === "undefined") return null; - return localStorage.getItem(API_KEY_STORAGE_KEY); -} - -/** Get auth headers for API requests */ -async function getAuthHeaders(): Promise<HeadersInit> { - const headers: HeadersInit = { - "Content-Type": "application/json", - }; - - // Try Supabase session first - if (supabase) { - const { data: { session } } = await supabase.auth.getSession(); - if (session?.access_token) { - headers["Authorization"] = `Bearer ${session.access_token}`; - return headers; - } - } - - // Fall back to API key if available - const apiKey = getStoredApiKey(); - if (apiKey) { - headers["X-Makima-API-Key"] = apiKey; - } - - return headers; -} - -// ============================================================================= -// Transcript Analysis Types -// ============================================================================= - -export interface TranscriptAnalysisResult { - requirements: Array<{ - text: string; - speaker: string; - timestamp: number; - confidence: number; - category?: string; - }>; - decisions: Array<{ - text: string; - speaker: string; - timestamp: number; - confidence: number; - context?: string; - }>; - actionItems: Array<{ - text: string; - speaker: string; - timestamp: number; - assignee?: string; - priority?: string; - }>; - keyTopics: string[]; - suggestedContractName?: string; - suggestedDescription?: string; - speakerSummary: Array<{ - speaker: string; - wordCount: number; - speakingTimeSeconds: number; - contributionPercentage: number; - }>; -} - -export interface AnalyzeResponse { - fileId: string; - analysis: TranscriptAnalysisResult; -} - -export interface CreateContractResponse { - contractId: string; - contractName: string; - filesCreated: Array<{ id: string; name: string; fileType: string }>; - tasksCreated: Array<{ id: string; name: string }>; -} - -export interface CreateContractOptions { - name?: string; - description?: string; - includeRequirements?: boolean; - includeDecisions?: boolean; - includeActionItems?: boolean; -} - -// ============================================================================= -// Listen API Functions -// ============================================================================= - -/** - * Analyze a transcript file to extract requirements, decisions, and action items. - */ -export async function analyzeTranscript(fileId: string): Promise<AnalyzeResponse> { - const response = await fetch(`${API_BASE}/api/v1/listen/analyze`, { - method: 'POST', - headers: await getAuthHeaders(), - body: JSON.stringify({ fileId }), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'Failed to analyze transcript' })); - throw new Error(error.message || 'Failed to analyze transcript'); - } - - return response.json(); -} - -/** - * Create a contract from a transcript analysis. - */ -export async function createContractFromTranscript( - fileId: string, - options?: CreateContractOptions -): Promise<CreateContractResponse> { - const response = await fetch(`${API_BASE}/api/v1/listen/create-contract`, { - method: 'POST', - headers: await getAuthHeaders(), - body: JSON.stringify({ - fileId, - ...options, - }), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'Failed to create contract' })); - throw new Error(error.message || 'Failed to create contract'); - } - - return response.json(); -} - -/** - * Update an existing contract with transcript analysis. - */ -export async function updateContractFromTranscript( - fileId: string, - contractId: string, - options?: Omit<CreateContractOptions, 'name'> -): Promise<CreateContractResponse> { - const response = await fetch(`${API_BASE}/api/v1/listen/update-contract`, { - method: 'POST', - headers: await getAuthHeaders(), - body: JSON.stringify({ - fileId, - contractId, - ...options, - }), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'Failed to update contract' })); - throw new Error(error.message || 'Failed to update contract'); - } - - return response.json(); -} diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index c1c6c35..8665ecc 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -10,7 +10,6 @@ import { PhaseConfirmationNotification } from "./components/PhaseConfirmationNot import { QuickSwitcher } from "./components/QuickSwitcher"; import { ProtectedRoute } from "./components/ProtectedRoute"; import HomePage from "./routes/_index"; -import ListenPage from "./routes/listen"; import FilesPage from "./routes/files"; import OrdersPage from "./routes/orders"; import MeshPage from "./routes/mesh"; @@ -36,14 +35,6 @@ createRoot(document.getElementById("root")!).render( <Route path="/" element={<HomePage />} /> <Route path="/login" element={<LoginPage />} /> <Route - path="/listen" - element={ - <ProtectedRoute> - <ListenPage /> - </ProtectedRoute> - } - /> - <Route path="/files" element={ <ProtectedRoute> diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx index b232aa0..5d757a4 100644 --- a/makima/frontend/src/routes/files.tsx +++ b/makima/frontend/src/routes/files.tsx @@ -3,7 +3,6 @@ import { useParams, useNavigate } from "react-router"; import { Masthead } from "../components/Masthead"; import { FileList } from "../components/files/FileList"; import { FileDetail, type FocusedElement } from "../components/files/FileDetail"; -import { CliInput } from "../components/files/CliInput"; import { ConflictNotification } from "../components/files/ConflictNotification"; import { UpdateNotification } from "../components/files/UpdateNotification"; import { useFiles } from "../hooks/useFiles"; @@ -57,7 +56,6 @@ function FilesPageContent() { const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null); const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(null); const [focusedElement, setFocusedElement] = useState<FocusedElement | null>(null); - const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null); const [createdTask, setCreatedTask] = useState<Task | null>(null); // Contract selection modal state for task creation const [showContractModal, setShowContractModal] = useState(false); @@ -246,18 +244,8 @@ function FilesPageContent() { [editFile, fileDetail, updateHasLocalChanges] ); - const handleBodyUpdate = useCallback( - (body: BodyElement[], summary: string | null) => { - if (fileDetail) { - setFileDetail({ - ...fileDetail, - body, - summary, - }); - } - }, - [fileDetail] - ); + // handleBodyUpdate was the CliInput callback for AI-driven body + // rewrites. CliInput is gone with the LLM module. const handleBodyElementUpdate = useCallback( async (index: number, element: BodyElement) => { @@ -423,9 +411,7 @@ function FilesPageContent() { setFocusedElement(element); }, []); - const handleClearFocus = useCallback(() => { - setFocusedElement(null); - }, []); + // handleClearFocus was passed to CliInput; both gone now. // Convert element to a different type const handleConvertElement = useCallback( @@ -506,55 +492,10 @@ function FilesPageContent() { [fileDetail, id, editFile, updateHasLocalChanges, focusedElement] ); - // Generate from element - focus on it and pre-fill a prompt - const handleGenerateFromElement = useCallback( - (index: number, action: string) => { - if (!fileDetail) return; - - const element = fileDetail.body[index]; - if (!element) return; - - // Get preview text - let preview = ""; - switch (element.type) { - case "heading": - case "paragraph": - preview = element.text.slice(0, 50); - break; - case "code": - preview = element.content.slice(0, 50); - break; - case "list": - preview = element.items[0]?.slice(0, 40) || ""; - break; - default: - preview = "Element"; - } - - // Focus on the element - setFocusedElement({ - index, - type: element.type, - preview: preview + (preview.length >= 50 ? "..." : ""), - }); - - // Set suggested prompt based on action - let prompt = ""; - switch (action) { - case "elaborate": - prompt = "Elaborate and expand on this content"; - break; - case "summarize": - prompt = "Summarize this content"; - break; - case "extract_actions": - prompt = "Extract action items from this content"; - break; - } - setSuggestedPrompt(prompt); - }, - [fileDetail] - ); + // handleGenerateFromElement was an LLM elaborate/summarise/extract + // affordance that piped a suggested prompt into CliInput. Both + // CliInput and the LLM module are gone; the handler + its prop on + // FileDetail are removed. // Create a mesh task from an element - shows contract selection modal const handleCreateTaskFromElement = useCallback( @@ -762,7 +703,6 @@ function FilesPageContent() { onBodyElementDelete={handleBodyElementDelete} onBodyElementDuplicate={handleBodyElementDuplicate} onConvertElement={handleConvertElement} - onGenerateFromElement={handleGenerateFromElement} onCreateTaskFromElement={handleCreateTaskFromElement} onEditingChange={updateIsActivelyEditing} hasPendingRemoteUpdate={!!remoteUpdate} @@ -779,16 +719,10 @@ function FilesPageContent() { onClearVersionSelection={clearSelectedVersion} /> </div> - <div className="shrink-0"> - <CliInput - fileId={id} - onUpdate={handleBodyUpdate} - focusedElement={focusedElement} - onClearFocus={handleClearFocus} - suggestedPrompt={suggestedPrompt} - onClearSuggestedPrompt={() => setSuggestedPrompt(null)} - /> - </div> + {/* CliInput (file-level LLM chat) removed alongside the LLM + module. The file detail view is now a pure read/edit + surface; AI-driven file editing was the primary value of + this composer and went with the LLM removal. */} </div> ) : id && detailLoading ? ( <div className="panel h-full flex items-center justify-center"> diff --git a/makima/frontend/src/routes/listen.tsx b/makima/frontend/src/routes/listen.tsx deleted file mode 100644 index a53cbd9..0000000 --- a/makima/frontend/src/routes/listen.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { useState, useCallback, useMemo, useEffect, useRef } from "react"; -import { Masthead } from "../components/Masthead"; -import { SpeakerPanel } from "../components/listen/SpeakerPanel"; -import { TranscriptPanel } from "../components/listen/TranscriptPanel"; -import { ControlPanel, type ContractOption } from "../components/listen/ControlPanel"; -import { TranscriptAnalysisPanel } from "../components/listen/TranscriptAnalysisPanel"; -import { DiscussContractModal } from "../components/listen/DiscussContractModal"; -import { useMicrophone } from "../hooks/useMicrophone"; -import { useWebSocket } from "../hooks/useWebSocket"; -import { listContracts, type CreatedContractInfo } from "../lib/api"; -import { useAuth } from "../contexts/AuthContext"; - -export default function ListenPage() { - const [isListening, setIsListening] = useState(false); - const [activeSpeaker, setActiveSpeaker] = useState<string | null>(null); - const [permissionRequested, setPermissionRequested] = useState(false); - const isListeningRef = useRef(false); - - // Contract selection state - const [contracts, setContracts] = useState<ContractOption[]>([]); - const [selectedContractId, setSelectedContractId] = useState<string | null>(null); - const [contractsLoading, setContractsLoading] = useState(true); - const { session, isAuthenticated } = useAuth(); - - // Saved transcript state for analysis - const [savedTranscript, setSavedTranscript] = useState<{ - fileId: string; - contractId: string; - } | null>(null); - - // Discuss contract modal state - const [isDiscussModalOpen, setIsDiscussModalOpen] = useState(false); - - // Fetch contracts on mount - useEffect(() => { - if (!isAuthenticated) { - setContractsLoading(false); - return; - } - - async function fetchContracts() { - try { - const response = await listContracts(); - setContracts( - response.contracts.map((c) => ({ - id: c.id, - name: c.name, - })) - ); - } catch (err) { - console.error("Failed to fetch contracts:", err); - } finally { - setContractsLoading(false); - } - } - fetchContracts(); - }, [isAuthenticated]); - - // Keep ref in sync with state for use in callbacks - useEffect(() => { - isListeningRef.current = isListening; - }, [isListening]); - - const ws = useWebSocket({ - onTranscript: (transcript) => { - // Track active speaker - if (!transcript.isFinal) { - setActiveSpeaker(transcript.speaker); - } - }, - onStopped: () => { - setIsListening(false); - setActiveSpeaker(null); - }, - onTranscriptSaved: (fileId, contractId) => { - // Store the saved transcript info for analysis - setSavedTranscript({ fileId, contractId }); - }, - }); - - const wsRef = useRef(ws); - useEffect(() => { - wsRef.current = ws; - }, [ws]); - - const handleAudioData = useCallback((samples: Float32Array) => { - if (wsRef.current.isConnected && isListeningRef.current) { - wsRef.current.sendAudio(samples); - } - }, []); - - const mic = useMicrophone({ - sampleRate: 16000, - onAudioData: handleAudioData, - }); - - // Request microphone permission on page load - useEffect(() => { - if (!permissionRequested) { - setPermissionRequested(true); - mic.requestPermission(); - } - }, [permissionRequested, mic.requestPermission]); - - // Derive unique speakers from transcripts - const speakers = useMemo(() => { - const speakerSet = new Set<string>(); - ws.transcripts.forEach((t) => speakerSet.add(t.speaker)); - - return Array.from(speakerSet).map((speaker) => ({ - id: speaker, - label: speaker, - isActive: speaker === activeSpeaker, - })); - }, [ws.transcripts, activeSpeaker]); - - // Clear active speaker after a delay - useEffect(() => { - if (activeSpeaker) { - const timer = setTimeout(() => setActiveSpeaker(null), 1500); - return () => clearTimeout(timer); - } - }, [activeSpeaker]); - - const handleToggle = useCallback(async () => { - if (isListening) { - // Stop listening - mic.stop(); - ws.stopSession("user_stopped"); - setIsListening(false); - setActiveSpeaker(null); - return; - } - - // If permission was denied or errored, try requesting again - if (mic.status === "denied" || mic.status === "error") { - const permitted = await mic.requestPermission(); - if (!permitted) { - return; - } - } - - // Start listening - start the microphone - const micStarted = await mic.start(); - if (!micStarted) { - // Microphone permission denied or error - return; - } - - // Microphone started, now connect to WebSocket - const connected = await ws.connect(); - if (!connected) { - // Connection failed - stop the microphone - mic.stop(); - return; - } - - // Both microphone and WebSocket are ready - start the session - // Pass contract_id and auth token if available - const authToken = session?.access_token || null; - ws.startSession(mic.sampleRate, mic.channels, selectedContractId, authToken); - setIsListening(true); - }, [isListening, mic, ws, selectedContractId, session]); - - const handleNew = useCallback(() => { - // Stop current session - backend auto-saves transcript on disconnect - mic.stop(); - if (ws.isConnected) { - ws.stopSession("new_session"); - } - ws.clearTranscripts(); - ws.disconnect(); - setIsListening(false); - setActiveSpeaker(null); - setSavedTranscript(null); - }, [mic, ws]); - - const handleCloseAnalysis = useCallback(() => { - setSavedTranscript(null); - }, []); - - // Get current transcript context for discussion - const transcriptContext = useMemo(() => { - if (ws.transcripts.length === 0) return undefined; - return ws.transcripts - .map(t => `[${t.speaker}]: ${t.text}`) - .join("\n"); - }, [ws.transcripts]); - - const handleOpenDiscussModal = useCallback(() => { - setIsDiscussModalOpen(true); - }, []); - - const handleContractCreated = useCallback((contract: CreatedContractInfo) => { - // Add to contracts list and select it - setContracts(prev => [ - { id: contract.id, name: contract.name }, - ...prev, - ]); - setSelectedContractId(contract.id); - // Close the modal after a short delay to show success - setTimeout(() => setIsDiscussModalOpen(false), 2000); - }, []); - - const error = ws.error || mic.error; - - return ( - <div className="relative z-10 h-screen flex flex-col overflow-hidden"> - <Masthead showTicker={false} showNav /> - - <main className="flex-1 p-4 md:p-6 grid grid-cols-1 md:grid-cols-[300px_1fr] grid-rows-[minmax(0,1fr)_auto] md:grid-rows-[minmax(0,1fr)_auto] gap-4 min-h-0 overflow-hidden"> - {/* Speaker Panel - top left on desktop, hidden on mobile */} - <div className="hidden md:block row-span-1 min-h-0 overflow-hidden"> - <SpeakerPanel speakers={speakers} /> - </div> - - {/* Transcript Panel - right side, spans 2 rows on desktop */} - <div className="md:row-span-2 min-h-0 overflow-hidden"> - <TranscriptPanel transcripts={ws.transcripts} /> - </div> - - {/* Control Panel - bottom left on desktop */} - <div className="md:col-start-1 md:row-start-2 shrink-0"> - <ControlPanel - isListening={isListening} - isConnected={ws.isConnected} - micStatus={mic.status} - micVolume={mic.volume} - hasTranscripts={ws.transcripts.length > 0} - onToggle={handleToggle} - onNew={handleNew} - error={error} - contracts={contracts} - selectedContractId={selectedContractId} - onContractChange={setSelectedContractId} - onDiscussContract={handleOpenDiscussModal} - contractsLoading={contractsLoading} - connectionStatus={ws.status} - /> - </div> - </main> - - {/* Transcript Analysis Panel - shown after recording stops and transcript is saved */} - {savedTranscript && !isListening && ( - <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> - <div className="w-full max-w-2xl mx-4 max-h-[90vh] overflow-hidden"> - <TranscriptAnalysisPanel - fileId={savedTranscript.fileId} - contractId={savedTranscript.contractId} - selectedContractId={selectedContractId} - onContractCreated={(response) => { - // Refresh contracts list and select the new contract - setContracts((prev) => [ - { id: response.contractId, name: response.contractName }, - ...prev, - ]); - setSelectedContractId(response.contractId); - }} - onContractUpdated={() => { - // Keep the current selection - }} - onClose={handleCloseAnalysis} - /> - </div> - </div> - )} - - {/* Discuss Contract Modal */} - <DiscussContractModal - isOpen={isDiscussModalOpen} - onClose={() => setIsDiscussModalOpen(false)} - transcriptContext={transcriptContext} - onContractCreated={handleContractCreated} - /> - </div> - ); -} diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx index f210227..362b2e0 100644 --- a/makima/frontend/src/routes/mesh.tsx +++ b/makima/frontend/src/routes/mesh.tsx @@ -4,11 +4,10 @@ import { Masthead } from "../components/Masthead"; import { TaskList } from "../components/mesh/TaskList"; import { TaskDetail } from "../components/mesh/TaskDetail"; import { TaskOutput } from "../components/mesh/TaskOutput"; -import { UnifiedMeshChatInput } from "../components/mesh/UnifiedMeshChatInput"; import { ContractCompleteQuestion } from "../components/mesh/ContractCompleteQuestion"; import { useTasks } from "../hooks/useTasks"; import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription"; -import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRelations, DaemonDirectory, TaskSummary, RepositoryHistoryEntry } from "../lib/api"; +import type { TaskWithSubtasks, ContractSummary, ContractWithRelations, DaemonDirectory, TaskSummary, RepositoryHistoryEntry } from "../lib/api"; import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories, continueTask as continueTaskApi, resumeSupervisor, branchTask, getRepositorySuggestions, getTaskDiff } from "../lib/api"; import { DirectoryInput } from "../components/mesh/DirectoryInput"; import { useAuth } from "../contexts/AuthContext"; @@ -650,28 +649,8 @@ export default function MeshPage() { setShowRepoSuggestions(false); }, []); - // Callback when task is updated via CLI - const handleTaskUpdatedFromCli = useCallback(async () => { - if (id) { - const updated = await fetchTask(id); - if (updated) { - setTaskDetail(updated); - } - } - // Also refresh the task list - fetchTasks(); - }, [id, fetchTask, fetchTasks]); - - // Calculate chat context based on current view - const chatContext: MeshChatContext = useMemo(() => { - if (!id) { - return { type: "mesh" }; - } - if (taskDetail?.parentTaskId) { - return { type: "subtask", taskId: id, parentTaskId: taskDetail.parentTaskId }; - } - return { type: "task", taskId: id }; - }, [id, taskDetail?.parentTaskId]); + // handleTaskUpdatedFromCli + chatContext drove the deleted + // UnifiedMeshChatInput; gone with the LLM module. // Handle resizing of the split panel const handleResizeStart = useCallback((e: MouseEvent) => { @@ -904,13 +883,9 @@ export default function MeshPage() { </div> )} - {/* Mesh Chat Input - always rendered to persist state across navigation */} - <div className="shrink-0"> - <UnifiedMeshChatInput - context={chatContext} - onUpdate={id ? handleTaskUpdatedFromCli : fetchTasks} - /> - </div> + {/* UnifiedMeshChatInput removed alongside the LLM module. The + mesh page is now a pure task viewer; spawning / editing + tasks via natural language chat went with LLM removal. */} </div> </main> diff --git a/makima/src/lib.rs b/makima/src/lib.rs index 3bc460b..1c4683d 100644 --- a/makima/src/lib.rs +++ b/makima/src/lib.rs @@ -2,7 +2,6 @@ pub mod audio; pub mod daemon; pub mod db; pub mod listen; -pub mod llm; pub mod orchestration; pub mod server; pub mod tts; diff --git a/makima/src/llm/claude.rs b/makima/src/llm/claude.rs deleted file mode 100644 index f475acd..0000000 --- a/makima/src/llm/claude.rs +++ /dev/null @@ -1,304 +0,0 @@ -//! Claude API client for LLM tool calling. - -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use super::tools::{Tool, ToolCall}; - -const CLAUDE_API_URL: &str = "https://api.anthropic.com/v1/messages"; -const ANTHROPIC_VERSION: &str = "2023-06-01"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ClaudeModel { - Opus, - Sonnet, -} - -impl ClaudeModel { - pub fn model_id(&self) -> &'static str { - match self { - ClaudeModel::Opus => "claude-opus-4-5-20251101", - ClaudeModel::Sonnet => "claude-sonnet-4-5-20250929", - } - } -} - -impl Default for ClaudeModel { - fn default() -> Self { - ClaudeModel::Opus - } -} - -#[derive(Debug, Error)] -pub enum ClaudeError { - #[error("HTTP request failed: {0}")] - Request(#[from] reqwest::Error), - #[error("API error: {0}")] - Api(String), - #[error("Missing API key")] - MissingApiKey, -} - -#[derive(Debug, Clone)] -pub struct ClaudeClient { - api_key: String, - client: reqwest::Client, - model: ClaudeModel, -} - -// Request types -#[derive(Debug, Serialize)] -struct ClaudeRequest { - model: String, - max_tokens: u32, - messages: Vec<Message>, - #[serde(skip_serializing_if = "Option::is_none")] - tools: Option<Vec<ToolDefinition>>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Message { - pub role: String, - pub content: MessageContent, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum MessageContent { - Text(String), - Blocks(Vec<ContentBlock>), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum ContentBlock { - #[serde(rename = "text")] - Text { text: String }, - #[serde(rename = "tool_use")] - ToolUse { - id: String, - name: String, - input: serde_json::Value, - }, - #[serde(rename = "tool_result")] - ToolResult { - tool_use_id: String, - content: String, - }, -} - -#[derive(Debug, Serialize)] -struct ToolDefinition { - name: String, - description: String, - input_schema: serde_json::Value, -} - -// Response types -#[derive(Debug, Deserialize)] -struct ClaudeResponse { - content: Vec<ResponseContentBlock>, - stop_reason: Option<String>, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type")] -pub enum ResponseContentBlock { - #[serde(rename = "text")] - Text { text: String }, - #[serde(rename = "tool_use")] - ToolUse { - id: String, - name: String, - input: serde_json::Value, - }, -} - -#[derive(Debug)] -pub struct ChatResult { - pub content: Option<String>, - pub tool_calls: Vec<ToolCall>, - /// Raw tool use blocks for including in subsequent messages - pub raw_tool_uses: Vec<ResponseContentBlock>, - pub stop_reason: String, -} - -impl ClaudeClient { - pub fn new(api_key: String, model: ClaudeModel) -> Self { - Self { - api_key, - client: reqwest::Client::new(), - model, - } - } - - pub fn from_env(model: ClaudeModel) -> Result<Self, ClaudeError> { - let api_key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| ClaudeError::MissingApiKey)?; - Ok(Self::new(api_key, model)) - } - - pub async fn chat_with_tools( - &self, - messages: Vec<Message>, - tools: &[Tool], - ) -> Result<ChatResult, ClaudeError> { - let tool_definitions: Vec<ToolDefinition> = tools - .iter() - .map(|t| ToolDefinition { - name: t.name.clone(), - description: t.description.clone(), - input_schema: t.parameters.clone(), - }) - .collect(); - - let request = ClaudeRequest { - model: self.model.model_id().to_string(), - max_tokens: 4096, - messages, - tools: Some(tool_definitions), - }; - - let response = self - .client - .post(CLAUDE_API_URL) - .header("x-api-key", &self.api_key) - .header("anthropic-version", ANTHROPIC_VERSION) - .header("Content-Type", "application/json") - .json(&request) - .send() - .await?; - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - return Err(ClaudeError::Api(error_text)); - } - - let claude_response: ClaudeResponse = response.json().await?; - - let stop_reason = claude_response.stop_reason.unwrap_or_else(|| "end_turn".to_string()); - - // Extract text content and tool uses from content blocks - let mut text_parts: Vec<String> = Vec::new(); - let mut raw_tool_uses: Vec<ResponseContentBlock> = Vec::new(); - - for block in &claude_response.content { - match block { - ResponseContentBlock::Text { text } => { - if !text.is_empty() { - text_parts.push(text.clone()); - } - } - ResponseContentBlock::ToolUse { .. } => { - raw_tool_uses.push(block.clone()); - } - } - } - - let content = if text_parts.is_empty() { - None - } else { - Some(text_parts.join("\n")) - }; - - // Convert tool uses to ToolCalls - let tool_calls: Vec<ToolCall> = raw_tool_uses - .iter() - .filter_map(|block| { - if let ResponseContentBlock::ToolUse { id, name, input } = block { - Some(ToolCall { - id: id.clone(), - name: name.clone(), - arguments: input.clone(), - }) - } else { - None - } - }) - .collect(); - - Ok(ChatResult { - content, - tool_calls, - raw_tool_uses, - stop_reason, - }) - } -} - -/// Helper to convert Groq-style messages to Claude messages -pub fn groq_messages_to_claude(messages: &[super::groq::Message]) -> Vec<Message> { - let mut claude_messages: Vec<Message> = Vec::new(); - - for msg in messages { - match msg.role.as_str() { - "system" => { - // Claude handles system prompts as first user message - if let Some(ref content) = msg.content { - claude_messages.push(Message { - role: "user".to_string(), - content: MessageContent::Text(format!("[System Instructions]: {}", content)), - }); - // Add assistant acknowledgment to maintain conversation structure - claude_messages.push(Message { - role: "assistant".to_string(), - content: MessageContent::Text("Understood. I'll follow these instructions.".to_string()), - }); - } - } - "user" => { - if let Some(ref content) = msg.content { - claude_messages.push(Message { - role: "user".to_string(), - content: MessageContent::Text(content.clone()), - }); - } - } - "assistant" => { - let mut blocks: Vec<ContentBlock> = Vec::new(); - - // Add text content if present - if let Some(ref content) = msg.content { - if !content.is_empty() { - blocks.push(ContentBlock::Text { text: content.clone() }); - } - } - - // Add tool uses if present - if let Some(ref tool_calls) = msg.tool_calls { - for tc in tool_calls { - let input: serde_json::Value = - serde_json::from_str(&tc.function.arguments).unwrap_or_default(); - blocks.push(ContentBlock::ToolUse { - id: tc.id.clone(), - name: tc.function.name.clone(), - input, - }); - } - } - - if !blocks.is_empty() { - claude_messages.push(Message { - role: "assistant".to_string(), - content: MessageContent::Blocks(blocks), - }); - } - } - "tool" => { - // Tool results in Claude go in a user message with tool_result blocks - if let Some(ref content) = msg.content { - let tool_use_id = msg.tool_call_id.clone().unwrap_or_default(); - claude_messages.push(Message { - role: "user".to_string(), - content: MessageContent::Blocks(vec![ContentBlock::ToolResult { - tool_use_id, - content: content.clone(), - }]), - }); - } - } - _ => {} - } - } - - claude_messages -} diff --git a/makima/src/llm/contract_evaluator.rs b/makima/src/llm/contract_evaluator.rs deleted file mode 100644 index e63bbfa..0000000 --- a/makima/src/llm/contract_evaluator.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Contract Evaluator - LLM-based evaluation of completed contracts against directive. -//! -//! This module will be reimplemented as part of the directive verification engine. -//! See the orchestration module for the new evaluation system. -//! -//! The new evaluation system will provide: -//! - Tiered verification (programmatic verifiers first, then LLM evaluation) -//! - Composite confidence scoring (weighted combination of results) -//! - Pluggable verifier interface (test runner, linter, build, type checker) -//! - Proper integration with the directive chain steps - -use serde::{Deserialize, Serialize}; -use sqlx::PgPool; -use uuid::Uuid; - -// use crate::db::models::{Contract, DirectiveAcceptanceCriterion, DirectiveRequirement}; - -/// Result of contract evaluation -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ContractEvaluationResult { - /// Whether the contract passed evaluation - pub passed: bool, - /// Overall score from 0.0 to 1.0 - pub overall_score: f64, - /// Results for each acceptance criterion - pub criteria_results: Vec<EvaluationCriterionResultLegacy>, - /// Summary feedback from the evaluator - pub summary_feedback: String, - /// Instructions for rework if failed - pub rework_instructions: Option<String>, -} - -/// Per-criterion evaluation result (legacy - kept for compatibility) -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct EvaluationCriterionResultLegacy { - pub criterion_id: String, - pub criterion_text: String, - pub passed: bool, - /// Score (0.0-1.0) - pub score: f64, - pub feedback: String, - /// Evidence supporting the evaluation - pub evidence: Vec<String>, -} - -/// File content for evaluation context -#[derive(Debug, Clone)] -pub struct FileContent { - pub path: String, - pub content: String, -} - -/// Contract evaluator for LLM-based assessment. -/// -/// NOTE: This is a stub implementation. The full evaluation system will be -/// implemented as part of the orchestration/verifier module. -pub struct ContractEvaluator { - _pool: PgPool, -} - -impl ContractEvaluator { - /// Create a new contract evaluator. - pub fn new(pool: PgPool) -> Self { - Self { _pool: pool } - } - - /// Evaluate a contract - stub implementation. - /// - /// This will be reimplemented in the orchestration module with: - /// - Programmatic verification (tests, lint, build) - /// - LLM evaluation - /// - Composite scoring - pub async fn evaluate_contract( - &self, - _contract_id: Uuid, - ) -> Result<ContractEvaluationResult, ContractEvaluatorError> { - // TODO: Implement using the new directive evaluation system - Err(ContractEvaluatorError::NotImplemented( - "Contract evaluator will be reimplemented with directive system".to_string(), - )) - } -} - -/// Error types for contract evaluation. -#[derive(Debug, thiserror::Error)] -pub enum ContractEvaluatorError { - #[error("Database error: {0}")] - Database(#[from] sqlx::Error), - - #[error("LLM error: {0}")] - Llm(String), - - #[error("Not implemented: {0}")] - NotImplemented(String), -} diff --git a/makima/src/llm/contract_tools.rs b/makima/src/llm/contract_tools.rs deleted file mode 100644 index 38d1a7e..0000000 --- a/makima/src/llm/contract_tools.rs +++ /dev/null @@ -1,1228 +0,0 @@ -//! Tool definitions for contract management via LLM. -//! -//! These tools allow the LLM to manage contracts: create tasks, add files, -//! manage repositories, and handle phase transitions. - -use serde_json::json; -use uuid::Uuid; - -use super::tools::Tool; - -/// Available tools for contract management -pub static CONTRACT_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::Lazy::new(|| { - vec![ - // ============================================================================= - // Query Tools - // ============================================================================= - Tool { - name: "get_contract_status".to_string(), - description: "Get an overview of the contract including current phase, file count, task count, and repository count.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "list_contract_files".to_string(), - description: "List all files in the contract with their names, descriptions, and phases.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "list_contract_tasks".to_string(), - description: "List all tasks in the contract with their names, status, and progress.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "list_contract_repositories".to_string(), - description: "List all repositories attached to the contract with their types and URLs/paths.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "read_file".to_string(), - description: "Read the full contents of a file including its body, transcript, and summary.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "file_id": { - "type": "string", - "description": "ID of the file to read" - } - }, - "required": ["file_id"] - }), - }, - // ============================================================================= - // File Management Tools - // ============================================================================= - Tool { - name: "create_empty_file".to_string(), - description: "Create a new empty file in the contract.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name for the new file" - }, - "description": { - "type": "string", - "description": "Optional description for the file" - } - }, - "required": ["name"] - }), - }, - // ============================================================================= - // Deliverable Management Tools - // ============================================================================= - Tool { - name: "mark_deliverable_complete".to_string(), - description: "Mark a phase deliverable as complete. Use this when you have verified that a deliverable requirement has been satisfied. Use get_phase_info or check_deliverables_met first to see available deliverable IDs.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "deliverable_id": { - "type": "string", - "description": "The ID of the deliverable to mark as complete (e.g., 'plan-document', 'pull-request', 'research-notes')" - }, - "phase": { - "type": "string", - "enum": ["research", "specify", "plan", "execute", "review"], - "description": "Phase the deliverable belongs to. Defaults to the current contract phase if not specified." - } - }, - "required": ["deliverable_id"] - }), - }, - // ============================================================================= - // Task Management Tools - // ============================================================================= - Tool { - name: "create_contract_task".to_string(), - description: "Create a new task within this contract. The task will be associated with the contract and can optionally use a contract repository.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the task" - }, - "plan": { - "type": "string", - "description": "Detailed instructions/plan for what the task should accomplish" - }, - "repository_url": { - "type": "string", - "description": "Git repository URL or local path. If not specified, uses the contract's primary repository." - }, - "base_branch": { - "type": "string", - "description": "Optional base branch to start from (default: main)" - } - }, - "required": ["name", "plan"] - }), - }, - Tool { - name: "delegate_content_generation".to_string(), - description: "Create a task to generate substantial content instead of writing it directly. Use this for filling templates, writing documentation, generating user stories, or any substantial writing task. The task will be created and can be started separately.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "file_id": { - "type": "string", - "description": "ID of the file to update with generated content (optional - if not specified, creates a new task without file context)" - }, - "instruction": { - "type": "string", - "description": "Clear instructions for what content should be generated" - }, - "context": { - "type": "string", - "description": "Additional context to help generate appropriate content" - } - }, - "required": ["instruction"] - }), - }, - Tool { - name: "start_task".to_string(), - description: "Start a task that is in 'pending' status. The task will be sent to a connected daemon for execution. A daemon must be connected for this to work.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to start" - } - }, - "required": ["task_id"] - }), - }, - // ============================================================================= - // Phase Management Tools - // ============================================================================= - Tool { - name: "get_phase_info".to_string(), - description: "Get detailed information about the current phase and what it means for the contract workflow.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "suggest_phase_transition".to_string(), - description: "Analyze whether the contract is ready to advance to the NEXT phase. Returns: currentPhase, nextPhase (the phase to advance TO), readiness status, and what's missing. Use this BEFORE calling advance_phase to know exactly which phase to advance to.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "advance_phase".to_string(), - description: "Advance the contract to the NEXT phase in sequence. Phases progress: research -> specify -> plan -> execute -> review. You can ONLY advance forward one step. Always use suggest_phase_transition first to check readiness and find the correct next phase. If the contract has phase_guard enabled, this will first return a pending_confirmation status with phase deliverables for user review. Call again with confirmed=true to complete the transition, or with feedback to request changes.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "new_phase": { - "type": "string", - "enum": ["specify", "plan", "execute", "review"], - "description": "The next phase to transition to. Must be exactly one step ahead of current phase (e.g., research->specify, specify->plan, plan->execute, execute->review)" - }, - "confirmed": { - "type": "boolean", - "description": "Set to true to confirm the phase transition when phase_guard is enabled. If omitted or false, returns deliverables for review." - }, - "feedback": { - "type": "string", - "description": "User feedback when requesting changes instead of confirming the transition. The feedback will be passed back to the task to address." - } - }, - "required": ["new_phase"] - }), - }, - // ============================================================================= - // Repository Management Tools - // ============================================================================= - Tool { - name: "list_daemon_directories".to_string(), - description: "List suggested directories from connected daemons. Use this to find valid local paths when the user wants to add a local repository or configure a target path. Returns working directories and home directories from connected agents.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "add_repository".to_string(), - description: "Add a repository to the contract. Can be a remote URL, local path, or create a managed repository.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["remote", "local", "managed"], - "description": "Type of repository to add" - }, - "name": { - "type": "string", - "description": "Display name for the repository" - }, - "url": { - "type": "string", - "description": "Repository URL (for remote type) or local path (for local type). Not needed for managed." - }, - "is_primary": { - "type": "boolean", - "description": "Whether this should be the primary repository for the contract" - } - }, - "required": ["type", "name"] - }), - }, - Tool { - name: "set_primary_repository".to_string(), - description: "Set a repository as the primary repository for this contract. The primary repo is used by default for new tasks.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "repository_id": { - "type": "string", - "description": "ID of the repository to set as primary" - } - }, - "required": ["repository_id"] - }), - }, - // ============================================================================= - // Phase Guidance Tools - // ============================================================================= - Tool { - name: "get_phase_checklist".to_string(), - description: "Get a detailed checklist of phase deliverables showing what's been created vs what's expected. Includes completion percentage and suggestions for next steps.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "check_deliverables_met".to_string(), - description: "Check if all required deliverables are met for the current phase and whether the contract is ready to advance to the next phase. Returns detailed status including: deliverables_met (bool), ready_to_advance (bool), required_deliverables (list with status), missing items, and auto_progress_recommended (bool). Use this before calling advance_phase to ensure all requirements are satisfied. For simple contracts: Plan phase needs Plan document + Repository, Execute phase needs completed tasks + PR. For specification contracts: Each phase has specific required documents.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - // ============================================================================= - // Task Derivation Tools - // ============================================================================= - Tool { - name: "derive_tasks_from_file".to_string(), - description: "Parse a file (typically Task Breakdown) to extract a list of tasks. Returns structured task data that can be used with create_chained_tasks.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "file_id": { - "type": "string", - "description": "ID of the file to parse tasks from (usually a Task Breakdown document)" - } - }, - "required": ["file_id"] - }), - }, - Tool { - name: "create_chained_tasks".to_string(), - description: "Create multiple tasks in sequence with automatic chaining. Each task will continue from the previous task's work using continue_from_task_id.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "tasks": { - "type": "array", - "description": "List of tasks to create in order", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Task name" - }, - "plan": { - "type": "string", - "description": "Task plan/instructions" - } - }, - "required": ["name", "plan"] - } - } - }, - "required": ["tasks"] - }), - }, - // ============================================================================= - // Task Completion Processing Tools - // ============================================================================= - Tool { - name: "process_task_completion".to_string(), - description: "Analyze a completed task's output and suggest next actions. Returns summary, affected files, and suggestions for follow-up tasks or file updates.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the completed task to analyze" - } - }, - "required": ["task_id"] - }), - }, - Tool { - name: "update_file_from_task".to_string(), - description: "Update a contract file with information from a completed task. Useful for updating Dev Notes or Implementation Log with task summaries.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "file_id": { - "type": "string", - "description": "ID of the file to update" - }, - "task_id": { - "type": "string", - "description": "ID of the task whose output should be added" - }, - "section_title": { - "type": "string", - "description": "Optional title for the section being added (e.g., 'Task: Implement Authentication')" - } - }, - "required": ["file_id", "task_id"] - }), - }, - // ============================================================================= - // Interactive Tools - // ============================================================================= - Tool { - name: "ask_user".to_string(), - description: "Ask the user one or more questions. Use this when you need clarification, want to offer choices, or need user input before proceeding. Questions can be single-select (user picks one option) or multi-select (user can pick multiple options). The question text supports markdown formatting.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "questions": { - "type": "array", - "description": "List of questions to ask the user", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for this question" - }, - "question": { - "type": "string", - "description": "The question to ask the user. Supports markdown formatting (bold, code, lists, etc.)" - }, - "options": { - "type": "array", - "items": { "type": "string" }, - "description": "Multiple choice options for the user to select from" - }, - "allowMultiple": { - "type": "boolean", - "description": "If true, user can select multiple options (multi-select). If false or omitted, user selects exactly one option (single-select). Default: false" - }, - "allowCustom": { - "type": "boolean", - "description": "If true, user can provide a custom text answer instead of selecting from options. Default: true" - } - }, - "required": ["id", "question", "options"] - } - } - }, - "required": ["questions"] - }), - }, - // ============================================================================= - // Transcript Analysis Tools - // ============================================================================= - Tool { - name: "analyze_transcript".to_string(), - description: "Analyze a file's transcript to extract requirements, decisions, and action items. Returns structured analysis including speaker statistics.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "file_id": { - "type": "string", - "description": "ID of the file containing the transcript to analyze" - } - }, - "required": ["file_id"] - }), - }, - Tool { - name: "create_contract_from_transcript".to_string(), - description: "Create a new contract from an analyzed transcript. Will extract requirements, decisions, and action items and create appropriate files and tasks in the new contract.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "file_id": { - "type": "string", - "description": "ID of the file containing the transcript" - }, - "name": { - "type": "string", - "description": "Optional name for the contract (otherwise auto-generated from analysis)" - }, - "description": { - "type": "string", - "description": "Optional description for the contract (otherwise auto-generated)" - }, - "include_requirements": { - "type": "boolean", - "description": "Whether to create a requirements file (default: true)" - }, - "include_decisions": { - "type": "boolean", - "description": "Whether to create a decisions file (default: true)" - }, - "include_action_items": { - "type": "boolean", - "description": "Whether to create tasks from action items (default: true)" - } - }, - "required": ["file_id"] - }), - }, - ] -}); - -/// Request for contract tool operations that require async database access -#[derive(Debug, Clone)] -pub enum ContractToolRequest { - // Query operations - GetContractStatus, - ListContractFiles, - ListContractTasks, - ListContractRepositories, - ReadFile { file_id: Uuid }, - - // File management - CreateEmptyFile { - name: String, - description: Option<String>, - }, - - // Deliverable management - MarkDeliverableComplete { - deliverable_id: String, - phase: Option<String>, - }, - - // Task management - CreateContractTask { - name: String, - plan: String, - repository_url: Option<String>, - base_branch: Option<String>, - }, - DelegateContentGeneration { - file_id: Option<Uuid>, - instruction: String, - context: Option<String>, - }, - StartTask { task_id: Uuid }, - - // Phase management - GetPhaseInfo, - SuggestPhaseTransition, - AdvancePhase { - new_phase: String, - /// Whether the user has confirmed the phase transition (for phase_guard) - confirmed: bool, - /// User feedback when they request changes instead of confirming - feedback: Option<String>, - }, - - // Repository management - ListDaemonDirectories, - AddRepository { - repo_type: String, - name: String, - url: Option<String>, - is_primary: bool, - }, - SetPrimaryRepository { repository_id: Uuid }, - - // Phase guidance - GetPhaseChecklist, - CheckDeliverablesMet, - - // Task derivation - DeriveTasksFromFile { file_id: Uuid }, - CreateChainedTasks { tasks: Vec<ChainedTaskDef> }, - - // Task completion processing - ProcessTaskCompletion { task_id: Uuid }, - UpdateFileFromTask { - file_id: Uuid, - task_id: Uuid, - section_title: Option<String>, - }, - - // Transcript analysis - AnalyzeTranscript { file_id: Uuid }, - CreateContractFromTranscript { - file_id: Uuid, - name: Option<String>, - description: Option<String>, - include_requirements: bool, - include_decisions: bool, - include_action_items: bool, - }, - -} - -/// Task definition for chained task creation -#[derive(Debug, Clone, serde::Deserialize)] -pub struct ChainedTaskDef { - pub name: String, - pub plan: String, -} - -/// Result from executing a contract tool -#[derive(Debug)] -pub struct ContractToolExecutionResult { - pub success: bool, - pub message: String, - pub data: Option<serde_json::Value>, - /// Request for async operations (handled by contract_chat handler) - pub request: Option<ContractToolRequest>, - /// Questions to ask the user (pauses conversation) - pub pending_questions: Option<Vec<super::tools::UserQuestion>>, -} - -/// Parse and validate a contract tool call, returning a ContractToolRequest for async handling -pub fn parse_contract_tool_call(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - match call.name.as_str() { - // Query operations - "get_contract_status" => parse_get_contract_status(), - "list_contract_files" => parse_list_contract_files(), - "list_contract_tasks" => parse_list_contract_tasks(), - "list_contract_repositories" => parse_list_contract_repositories(), - "read_file" => parse_read_file(call), - - // File management - "create_empty_file" => parse_create_empty_file(call), - - // Deliverable management - "mark_deliverable_complete" => parse_mark_deliverable_complete(call), - - // Task management - "create_contract_task" => parse_create_contract_task(call), - "delegate_content_generation" => parse_delegate_content_generation(call), - "start_task" => parse_start_task(call), - - // Phase management - "get_phase_info" => parse_get_phase_info(), - "suggest_phase_transition" => parse_suggest_phase_transition(), - "advance_phase" => parse_advance_phase(call), - - // Repository management - "list_daemon_directories" => parse_list_daemon_directories(), - "add_repository" => parse_add_repository(call), - "set_primary_repository" => parse_set_primary_repository(call), - - // Phase guidance - "get_phase_checklist" => parse_get_phase_checklist(), - "check_deliverables_met" => parse_check_deliverables_met(), - - // Task derivation - "derive_tasks_from_file" => parse_derive_tasks_from_file(call), - "create_chained_tasks" => parse_create_chained_tasks(call), - - // Task completion processing - "process_task_completion" => parse_process_task_completion(call), - "update_file_from_task" => parse_update_file_from_task(call), - - // Interactive tools - "ask_user" => parse_ask_user(call), - - // Transcript analysis tools - "analyze_transcript" => parse_analyze_transcript(call), - "create_contract_from_transcript" => parse_create_contract_from_transcript(call), - - _ => ContractToolExecutionResult { - success: false, - message: format!("Unknown contract tool: {}", call.name), - data: None, - request: None, - pending_questions: None, - }, - } -} - -// ============================================================================= -// Query Tool Parsing -// ============================================================================= - -fn parse_get_contract_status() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Getting contract status...".to_string(), - data: None, - request: Some(ContractToolRequest::GetContractStatus), - pending_questions: None, - } -} - -fn parse_list_contract_files() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Listing contract files...".to_string(), - data: None, - request: Some(ContractToolRequest::ListContractFiles), - pending_questions: None, - } -} - -fn parse_list_contract_tasks() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Listing contract tasks...".to_string(), - data: None, - request: Some(ContractToolRequest::ListContractTasks), - pending_questions: None, - } -} - -fn parse_list_contract_repositories() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Listing contract repositories...".to_string(), - data: None, - request: Some(ContractToolRequest::ListContractRepositories), - pending_questions: None, - } -} - -fn parse_read_file(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let file_id = parse_uuid_arg(call, "file_id"); - let Some(file_id) = file_id else { - return error_result("Missing or invalid required parameter: file_id"); - }; - - ContractToolExecutionResult { - success: true, - message: "Reading file...".to_string(), - data: None, - request: Some(ContractToolRequest::ReadFile { file_id }), - pending_questions: None, - } -} - -// ============================================================================= -// File Management Tool Parsing -// ============================================================================= - -fn parse_create_empty_file(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let name = call.arguments.get("name").and_then(|v| v.as_str()); - - let Some(name) = name else { - return error_result("Missing required parameter: name"); - }; - - let description = call - .arguments - .get("description") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - ContractToolExecutionResult { - success: true, - message: format!("Creating empty file '{}'...", name), - data: None, - request: Some(ContractToolRequest::CreateEmptyFile { - name: name.to_string(), - description, - }), - pending_questions: None, - } -} - -// ============================================================================= -// Deliverable Management Tool Parsing -// ============================================================================= - -fn parse_mark_deliverable_complete(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let deliverable_id = call - .arguments - .get("deliverable_id") - .and_then(|v| v.as_str()); - - let Some(deliverable_id) = deliverable_id else { - return error_result("Missing required parameter: deliverable_id"); - }; - - let phase = call - .arguments - .get("phase") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - ContractToolExecutionResult { - success: true, - message: format!("Marking deliverable '{}' as complete...", deliverable_id), - data: None, - request: Some(ContractToolRequest::MarkDeliverableComplete { - deliverable_id: deliverable_id.to_string(), - phase, - }), - pending_questions: None, - } -} - -// ============================================================================= -// Task Management Tool Parsing -// ============================================================================= - -fn parse_create_contract_task(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let name = call.arguments.get("name").and_then(|v| v.as_str()); - let plan = call.arguments.get("plan").and_then(|v| v.as_str()); - - let Some(name) = name else { - return error_result("Missing required parameter: name"); - }; - let Some(plan) = plan else { - return error_result("Missing required parameter: plan"); - }; - - let repository_url = call - .arguments - .get("repository_url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let base_branch = call - .arguments - .get("base_branch") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - ContractToolExecutionResult { - success: true, - message: format!("Creating task '{}'...", name), - data: None, - request: Some(ContractToolRequest::CreateContractTask { - name: name.to_string(), - plan: plan.to_string(), - repository_url, - base_branch, - }), - pending_questions: None, - } -} - -fn parse_delegate_content_generation(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let instruction = call.arguments.get("instruction").and_then(|v| v.as_str()); - - let Some(instruction) = instruction else { - return error_result("Missing required parameter: instruction"); - }; - - let file_id = parse_uuid_arg(call, "file_id"); - let context = call - .arguments - .get("context") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - ContractToolExecutionResult { - success: true, - message: "Creating content generation task...".to_string(), - data: None, - request: Some(ContractToolRequest::DelegateContentGeneration { - file_id, - instruction: instruction.to_string(), - context, - }), - pending_questions: None, - } -} - -fn parse_start_task(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - ContractToolExecutionResult { - success: true, - message: "Starting task...".to_string(), - data: None, - request: Some(ContractToolRequest::StartTask { task_id }), - pending_questions: None, - } -} - -// ============================================================================= -// Phase Management Tool Parsing -// ============================================================================= - -fn parse_get_phase_info() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Getting phase information...".to_string(), - data: None, - request: Some(ContractToolRequest::GetPhaseInfo), - pending_questions: None, - } -} - -fn parse_suggest_phase_transition() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Analyzing phase transition readiness...".to_string(), - data: None, - request: Some(ContractToolRequest::SuggestPhaseTransition), - pending_questions: None, - } -} - -fn parse_advance_phase(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let new_phase = call.arguments.get("new_phase").and_then(|v| v.as_str()); - - let Some(new_phase) = new_phase else { - return error_result("Missing required parameter: new_phase"); - }; - - let valid_phases = ["research", "specify", "plan", "execute", "review"]; - if !valid_phases.contains(&new_phase) { - return error_result("Invalid phase. Must be one of: research, specify, plan, execute, review"); - } - - // Parse optional confirmed flag (defaults to false for initial phase_guard check) - let confirmed = call - .arguments - .get("confirmed") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - // Parse optional feedback (for when user requests changes) - let feedback = call - .arguments - .get("feedback") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - ContractToolExecutionResult { - success: true, - message: format!("Advancing to '{}' phase...", new_phase), - data: None, - request: Some(ContractToolRequest::AdvancePhase { - new_phase: new_phase.to_string(), - confirmed, - feedback, - }), - pending_questions: None, - } -} - -// ============================================================================= -// Repository Management Tool Parsing -// ============================================================================= - -fn parse_list_daemon_directories() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Listing daemon directories...".to_string(), - data: None, - request: Some(ContractToolRequest::ListDaemonDirectories), - pending_questions: None, - } -} - -fn parse_add_repository(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let repo_type = call.arguments.get("type").and_then(|v| v.as_str()); - let name = call.arguments.get("name").and_then(|v| v.as_str()); - - let Some(repo_type) = repo_type else { - return error_result("Missing required parameter: type"); - }; - let Some(name) = name else { - return error_result("Missing required parameter: name"); - }; - - let valid_types = ["remote", "local", "managed"]; - if !valid_types.contains(&repo_type) { - return error_result("Invalid type. Must be one of: remote, local, managed"); - } - - let url = call - .arguments - .get("url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - // Validate URL is provided for remote and local types - if (repo_type == "remote" || repo_type == "local") && url.is_none() { - return error_result("URL/path is required for remote and local repository types"); - } - - let is_primary = call - .arguments - .get("is_primary") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - ContractToolExecutionResult { - success: true, - message: format!("Adding {} repository '{}'...", repo_type, name), - data: None, - request: Some(ContractToolRequest::AddRepository { - repo_type: repo_type.to_string(), - name: name.to_string(), - url, - is_primary, - }), - pending_questions: None, - } -} - -fn parse_set_primary_repository(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let repository_id = parse_uuid_arg(call, "repository_id"); - let Some(repository_id) = repository_id else { - return error_result("Missing or invalid required parameter: repository_id"); - }; - - ContractToolExecutionResult { - success: true, - message: "Setting primary repository...".to_string(), - data: None, - request: Some(ContractToolRequest::SetPrimaryRepository { repository_id }), - pending_questions: None, - } -} - -// ============================================================================= -// Interactive Tool Parsing -// ============================================================================= - -fn parse_ask_user(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let questions_value = call.arguments.get("questions"); - - let Some(questions_array) = questions_value.and_then(|v| v.as_array()) else { - return error_result("Missing or invalid 'questions' parameter"); - }; - - let mut questions: Vec<super::tools::UserQuestion> = Vec::new(); - - for q in questions_array { - let id = q.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let question = q.get("question").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let options: Vec<String> = q - .get("options") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|o| o.as_str()) - .map(|s| s.to_string()) - .collect() - }) - .unwrap_or_default(); - let allow_multiple = q.get("allowMultiple").and_then(|v| v.as_bool()).unwrap_or(false); - let allow_custom = q.get("allowCustom").and_then(|v| v.as_bool()).unwrap_or(true); - - if id.is_empty() || question.is_empty() || options.is_empty() { - continue; - } - - questions.push(super::tools::UserQuestion { - id, - question, - options, - allow_multiple, - allow_custom, - }); - } - - if questions.is_empty() { - return error_result("No valid questions provided"); - } - - let question_count = questions.len(); - ContractToolExecutionResult { - success: true, - message: format!("Asking user {} question(s). Waiting for response...", question_count), - data: None, - request: None, - pending_questions: Some(questions), - } -} - -// ============================================================================= -// Phase Guidance Tool Parsing -// ============================================================================= - -fn parse_get_phase_checklist() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Getting phase checklist...".to_string(), - data: None, - request: Some(ContractToolRequest::GetPhaseChecklist), - pending_questions: None, - } -} - -fn parse_check_deliverables_met() -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: true, - message: "Checking if deliverables are met...".to_string(), - data: None, - request: Some(ContractToolRequest::CheckDeliverablesMet), - pending_questions: None, - } -} - -// ============================================================================= -// Task Derivation Tool Parsing -// ============================================================================= - -fn parse_derive_tasks_from_file(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let file_id = parse_uuid_arg(call, "file_id"); - let Some(file_id) = file_id else { - return error_result("Missing or invalid required parameter: file_id"); - }; - - ContractToolExecutionResult { - success: true, - message: "Deriving tasks from file...".to_string(), - data: None, - request: Some(ContractToolRequest::DeriveTasksFromFile { file_id }), - pending_questions: None, - } -} - -fn parse_create_chained_tasks(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let tasks_value = call.arguments.get("tasks"); - - let Some(tasks_array) = tasks_value.and_then(|v| v.as_array()) else { - return error_result("Missing or invalid 'tasks' parameter"); - }; - - let mut tasks: Vec<ChainedTaskDef> = Vec::new(); - - for task in tasks_array { - let name = task.get("name").and_then(|v| v.as_str()); - let plan = task.get("plan").and_then(|v| v.as_str()); - - match (name, plan) { - (Some(n), Some(p)) => { - tasks.push(ChainedTaskDef { - name: n.to_string(), - plan: p.to_string(), - }); - } - _ => { - return error_result("Each task must have 'name' and 'plan' fields"); - } - } - } - - if tasks.is_empty() { - return error_result("No valid tasks provided"); - } - - let task_count = tasks.len(); - ContractToolExecutionResult { - success: true, - message: format!("Creating {} chained task(s)...", task_count), - data: None, - request: Some(ContractToolRequest::CreateChainedTasks { tasks }), - pending_questions: None, - } -} - -// ============================================================================= -// Task Completion Processing Tool Parsing -// ============================================================================= - -fn parse_process_task_completion(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - ContractToolExecutionResult { - success: true, - message: "Processing task completion...".to_string(), - data: None, - request: Some(ContractToolRequest::ProcessTaskCompletion { task_id }), - pending_questions: None, - } -} - -fn parse_update_file_from_task(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let file_id = parse_uuid_arg(call, "file_id"); - let task_id = parse_uuid_arg(call, "task_id"); - - let Some(file_id) = file_id else { - return error_result("Missing or invalid required parameter: file_id"); - }; - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - let section_title = call - .arguments - .get("section_title") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - ContractToolExecutionResult { - success: true, - message: "Updating file from task...".to_string(), - data: None, - request: Some(ContractToolRequest::UpdateFileFromTask { - file_id, - task_id, - section_title, - }), - pending_questions: None, - } -} - -// ============================================================================= -// Transcript Analysis Tool Parsing -// ============================================================================= - -fn parse_analyze_transcript(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let file_id = parse_uuid_arg(call, "file_id"); - let Some(file_id) = file_id else { - return error_result("Missing or invalid required parameter: file_id"); - }; - - ContractToolExecutionResult { - success: true, - message: "Analyzing transcript...".to_string(), - data: None, - request: Some(ContractToolRequest::AnalyzeTranscript { file_id }), - pending_questions: None, - } -} - -fn parse_create_contract_from_transcript(call: &super::tools::ToolCall) -> ContractToolExecutionResult { - let file_id = parse_uuid_arg(call, "file_id"); - let Some(file_id) = file_id else { - return error_result("Missing or invalid required parameter: file_id"); - }; - - let name = call.arguments.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()); - let description = call.arguments.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()); - let include_requirements = call.arguments.get("include_requirements").and_then(|v| v.as_bool()).unwrap_or(true); - let include_decisions = call.arguments.get("include_decisions").and_then(|v| v.as_bool()).unwrap_or(true); - let include_action_items = call.arguments.get("include_action_items").and_then(|v| v.as_bool()).unwrap_or(true); - - ContractToolExecutionResult { - success: true, - message: "Creating contract from transcript...".to_string(), - data: None, - request: Some(ContractToolRequest::CreateContractFromTranscript { - file_id, - name, - description, - include_requirements, - include_decisions, - include_action_items, - }), - pending_questions: None, - } -} - -// ============================================================================= -// Helper Functions -// ============================================================================= - -fn parse_uuid_arg(call: &super::tools::ToolCall, key: &str) -> Option<Uuid> { - call.arguments - .get(key) - .and_then(|v| v.as_str()) - .and_then(|s| Uuid::parse_str(s).ok()) -} - -fn error_result(message: &str) -> ContractToolExecutionResult { - ContractToolExecutionResult { - success: false, - message: message.to_string(), - data: None, - request: None, - pending_questions: None, - } -} diff --git a/makima/src/llm/discuss_tools.rs b/makima/src/llm/discuss_tools.rs deleted file mode 100644 index 7330db3..0000000 --- a/makima/src/llm/discuss_tools.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Tool definitions for contract discussion via LLM. -//! -//! These tools allow Makima to help users define and create contracts -//! through natural conversation. - -use serde_json::json; - -use super::tools::Tool; - -/// Available tools for contract discussion -pub static DISCUSS_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::Lazy::new(|| { - vec![ - Tool { - name: "create_contract".to_string(), - description: "Create a new contract based on the discussion. Only call this when the user has confirmed they're ready to create the contract.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name for the contract" - }, - "description": { - "type": "string", - "description": "Detailed description of what the contract is for" - }, - "contract_type": { - "type": "string", - "enum": ["simple", "specification", "execute"], - "description": "Type of contract workflow" - }, - "repository_url": { - "type": "string", - "description": "Optional repository URL if discussed" - }, - "local_only": { - "type": "boolean", - "description": "If true, tasks won't auto-push or create PRs" - } - }, - "required": ["name", "description", "contract_type"] - }), - }, - Tool { - name: "ask_clarification".to_string(), - description: "Ask the user a clarifying question with multiple choice options.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "question": { - "type": "string", - "description": "The question to ask" - }, - "options": { - "type": "array", - "items": { "type": "string" }, - "description": "Multiple choice options" - }, - "allow_custom": { - "type": "boolean", - "description": "Allow user to provide a custom answer" - } - }, - "required": ["question", "options"] - }), - }, - ] -}); - -/// Request for discussion tool operations that require async database access -#[derive(Debug, Clone)] -pub enum DiscussToolRequest { - /// Create a new contract - CreateContract { - name: String, - description: String, - contract_type: String, - repository_url: Option<String>, - local_only: bool, - }, -} - -/// Result from executing a discussion tool -#[derive(Debug)] -pub struct DiscussToolExecutionResult { - pub success: bool, - pub message: String, - pub data: Option<serde_json::Value>, - /// Request for async operations (handled by discuss handler) - pub request: Option<DiscussToolRequest>, - /// Questions to ask the user (pauses conversation) - pub pending_questions: Option<Vec<super::tools::UserQuestion>>, -} - -/// Parse and validate a discussion tool call, returning a DiscussToolRequest for async handling -pub fn parse_discuss_tool_call(call: &super::tools::ToolCall) -> DiscussToolExecutionResult { - match call.name.as_str() { - "create_contract" => parse_create_contract(call), - "ask_clarification" => parse_ask_clarification(call), - _ => DiscussToolExecutionResult { - success: false, - message: format!("Unknown discussion tool: {}", call.name), - data: None, - request: None, - pending_questions: None, - }, - } -} - -fn parse_create_contract(call: &super::tools::ToolCall) -> DiscussToolExecutionResult { - let name = call.arguments.get("name").and_then(|v| v.as_str()); - let description = call.arguments.get("description").and_then(|v| v.as_str()); - let contract_type = call.arguments.get("contract_type").and_then(|v| v.as_str()); - - let Some(name) = name else { - return error_result("Missing required parameter: name"); - }; - let Some(description) = description else { - return error_result("Missing required parameter: description"); - }; - let Some(contract_type) = contract_type else { - return error_result("Missing required parameter: contract_type"); - }; - - let valid_types = ["simple", "specification", "execute"]; - if !valid_types.contains(&contract_type) { - return error_result("Invalid contract_type. Must be one of: simple, specification, execute"); - } - - let repository_url = call - .arguments - .get("repository_url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let local_only = call - .arguments - .get("local_only") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - DiscussToolExecutionResult { - success: true, - message: format!("Creating contract '{}'...", name), - data: None, - request: Some(DiscussToolRequest::CreateContract { - name: name.to_string(), - description: description.to_string(), - contract_type: contract_type.to_string(), - repository_url, - local_only, - }), - pending_questions: None, - } -} - -fn parse_ask_clarification(call: &super::tools::ToolCall) -> DiscussToolExecutionResult { - let question = call.arguments.get("question").and_then(|v| v.as_str()); - let options = call.arguments.get("options").and_then(|v| v.as_array()); - - let Some(question) = question else { - return error_result("Missing required parameter: question"); - }; - let Some(options) = options else { - return error_result("Missing required parameter: options"); - }; - - let options: Vec<String> = options - .iter() - .filter_map(|o| o.as_str()) - .map(|s| s.to_string()) - .collect(); - - if options.is_empty() { - return error_result("Options array cannot be empty"); - } - - let allow_custom = call - .arguments - .get("allow_custom") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - - // Create a UserQuestion for the ask_clarification tool - let user_question = super::tools::UserQuestion { - id: "clarification".to_string(), - question: question.to_string(), - options, - allow_multiple: false, - allow_custom, - }; - - DiscussToolExecutionResult { - success: true, - message: format!("Asking clarification: {}", question), - data: None, - request: None, - pending_questions: Some(vec![user_question]), - } -} - -fn error_result(message: &str) -> DiscussToolExecutionResult { - DiscussToolExecutionResult { - success: false, - message: message.to_string(), - data: None, - request: None, - pending_questions: None, - } -} diff --git a/makima/src/llm/groq.rs b/makima/src/llm/groq.rs deleted file mode 100644 index ee01fcf..0000000 --- a/makima/src/llm/groq.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! Groq API client for LLM tool calling. - -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use super::tools::{Tool, ToolCall}; - -const GROQ_API_URL: &str = "https://api.groq.com/openai/v1/chat/completions"; -const MODEL: &str = "moonshotai/kimi-k2-instruct-0905"; - -#[derive(Debug, Error)] -pub enum GroqError { - #[error("HTTP request failed: {0}")] - Request(#[from] reqwest::Error), - #[error("API error: {0}")] - Api(String), - #[error("Missing API key")] - MissingApiKey, -} - -#[derive(Debug, Clone)] -pub struct GroqClient { - api_key: String, - client: reqwest::Client, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Message { - pub role: String, - pub content: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_calls: Option<Vec<ToolCallResponse>>, - #[serde(skip_serializing_if = "Option::is_none")] - pub tool_call_id: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCallResponse { - pub id: String, - #[serde(rename = "type")] - pub call_type: String, - pub function: FunctionCall, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FunctionCall { - pub name: String, - pub arguments: String, -} - -#[derive(Debug, Serialize)] -struct ChatRequest { - model: String, - messages: Vec<Message>, - tools: Vec<ToolDefinition>, - tool_choice: String, -} - -#[derive(Debug, Serialize)] -struct ToolDefinition { - #[serde(rename = "type")] - tool_type: String, - function: FunctionDefinition, -} - -#[derive(Debug, Serialize)] -struct FunctionDefinition { - name: String, - description: String, - parameters: serde_json::Value, -} - -#[derive(Debug, Deserialize)] -struct ChatResponse { - choices: Vec<Choice>, -} - -#[derive(Debug, Deserialize)] -struct Choice { - message: MessageResponse, - finish_reason: String, -} - -#[derive(Debug, Deserialize)] -struct MessageResponse { - role: String, - content: Option<String>, - tool_calls: Option<Vec<ToolCallResponse>>, -} - -#[derive(Debug)] -pub struct ChatResult { - pub content: Option<String>, - pub tool_calls: Vec<ToolCall>, - /// Raw tool call responses for including in subsequent messages - pub raw_tool_calls: Vec<ToolCallResponse>, - pub finish_reason: String, -} - -impl GroqClient { - pub fn new(api_key: String) -> Self { - Self { - api_key, - client: reqwest::Client::new(), - } - } - - pub fn from_env() -> Result<Self, GroqError> { - let api_key = std::env::var("GROQ_API_KEY").map_err(|_| GroqError::MissingApiKey)?; - Ok(Self::new(api_key)) - } - - pub async fn chat_with_tools( - &self, - messages: Vec<Message>, - tools: &[Tool], - ) -> Result<ChatResult, GroqError> { - let tool_definitions: Vec<ToolDefinition> = tools - .iter() - .map(|t| ToolDefinition { - tool_type: "function".to_string(), - function: FunctionDefinition { - name: t.name.clone(), - description: t.description.clone(), - parameters: t.parameters.clone(), - }, - }) - .collect(); - - let request = ChatRequest { - model: MODEL.to_string(), - messages, - tools: tool_definitions, - tool_choice: "auto".to_string(), - }; - - let response = self - .client - .post(GROQ_API_URL) - .header("Authorization", format!("Bearer {}", self.api_key)) - .header("Content-Type", "application/json") - .json(&request) - .send() - .await?; - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - return Err(GroqError::Api(error_text)); - } - - let chat_response: ChatResponse = response.json().await?; - - let choice = chat_response - .choices - .into_iter() - .next() - .ok_or_else(|| GroqError::Api("No choices in response".to_string()))?; - - let raw_tool_calls = choice.message.tool_calls.unwrap_or_default(); - - let tool_calls = raw_tool_calls - .iter() - .map(|tc| ToolCall { - id: tc.id.clone(), - name: tc.function.name.clone(), - arguments: serde_json::from_str(&tc.function.arguments).unwrap_or_default(), - }) - .collect(); - - Ok(ChatResult { - content: choice.message.content, - tool_calls, - raw_tool_calls, - finish_reason: choice.finish_reason, - }) - } -} diff --git a/makima/src/llm/markdown.rs b/makima/src/llm/markdown.rs deleted file mode 100644 index 482dc8c..0000000 --- a/makima/src/llm/markdown.rs +++ /dev/null @@ -1,334 +0,0 @@ -//! Markdown conversion utilities for BodyElement arrays. -//! -//! Provides bidirectional conversion between structured BodyElement[] and markdown strings. - -use crate::db::models::BodyElement; - -/// Convert a slice of BodyElements to a markdown string. -/// -/// Handles: -/// - Headings: `# heading` through `###### heading` based on level -/// - Paragraphs: plain text with blank lines between -/// - Code blocks: ````language\ncontent\n```` -/// - Lists: ordered (1. 2. 3.) and unordered (- - -) -/// - Charts: rendered as fenced JSON with chart type -/// - Images: rendered as markdown image syntax -pub fn body_to_markdown(elements: &[BodyElement]) -> String { - elements - .iter() - .filter_map(|elem| match elem { - BodyElement::Heading { level, text } => { - let hashes = "#".repeat((*level).min(6) as usize); - Some(format!("{} {}", hashes, text)) - } - BodyElement::Paragraph { text } => Some(text.clone()), - BodyElement::Code { language, content } => { - let lang = language.as_deref().unwrap_or(""); - Some(format!("```{}\n{}\n```", lang, content)) - } - BodyElement::List { ordered, items } => { - let list: Vec<String> = items - .iter() - .enumerate() - .map(|(i, item)| { - if *ordered { - format!("{}. {}", i + 1, item) - } else { - format!("- {}", item) - } - }) - .collect(); - Some(list.join("\n")) - } - BodyElement::Chart { - chart_type, - title, - data, - config: _, - } => { - // Render chart as a fenced block with metadata - let title_str = title - .as_ref() - .map(|t| format!(" - {}", t)) - .unwrap_or_default(); - let data_str = serde_json::to_string_pretty(data).unwrap_or_default(); - Some(format!( - "```chart:{:?}{}\n{}\n```", - chart_type, title_str, data_str - )) - } - BodyElement::Image { src, alt, caption } => { - let alt_text = alt.as_deref().unwrap_or("image"); - let caption_str = caption - .as_ref() - .map(|c| format!("\n*{}*", c)) - .unwrap_or_default(); - Some(format!("{}", alt_text, src, caption_str)) - } - // Markdown elements output their content directly - it's already markdown - BodyElement::Markdown { content } => Some(content.clone()), - }) - .collect::<Vec<_>>() - .join("\n\n") -} - -/// Parse a markdown string into a vector of BodyElements. -/// -/// Handles: -/// - Headings: lines starting with # through ###### -/// - Code blocks: ````language ... ```` -/// - Ordered lists: lines starting with 1. 2. etc. -/// - Unordered lists: lines starting with - or * -/// - Paragraphs: all other non-empty lines -pub fn markdown_to_body(markdown: &str) -> Vec<BodyElement> { - let mut elements = Vec::new(); - let lines: Vec<&str> = markdown.lines().collect(); - let mut i = 0; - - while i < lines.len() { - let line = lines[i]; - let trimmed = line.trim(); - - // Skip empty lines - if trimmed.is_empty() { - i += 1; - continue; - } - - // Check for code blocks - if trimmed.starts_with("```") { - let language = trimmed.trim_start_matches('`').trim(); - let language = if language.is_empty() { - None - } else { - Some(language.to_string()) - }; - - let mut content_lines = Vec::new(); - i += 1; - - // Collect content until closing ``` - while i < lines.len() && !lines[i].trim().starts_with("```") { - content_lines.push(lines[i]); - i += 1; - } - - // Skip the closing ``` - if i < lines.len() { - i += 1; - } - - elements.push(BodyElement::Code { - language, - content: content_lines.join("\n"), - }); - continue; - } - - // Check for headings - if trimmed.starts_with('#') { - let level = trimmed.chars().take_while(|&c| c == '#').count() as u8; - let text = trimmed.trim_start_matches('#').trim().to_string(); - elements.push(BodyElement::Heading { level, text }); - i += 1; - continue; - } - - // Check for unordered lists (- or *) - if trimmed.starts_with("- ") || trimmed.starts_with("* ") { - let mut items = Vec::new(); - while i < lines.len() { - let current = lines[i].trim(); - if current.starts_with("- ") || current.starts_with("* ") { - items.push(current[2..].to_string()); - i += 1; - } else if current.is_empty() { - i += 1; - break; - } else { - break; - } - } - elements.push(BodyElement::List { - ordered: false, - items, - }); - continue; - } - - // Check for ordered lists (1. 2. etc.) - if let Some(rest) = try_parse_ordered_list_item(trimmed) { - let mut items = Vec::new(); - items.push(rest.to_string()); - i += 1; - - while i < lines.len() { - let current = lines[i].trim(); - if let Some(item_rest) = try_parse_ordered_list_item(current) { - items.push(item_rest.to_string()); - i += 1; - } else if current.is_empty() { - i += 1; - break; - } else { - break; - } - } - elements.push(BodyElement::List { - ordered: true, - items, - }); - continue; - } - - // Default: paragraph (collect consecutive non-empty lines) - let mut para_lines = Vec::new(); - while i < lines.len() { - let current = lines[i].trim(); - if current.is_empty() - || current.starts_with('#') - || current.starts_with("```") - || current.starts_with("- ") - || current.starts_with("* ") - || try_parse_ordered_list_item(current).is_some() - { - break; - } - para_lines.push(current); - i += 1; - } - - if !para_lines.is_empty() { - elements.push(BodyElement::Paragraph { - text: para_lines.join(" "), - }); - } - } - - elements -} - -/// Try to parse an ordered list item (e.g., "1. Item text") -/// Returns the text after the number and period, or None if not a list item. -fn try_parse_ordered_list_item(s: &str) -> Option<&str> { - let mut chars = s.char_indices(); - - // Must start with a digit - let (_, first) = chars.next()?; - if !first.is_ascii_digit() { - return None; - } - - // Consume remaining digits - let mut last_digit_end = 1; - for (idx, c) in chars.by_ref() { - if c.is_ascii_digit() { - last_digit_end = idx + 1; - } else if c == '.' { - // Found the period - check for space after - let rest = &s[last_digit_end + 1..]; - let rest = rest.trim_start(); - if !rest.is_empty() || s.ends_with(". ") { - return Some(rest); - } - return None; - } else { - return None; - } - } - - None -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_body_to_markdown_heading() { - let elements = vec![BodyElement::Heading { - level: 2, - text: "Hello World".to_string(), - }]; - assert_eq!(body_to_markdown(&elements), "## Hello World"); - } - - #[test] - fn test_body_to_markdown_paragraph() { - let elements = vec![BodyElement::Paragraph { - text: "This is a paragraph.".to_string(), - }]; - assert_eq!(body_to_markdown(&elements), "This is a paragraph."); - } - - #[test] - fn test_body_to_markdown_code() { - let elements = vec![BodyElement::Code { - language: Some("rust".to_string()), - content: "fn main() {}".to_string(), - }]; - assert_eq!( - body_to_markdown(&elements), - "```rust\nfn main() {}\n```" - ); - } - - #[test] - fn test_body_to_markdown_list() { - let elements = vec![BodyElement::List { - ordered: false, - items: vec!["Item 1".to_string(), "Item 2".to_string()], - }]; - assert_eq!(body_to_markdown(&elements), "- Item 1\n- Item 2"); - } - - #[test] - fn test_markdown_to_body_heading() { - let md = "## Hello World"; - let elements = markdown_to_body(md); - assert_eq!(elements.len(), 1); - match &elements[0] { - BodyElement::Heading { level, text } => { - assert_eq!(*level, 2); - assert_eq!(text, "Hello World"); - } - _ => panic!("Expected Heading"), - } - } - - #[test] - fn test_markdown_to_body_code() { - let md = "```rust\nfn main() {}\n```"; - let elements = markdown_to_body(md); - assert_eq!(elements.len(), 1); - match &elements[0] { - BodyElement::Code { language, content } => { - assert_eq!(language.as_deref(), Some("rust")); - assert_eq!(content, "fn main() {}"); - } - _ => panic!("Expected Code"), - } - } - - #[test] - fn test_roundtrip() { - let original = vec![ - BodyElement::Heading { - level: 1, - text: "Title".to_string(), - }, - BodyElement::Paragraph { - text: "Some text here.".to_string(), - }, - BodyElement::List { - ordered: false, - items: vec!["A".to_string(), "B".to_string()], - }, - ]; - - let markdown = body_to_markdown(&original); - let parsed = markdown_to_body(&markdown); - - assert_eq!(parsed.len(), 3); - } -} diff --git a/makima/src/llm/mesh_tools.rs b/makima/src/llm/mesh_tools.rs deleted file mode 100644 index 8bddf71..0000000 --- a/makima/src/llm/mesh_tools.rs +++ /dev/null @@ -1,1411 +0,0 @@ -//! Tool definitions for task mesh orchestration via LLM. -//! -//! These tools allow the LLM to create, manage, and coordinate tasks across -//! connected daemons running Claude Code containers. - -use serde_json::json; -use uuid::Uuid; - -use super::tools::Tool; - -/// Available tools for mesh/task orchestration -pub static MESH_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::Lazy::new(|| { - vec![ - // ============================================================================= - // Task Lifecycle Tools - // ============================================================================= - Tool { - name: "create_task".to_string(), - description: "Create a new task (or subtask if parent_task_id provided). The task will be in 'pending' status until run_task is called.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the task" - }, - "plan": { - "type": "string", - "description": "Detailed instructions/plan for what the task should accomplish" - }, - "parent_task_id": { - "type": "string", - "description": "Optional parent task ID to create this as a subtask" - }, - "repository_url": { - "type": "string", - "description": "Git repository URL or local path for the task (required)" - }, - "base_branch": { - "type": "string", - "description": "Optional base branch to start from (default: main)" - }, - "merge_mode": { - "type": "string", - "enum": ["pr", "auto", "manual"], - "description": "How to handle completion: 'pr' creates PR, 'auto' auto-merges, 'manual' leaves changes for review" - }, - "priority": { - "type": "integer", - "description": "Task priority (higher = more important, default: 0)" - } - }, - "required": ["name", "plan", "repository_url"] - }), - }, - Tool { - name: "run_task".to_string(), - description: "Start executing a pending task on an available daemon. The task must be in 'pending' or 'paused' status.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to run" - }, - "daemon_id": { - "type": "string", - "description": "Optional specific daemon ID to run on. If not specified, an available daemon will be selected." - } - }, - "required": ["task_id"] - }), - }, - Tool { - name: "pause_task".to_string(), - description: "Pause a running task. The container state will be preserved.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to pause" - } - }, - "required": ["task_id"] - }), - }, - Tool { - name: "resume_task".to_string(), - description: "Resume a paused task.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to resume" - } - }, - "required": ["task_id"] - }), - }, - Tool { - name: "interrupt_task".to_string(), - description: "Interrupt a running task. Use graceful=true to allow current operation to complete.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to interrupt" - }, - "graceful": { - "type": "boolean", - "description": "If true, wait for current operation to complete before stopping" - } - }, - "required": ["task_id"] - }), - }, - Tool { - name: "discard_task".to_string(), - description: "Discard a task and delete its overlay. All changes will be lost. Use with caution.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to discard" - }, - "confirm": { - "type": "boolean", - "description": "Must be true to confirm deletion" - } - }, - "required": ["task_id", "confirm"] - }), - }, - // ============================================================================= - // Task Query Tools - // ============================================================================= - Tool { - name: "query_task_status".to_string(), - description: "Get detailed status and information about a task.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to query" - } - }, - "required": ["task_id"] - }), - }, - Tool { - name: "list_tasks".to_string(), - description: "List all tasks, optionally filtered by status or parent.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "status_filter": { - "type": "string", - "enum": ["pending", "running", "paused", "blocked", "done", "failed", "merged"], - "description": "Optional filter by task status" - }, - "parent_task_id": { - "type": "string", - "description": "Optional filter to list only subtasks of this parent" - } - } - }), - }, - Tool { - name: "list_subtasks".to_string(), - description: "List all subtasks of a specific task.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the parent task" - } - }, - "required": ["task_id"] - }), - }, - Tool { - name: "list_siblings".to_string(), - description: "List sibling tasks (tasks with the same parent) of a specific task.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to find siblings for" - } - }, - "required": ["task_id"] - }), - }, - Tool { - name: "list_daemons".to_string(), - description: "List all connected daemons and their current status.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "list_daemon_directories".to_string(), - description: "List all available directories from connected daemons. Use this to find existing repositories and suggested working directories when creating tasks. Returns directories like the daemon's working directory and home directory where repos can be cloned.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - // ============================================================================= - // File Access Tools - // ============================================================================= - Tool { - name: "list_files".to_string(), - description: "List all files available in the system. Returns file IDs, names, and descriptions.".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "read_file".to_string(), - description: "Read the contents of a file from the files system. Returns the file's name, description, summary, body content (headings and paragraphs), and transcript entries with speaker and timing information.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "file_id": { - "type": "string", - "description": "ID of the file to read" - } - }, - "required": ["file_id"] - }), - }, - // ============================================================================= - // Task Communication Tools - // ============================================================================= - Tool { - name: "send_message_to_task".to_string(), - description: "Send a message to a running task's Claude Code instance. Use this to provide additional context, answer questions, or give new instructions.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the running task" - }, - "message": { - "type": "string", - "description": "Message to send to the task" - } - }, - "required": ["task_id", "message"] - }), - }, - Tool { - name: "update_task_plan".to_string(), - description: "Update the plan/instructions for a task. Can optionally interrupt a running task to apply new plan.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to update" - }, - "new_plan": { - "type": "string", - "description": "New plan/instructions for the task" - }, - "interrupt_if_running": { - "type": "boolean", - "description": "If true and task is running, interrupt it to apply new plan" - } - }, - "required": ["task_id", "new_plan"] - }), - }, - // ============================================================================= - // Overlay/Merge Tools - // ============================================================================= - Tool { - name: "peek_sibling_overlay".to_string(), - description: "View the changes made by a sibling task's overlay. Useful for understanding what other tasks have done before merging.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "sibling_task_id": { - "type": "string", - "description": "ID of the sibling task to peek at" - } - }, - "required": ["sibling_task_id"] - }), - }, - Tool { - name: "get_overlay_diff".to_string(), - description: "Get a git diff of all changes in a task's overlay.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task" - } - }, - "required": ["task_id"] - }), - }, - Tool { - name: "preview_merge".to_string(), - description: "Preview what a merge would look like without actually merging. Shows potential conflicts.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to preview merge for" - } - }, - "required": ["task_id"] - }), - }, - Tool { - name: "merge_subtask".to_string(), - description: "Merge a completed subtask's changes to its parent branch.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the subtask to merge" - } - }, - "required": ["task_id"] - }), - }, - Tool { - name: "complete_task".to_string(), - description: "Mark a task as complete and trigger the merge flow based on merge_mode. For 'pr' mode, creates a pull request. For 'auto' mode, merges directly.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to complete" - } - }, - "required": ["task_id"] - }), - }, - Tool { - name: "set_merge_mode".to_string(), - description: "Change the merge mode for a task.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task" - }, - "mode": { - "type": "string", - "enum": ["pr", "auto", "manual"], - "description": "New merge mode: 'pr' (create PR), 'auto' (auto-merge), 'manual' (leave for manual review)" - } - }, - "required": ["task_id", "mode"] - }), - }, - // ============================================================================= - // Interactive Tools - // ============================================================================= - Tool { - name: "ask_user".to_string(), - description: "Ask the user one or more questions. Use this when you need clarification, want to offer choices, or need user input before proceeding. Questions can be single-select (user picks one option) or multi-select (user can pick multiple options). The question text supports markdown formatting.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "questions": { - "type": "array", - "description": "List of questions to ask the user", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for this question" - }, - "question": { - "type": "string", - "description": "The question to ask the user. Supports markdown formatting (bold, code, lists, etc.)" - }, - "options": { - "type": "array", - "items": { "type": "string" }, - "description": "Multiple choice options for the user to select from" - }, - "allowMultiple": { - "type": "boolean", - "description": "If true, user can select multiple options (multi-select). If false or omitted, user selects exactly one option (single-select). Default: false" - }, - "allowCustom": { - "type": "boolean", - "description": "If true, user can provide a custom text answer instead of selecting from options. Default: true" - } - }, - "required": ["id", "question", "options"] - } - } - }, - "required": ["questions"] - }), - }, - // ============================================================================= - // Supervisor Tools (only available to supervisor tasks) - // ============================================================================= - Tool { - name: "get_all_contract_tasks".to_string(), - description: "Get status of all tasks in the contract tree. Only available to supervisor tasks.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "contract_id": { - "type": "string", - "description": "ID of the contract to query tasks for" - } - }, - "required": ["contract_id"] - }), - }, - Tool { - name: "wait_for_task_completion".to_string(), - description: "Block until a task completes or timeout. Only available to supervisor tasks.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to wait for" - }, - "timeout_seconds": { - "type": "integer", - "description": "Maximum time to wait in seconds (default: 300)", - "default": 300 - } - }, - "required": ["task_id"] - }), - }, - Tool { - name: "read_task_worktree".to_string(), - description: "Read a file from any task's worktree. Only available to supervisor tasks.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task whose worktree to read from" - }, - "file_path": { - "type": "string", - "description": "Path to the file within the worktree" - } - }, - "required": ["task_id", "file_path"] - }), - }, - Tool { - name: "spawn_task".to_string(), - description: "Create and start a child task (fire and forget). Only available to supervisor tasks.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the task" - }, - "plan": { - "type": "string", - "description": "Detailed instructions/plan for what the task should accomplish" - }, - "parent_task_id": { - "type": "string", - "description": "Optional parent task to branch from" - }, - "checkpoint_sha": { - "type": "string", - "description": "Optional checkpoint SHA to branch from" - }, - "repository_url": { - "type": "string", - "description": "Git repository URL (optional - inherits from contract if not provided)" - }, - "base_branch": { - "type": "string", - "description": "Optional base branch to start from" - } - }, - "required": ["name", "plan"] - }), - }, - Tool { - name: "create_checkpoint".to_string(), - description: "Create a git checkpoint (commit) in the current task's worktree. Only available to supervisor tasks.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to checkpoint" - }, - "message": { - "type": "string", - "description": "Commit message for the checkpoint" - } - }, - "required": ["task_id", "message"] - }), - }, - Tool { - name: "list_task_checkpoints".to_string(), - description: "List all checkpoints for a task.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the task to list checkpoints for" - } - }, - "required": ["task_id"] - }), - }, - Tool { - name: "get_task_tree".to_string(), - description: "Get the full task tree starting from a specific task.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "ID of the root task" - } - }, - "required": ["task_id"] - }), - }, - ] -}); - -/// Request for mesh tool operations that require async database/daemon access -#[derive(Debug, Clone)] -pub enum MeshToolRequest { - // Task lifecycle - CreateTask { - name: String, - plan: String, - parent_task_id: Option<Uuid>, - repository_url: Option<String>, - base_branch: Option<String>, - merge_mode: Option<String>, - priority: Option<i32>, - }, - RunTask { - task_id: Uuid, - daemon_id: Option<Uuid>, - }, - PauseTask { - task_id: Uuid, - }, - ResumeTask { - task_id: Uuid, - }, - InterruptTask { - task_id: Uuid, - graceful: bool, - }, - DiscardTask { - task_id: Uuid, - }, - - // Task queries - QueryTaskStatus { - task_id: Uuid, - }, - ListTasks { - status_filter: Option<String>, - parent_task_id: Option<Uuid>, - }, - ListSubtasks { - task_id: Uuid, - }, - ListSiblings { - task_id: Uuid, - }, - ListDaemons, - ListDaemonDirectories, - - // File access - ListFiles, - ReadFile { - file_id: Uuid, - }, - - // Task communication - SendMessageToTask { - task_id: Uuid, - message: String, - }, - UpdateTaskPlan { - task_id: Uuid, - new_plan: String, - interrupt_if_running: bool, - }, - - // Overlay/merge operations - PeekSiblingOverlay { - sibling_task_id: Uuid, - }, - GetOverlayDiff { - task_id: Uuid, - }, - PreviewMerge { - task_id: Uuid, - }, - MergeSubtask { - task_id: Uuid, - }, - CompleteTask { - task_id: Uuid, - }, - SetMergeMode { - task_id: Uuid, - mode: String, - }, - - // Supervisor tools (only for supervisor tasks) - GetAllContractTasks { - contract_id: Uuid, - }, - WaitForTaskCompletion { - task_id: Uuid, - timeout_seconds: i32, - }, - ReadTaskWorktree { - task_id: Uuid, - file_path: String, - }, - SpawnTask { - name: String, - plan: String, - parent_task_id: Option<Uuid>, - checkpoint_sha: Option<String>, - repository_url: Option<String>, - base_branch: Option<String>, - }, - CreateCheckpoint { - task_id: Uuid, - message: String, - }, - ListTaskCheckpoints { - task_id: Uuid, - }, - GetTaskTree { - task_id: Uuid, - }, -} - -/// Result from executing a mesh tool -#[derive(Debug)] -pub struct MeshToolExecutionResult { - pub success: bool, - pub message: String, - pub data: Option<serde_json::Value>, - /// Request for async operations (handled by mesh_chat handler) - pub request: Option<MeshToolRequest>, - /// Questions to ask the user (pauses conversation) - pub pending_questions: Option<Vec<super::tools::UserQuestion>>, -} - -/// Parse and validate a mesh tool call, returning a MeshToolRequest for async handling -pub fn parse_mesh_tool_call( - call: &super::tools::ToolCall, -) -> MeshToolExecutionResult { - match call.name.as_str() { - // Task lifecycle - "create_task" => parse_create_task(call), - "run_task" => parse_run_task(call), - "pause_task" => parse_pause_task(call), - "resume_task" => parse_resume_task(call), - "interrupt_task" => parse_interrupt_task(call), - "discard_task" => parse_discard_task(call), - - // Task queries - "query_task_status" => parse_query_task_status(call), - "list_tasks" => parse_list_tasks(call), - "list_subtasks" => parse_list_subtasks(call), - "list_siblings" => parse_list_siblings(call), - "list_daemons" => parse_list_daemons(), - "list_daemon_directories" => parse_list_daemon_directories(), - - // File access - "list_files" => parse_list_files(), - "read_file" => parse_read_file(call), - - // Task communication - "send_message_to_task" => parse_send_message_to_task(call), - "update_task_plan" => parse_update_task_plan(call), - - // Overlay/merge operations - "peek_sibling_overlay" => parse_peek_sibling_overlay(call), - "get_overlay_diff" => parse_get_overlay_diff(call), - "preview_merge" => parse_preview_merge(call), - "merge_subtask" => parse_merge_subtask(call), - "complete_task" => parse_complete_task(call), - "set_merge_mode" => parse_set_merge_mode(call), - - // Interactive tools - "ask_user" => parse_ask_user(call), - - // Supervisor tools - "get_all_contract_tasks" => parse_get_all_contract_tasks(call), - "wait_for_task_completion" => parse_wait_for_task_completion(call), - "read_task_worktree" => parse_read_task_worktree(call), - "spawn_task" => parse_spawn_task(call), - "create_checkpoint" => parse_create_checkpoint(call), - "list_task_checkpoints" => parse_list_task_checkpoints(call), - "get_task_tree" => parse_get_task_tree(call), - - _ => MeshToolExecutionResult { - success: false, - message: format!("Unknown mesh tool: {}", call.name), - data: None, - request: None, - pending_questions: None, - }, - } -} - -// ============================================================================= -// Tool Parsing Functions -// ============================================================================= - -fn parse_create_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let name = call.arguments.get("name").and_then(|v| v.as_str()); - let plan = call.arguments.get("plan").and_then(|v| v.as_str()); - let repository_url = call - .arguments - .get("repository_url") - .and_then(|v| v.as_str()); - - let Some(name) = name else { - return error_result("Missing required parameter: name"); - }; - let Some(plan) = plan else { - return error_result("Missing required parameter: plan"); - }; - let Some(repository_url) = repository_url else { - return error_result("Missing required parameter: repository_url"); - }; - - let parent_task_id = call - .arguments - .get("parent_task_id") - .and_then(|v| v.as_str()) - .and_then(|s| Uuid::parse_str(s).ok()); - - let repository_url = Some(repository_url.to_string()); - - let base_branch = call - .arguments - .get("base_branch") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let merge_mode = call - .arguments - .get("merge_mode") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let priority = call - .arguments - .get("priority") - .and_then(|v| v.as_i64()) - .map(|v| v as i32); - - MeshToolExecutionResult { - success: true, - message: "Creating task...".to_string(), - data: None, - request: Some(MeshToolRequest::CreateTask { - name: name.to_string(), - plan: plan.to_string(), - parent_task_id, - repository_url, - base_branch, - merge_mode, - priority, - }), - pending_questions: None, - } -} - -fn parse_run_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - let daemon_id = call - .arguments - .get("daemon_id") - .and_then(|v| v.as_str()) - .and_then(|s| Uuid::parse_str(s).ok()); - - MeshToolExecutionResult { - success: true, - message: "Starting task...".to_string(), - data: None, - request: Some(MeshToolRequest::RunTask { task_id, daemon_id }), - pending_questions: None, - } -} - -fn parse_pause_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - MeshToolExecutionResult { - success: true, - message: "Pausing task...".to_string(), - data: None, - request: Some(MeshToolRequest::PauseTask { task_id }), - pending_questions: None, - } -} - -fn parse_resume_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - MeshToolExecutionResult { - success: true, - message: "Resuming task...".to_string(), - data: None, - request: Some(MeshToolRequest::ResumeTask { task_id }), - pending_questions: None, - } -} - -fn parse_interrupt_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - let graceful = call - .arguments - .get("graceful") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - - MeshToolExecutionResult { - success: true, - message: if graceful { - "Gracefully interrupting task...".to_string() - } else { - "Force interrupting task...".to_string() - }, - data: None, - request: Some(MeshToolRequest::InterruptTask { task_id, graceful }), - pending_questions: None, - } -} - -fn parse_discard_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - let confirm = call - .arguments - .get("confirm") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - if !confirm { - return error_result("Must set confirm=true to discard a task"); - } - - MeshToolExecutionResult { - success: true, - message: "Discarding task...".to_string(), - data: None, - request: Some(MeshToolRequest::DiscardTask { task_id }), - pending_questions: None, - } -} - -fn parse_query_task_status(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - MeshToolExecutionResult { - success: true, - message: "Querying task status...".to_string(), - data: None, - request: Some(MeshToolRequest::QueryTaskStatus { task_id }), - pending_questions: None, - } -} - -fn parse_list_tasks(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let status_filter = call - .arguments - .get("status_filter") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let parent_task_id = call - .arguments - .get("parent_task_id") - .and_then(|v| v.as_str()) - .and_then(|s| Uuid::parse_str(s).ok()); - - MeshToolExecutionResult { - success: true, - message: "Listing tasks...".to_string(), - data: None, - request: Some(MeshToolRequest::ListTasks { - status_filter, - parent_task_id, - }), - pending_questions: None, - } -} - -fn parse_list_subtasks(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - MeshToolExecutionResult { - success: true, - message: "Listing subtasks...".to_string(), - data: None, - request: Some(MeshToolRequest::ListSubtasks { task_id }), - pending_questions: None, - } -} - -fn parse_list_siblings(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - MeshToolExecutionResult { - success: true, - message: "Listing sibling tasks...".to_string(), - data: None, - request: Some(MeshToolRequest::ListSiblings { task_id }), - pending_questions: None, - } -} - -fn parse_list_daemons() -> MeshToolExecutionResult { - MeshToolExecutionResult { - success: true, - message: "Listing daemons...".to_string(), - data: None, - request: Some(MeshToolRequest::ListDaemons), - pending_questions: None, - } -} - -fn parse_list_daemon_directories() -> MeshToolExecutionResult { - MeshToolExecutionResult { - success: true, - message: "Listing daemon directories...".to_string(), - data: None, - request: Some(MeshToolRequest::ListDaemonDirectories), - pending_questions: None, - } -} - -fn parse_list_files() -> MeshToolExecutionResult { - MeshToolExecutionResult { - success: true, - message: "Listing files...".to_string(), - data: None, - request: Some(MeshToolRequest::ListFiles), - pending_questions: None, - } -} - -fn parse_read_file(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let file_id = parse_uuid_arg(call, "file_id"); - let Some(file_id) = file_id else { - return error_result("Missing or invalid required parameter: file_id"); - }; - - MeshToolExecutionResult { - success: true, - message: "Reading file...".to_string(), - data: None, - request: Some(MeshToolRequest::ReadFile { file_id }), - pending_questions: None, - } -} - -fn parse_send_message_to_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - let message = call.arguments.get("message").and_then(|v| v.as_str()); - let Some(message) = message else { - return error_result("Missing required parameter: message"); - }; - - MeshToolExecutionResult { - success: true, - message: "Sending message to task...".to_string(), - data: None, - request: Some(MeshToolRequest::SendMessageToTask { - task_id, - message: message.to_string(), - }), - pending_questions: None, - } -} - -fn parse_update_task_plan(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - let new_plan = call.arguments.get("new_plan").and_then(|v| v.as_str()); - let Some(new_plan) = new_plan else { - return error_result("Missing required parameter: new_plan"); - }; - - let interrupt_if_running = call - .arguments - .get("interrupt_if_running") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - MeshToolExecutionResult { - success: true, - message: "Updating task plan...".to_string(), - data: None, - request: Some(MeshToolRequest::UpdateTaskPlan { - task_id, - new_plan: new_plan.to_string(), - interrupt_if_running, - }), - pending_questions: None, - } -} - -fn parse_peek_sibling_overlay(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let sibling_task_id = parse_uuid_arg(call, "sibling_task_id"); - let Some(sibling_task_id) = sibling_task_id else { - return error_result("Missing or invalid required parameter: sibling_task_id"); - }; - - MeshToolExecutionResult { - success: true, - message: "Peeking at sibling overlay...".to_string(), - data: None, - request: Some(MeshToolRequest::PeekSiblingOverlay { sibling_task_id }), - pending_questions: None, - } -} - -fn parse_get_overlay_diff(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - MeshToolExecutionResult { - success: true, - message: "Getting overlay diff...".to_string(), - data: None, - request: Some(MeshToolRequest::GetOverlayDiff { task_id }), - pending_questions: None, - } -} - -fn parse_preview_merge(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - MeshToolExecutionResult { - success: true, - message: "Previewing merge...".to_string(), - data: None, - request: Some(MeshToolRequest::PreviewMerge { task_id }), - pending_questions: None, - } -} - -fn parse_merge_subtask(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - MeshToolExecutionResult { - success: true, - message: "Merging subtask...".to_string(), - data: None, - request: Some(MeshToolRequest::MergeSubtask { task_id }), - pending_questions: None, - } -} - -fn parse_complete_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - MeshToolExecutionResult { - success: true, - message: "Completing task...".to_string(), - data: None, - request: Some(MeshToolRequest::CompleteTask { task_id }), - pending_questions: None, - } -} - -fn parse_set_merge_mode(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let task_id = parse_uuid_arg(call, "task_id"); - let Some(task_id) = task_id else { - return error_result("Missing or invalid required parameter: task_id"); - }; - - let mode = call.arguments.get("mode").and_then(|v| v.as_str()); - let Some(mode) = mode else { - return error_result("Missing required parameter: mode"); - }; - - if !["pr", "auto", "manual"].contains(&mode) { - return error_result("Invalid mode. Must be 'pr', 'auto', or 'manual'"); - } - - MeshToolExecutionResult { - success: true, - message: format!("Setting merge mode to '{}'...", mode), - data: None, - request: Some(MeshToolRequest::SetMergeMode { - task_id, - mode: mode.to_string(), - }), - pending_questions: None, - } -} - -fn parse_ask_user(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let questions_value = call.arguments.get("questions"); - - let Some(questions_array) = questions_value.and_then(|v| v.as_array()) else { - return error_result("Missing or invalid 'questions' parameter"); - }; - - let mut questions: Vec<super::tools::UserQuestion> = Vec::new(); - - for q in questions_array { - let id = q.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let question = q.get("question").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let options: Vec<String> = q - .get("options") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|o| o.as_str()) - .map(|s| s.to_string()) - .collect() - }) - .unwrap_or_default(); - let allow_multiple = q.get("allowMultiple").and_then(|v| v.as_bool()).unwrap_or(false); - let allow_custom = q.get("allowCustom").and_then(|v| v.as_bool()).unwrap_or(true); - - if id.is_empty() || question.is_empty() || options.is_empty() { - continue; - } - - questions.push(super::tools::UserQuestion { - id, - question, - options, - allow_multiple, - allow_custom, - }); - } - - if questions.is_empty() { - return error_result("No valid questions provided"); - } - - let question_count = questions.len(); - MeshToolExecutionResult { - success: true, - message: format!("Asking user {} question(s). Waiting for response...", question_count), - data: None, - request: None, - pending_questions: Some(questions), - } -} - -// ============================================================================= -// Supervisor Tool Parsing Functions -// ============================================================================= - -fn parse_get_all_contract_tasks(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let Some(contract_id) = parse_uuid_arg(call, "contract_id") else { - return error_result("Missing or invalid contract_id"); - }; - - MeshToolExecutionResult { - success: true, - message: "Querying all contract tasks...".to_string(), - data: None, - request: Some(MeshToolRequest::GetAllContractTasks { contract_id }), - pending_questions: None, - } -} - -fn parse_wait_for_task_completion(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let Some(task_id) = parse_uuid_arg(call, "task_id") else { - return error_result("Missing or invalid task_id"); - }; - - let timeout_seconds = call - .arguments - .get("timeout_seconds") - .and_then(|v| v.as_i64()) - .map(|v| v as i32) - .unwrap_or(300); - - MeshToolExecutionResult { - success: true, - message: format!("Waiting for task completion (timeout: {}s)...", timeout_seconds), - data: None, - request: Some(MeshToolRequest::WaitForTaskCompletion { - task_id, - timeout_seconds, - }), - pending_questions: None, - } -} - -fn parse_read_task_worktree(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let Some(task_id) = parse_uuid_arg(call, "task_id") else { - return error_result("Missing or invalid task_id"); - }; - - let Some(file_path) = call.arguments.get("file_path").and_then(|v| v.as_str()) else { - return error_result("Missing required parameter: file_path"); - }; - - MeshToolExecutionResult { - success: true, - message: format!("Reading file from task worktree: {}", file_path), - data: None, - request: Some(MeshToolRequest::ReadTaskWorktree { - task_id, - file_path: file_path.to_string(), - }), - pending_questions: None, - } -} - -fn parse_spawn_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let Some(name) = call.arguments.get("name").and_then(|v| v.as_str()) else { - return error_result("Missing required parameter: name"); - }; - - let Some(plan) = call.arguments.get("plan").and_then(|v| v.as_str()) else { - return error_result("Missing required parameter: plan"); - }; - - let parent_task_id = parse_uuid_arg(call, "parent_task_id"); - - let checkpoint_sha = call - .arguments - .get("checkpoint_sha") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let repository_url = call - .arguments - .get("repository_url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let base_branch = call - .arguments - .get("base_branch") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - MeshToolExecutionResult { - success: true, - message: format!("Spawning task: {}", name), - data: None, - request: Some(MeshToolRequest::SpawnTask { - name: name.to_string(), - plan: plan.to_string(), - parent_task_id, - checkpoint_sha, - repository_url, - base_branch, - }), - pending_questions: None, - } -} - -fn parse_create_checkpoint(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let Some(task_id) = parse_uuid_arg(call, "task_id") else { - return error_result("Missing or invalid task_id"); - }; - - let Some(message) = call.arguments.get("message").and_then(|v| v.as_str()) else { - return error_result("Missing required parameter: message"); - }; - - MeshToolExecutionResult { - success: true, - message: format!("Creating checkpoint: {}", message), - data: None, - request: Some(MeshToolRequest::CreateCheckpoint { - task_id, - message: message.to_string(), - }), - pending_questions: None, - } -} - -fn parse_list_task_checkpoints(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let Some(task_id) = parse_uuid_arg(call, "task_id") else { - return error_result("Missing or invalid task_id"); - }; - - MeshToolExecutionResult { - success: true, - message: "Listing task checkpoints...".to_string(), - data: None, - request: Some(MeshToolRequest::ListTaskCheckpoints { task_id }), - pending_questions: None, - } -} - -fn parse_get_task_tree(call: &super::tools::ToolCall) -> MeshToolExecutionResult { - let Some(task_id) = parse_uuid_arg(call, "task_id") else { - return error_result("Missing or invalid task_id"); - }; - - MeshToolExecutionResult { - success: true, - message: "Getting task tree...".to_string(), - data: None, - request: Some(MeshToolRequest::GetTaskTree { task_id }), - pending_questions: None, - } -} - -// ============================================================================= -// Helper Functions -// ============================================================================= - -fn parse_uuid_arg(call: &super::tools::ToolCall, key: &str) -> Option<Uuid> { - call.arguments - .get(key) - .and_then(|v| v.as_str()) - .and_then(|s| Uuid::parse_str(s).ok()) -} - -fn error_result(message: &str) -> MeshToolExecutionResult { - MeshToolExecutionResult { - success: false, - message: message.to_string(), - data: None, - request: None, - pending_questions: None, - } -} diff --git a/makima/src/llm/mod.rs b/makima/src/llm/mod.rs deleted file mode 100644 index 6c9965c..0000000 --- a/makima/src/llm/mod.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! LLM integration module for file editing via tool calling. - -pub mod claude; -pub mod contract_evaluator; -pub mod contract_tools; -pub mod discuss_tools; -pub mod groq; -pub mod markdown; -pub mod mesh_tools; -pub mod phase_guidance; -pub mod task_output; -pub mod templates; -pub mod tools; -pub mod transcript_analyzer; - -pub use claude::{ClaudeClient, ClaudeModel}; -pub use contract_tools::{ - parse_contract_tool_call, ChainedTaskDef, ContractToolExecutionResult, ContractToolRequest, - CONTRACT_TOOLS, -}; -pub use discuss_tools::{ - parse_discuss_tool_call, DiscussToolExecutionResult, DiscussToolRequest, DISCUSS_TOOLS, -}; -pub use groq::GroqClient; -pub use mesh_tools::{parse_mesh_tool_call, MeshToolExecutionResult, MeshToolRequest, MESH_TOOLS}; -pub use phase_guidance::{ - check_deliverables_met, format_checklist_markdown, generate_deliverable_prompt_guidance, - get_next_phase_for_contract, get_phase_checklist_for_type, get_phase_deliverables, - get_phase_deliverables_for_type, get_phase_deliverables_with_config, should_auto_progress, - AutoProgressAction, AutoProgressDecision, Deliverable, DeliverableCheckResult, DeliverableItem, - DeliverablePriority, DeliverableStatus, PhaseChecklist, PhaseDeliverables, TaskInfo, TaskStats, -}; -pub use task_output::{ - analyze_task_output, format_parsed_tasks, parse_tasks_from_breakdown, ParsedTask, - PhaseImpact, SuggestedAction, TaskOutputAnalysis, TaskParseResult, -}; -pub use markdown::{body_to_markdown, markdown_to_body}; -pub use templates::{all_contract_types, ContractTypeTemplate}; -pub use tools::{ - execute_tool_call, Tool, ToolCall, ToolResult, UserAnswer, UserQuestion, VersionToolRequest, - AVAILABLE_TOOLS, -}; -pub use transcript_analyzer::{ - TranscriptAnalysisResult, ExtractedRequirement, ExtractedDecision, - ExtractedActionItem, SpeakerStats, format_transcript_for_analysis, - calculate_speaker_stats, build_analysis_prompt, parse_analysis_response, -}; -pub use contract_evaluator::{ - ContractEvaluator, ContractEvaluationResult, ContractEvaluatorError, -}; - -/// Available LLM providers and models -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum LlmModel { - /// Claude Sonnet 4.5 - balanced speed and capability - ClaudeSonnet, - /// Claude Opus 4.5 (default) - most capable - #[default] - ClaudeOpus, - /// Groq Kimi - fast alternative provider - GroqKimi, -} - -impl LlmModel { - pub fn from_str(s: &str) -> Option<Self> { - match s.to_lowercase().as_str() { - "claude-sonnet" | "sonnet" | "claude" => Some(LlmModel::ClaudeSonnet), - "claude-opus" | "opus" => Some(LlmModel::ClaudeOpus), - "groq" | "kimi" | "groq-kimi" => Some(LlmModel::GroqKimi), - _ => None, - } - } -} diff --git a/makima/src/llm/phase_guidance.rs b/makima/src/llm/phase_guidance.rs deleted file mode 100644 index 712e8bb..0000000 --- a/makima/src/llm/phase_guidance.rs +++ /dev/null @@ -1,1032 +0,0 @@ -//! Phase guidance and deliverables tracking for contract management. -//! -//! This module provides structured guidance for each contract phase, tracking -//! expected deliverables and completion criteria. -//! -//! ## Contract Types -//! -//! ### Simple -//! - **Plan phase**: One required deliverable: "Plan" -//! - **Execute phase**: One required deliverable: "PR" -//! -//! ### Specification -//! - **Research phase**: One required deliverable: "Research Notes" -//! - **Specify phase**: One required deliverable: "Requirements Document" -//! - **Plan phase**: One required deliverable: "Plan" -//! - **Execute phase**: One required deliverable: "PR" -//! - **Review phase**: One required deliverable: "Release Notes" -//! -//! ### Execute -//! - **Execute phase only**: No deliverables at all - -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -/// Priority level for deliverables -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum DeliverablePriority { - /// Must be completed before advancing phase - Required, - /// Strongly suggested for phase completion - Recommended, - /// Nice to have, not blocking - Optional, -} - -/// A deliverable for a phase -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct Deliverable { - /// Unique identifier for the deliverable - pub id: String, - /// Display name - pub name: String, - /// Priority level - pub priority: DeliverablePriority, - /// Brief description of purpose - pub description: String, -} - -/// Expected deliverables for a phase -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct PhaseDeliverables { - /// Phase name - pub phase: String, - /// Deliverables for this phase - pub deliverables: Vec<Deliverable>, - /// Whether a repository is required for this phase - pub requires_repository: bool, - /// Whether tasks should be completed in this phase - pub requires_tasks: bool, - /// Guidance text for this phase - pub guidance: String, -} - -/// Status of a deliverable -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct DeliverableStatus { - /// Deliverable ID - pub id: String, - /// Display name - pub name: String, - /// Priority - pub priority: DeliverablePriority, - /// Whether it has been completed - pub completed: bool, -} - -/// Checklist for phase completion -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct PhaseChecklist { - /// Current phase - pub phase: String, - /// Deliverable status list - pub deliverables: Vec<DeliverableStatus>, - /// Whether repository is configured - pub has_repository: bool, - /// Whether repository was required - pub repository_required: bool, - /// Task statistics (for execute phase) - pub task_stats: Option<TaskStats>, - /// Overall completion percentage (0-100) - pub completion_percentage: u8, - /// Summary message - pub summary: String, - /// Suggestions for next actions - pub suggestions: Vec<String>, -} - -/// Task statistics for execute phase -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct TaskStats { - pub total: usize, - pub pending: usize, - pub running: usize, - pub done: usize, - pub failed: usize, -} - -/// Minimal task info for checklist building -pub struct TaskInfo { - pub name: String, - pub status: String, -} - -use crate::db::models::PhaseConfig; - -/// Get phase deliverables configuration (legacy, defaults to "simple" contract type) -pub fn get_phase_deliverables(phase: &str) -> PhaseDeliverables { - get_phase_deliverables_for_type(phase, "simple") -} - -/// Get phase deliverables configuration for a specific contract type -pub fn get_phase_deliverables_for_type(phase: &str, contract_type: &str) -> PhaseDeliverables { - match contract_type { - "execute" => get_execute_type_deliverables(phase), - "specification" => get_specification_type_deliverables(phase), - "simple" | _ => get_simple_type_deliverables(phase), - } -} - -/// Get phase deliverables from a custom PhaseConfig -/// This is used for contracts with custom templates -pub fn get_phase_deliverables_from_config(phase: &str, config: &PhaseConfig) -> PhaseDeliverables { - // Check if this phase exists in the config - let phase_exists = config.phases.iter().any(|p| p.id == phase); - if !phase_exists { - return PhaseDeliverables { - phase: phase.to_string(), - deliverables: vec![], - requires_repository: false, - requires_tasks: false, - guidance: format!("Phase '{}' is not defined in this contract template", phase), - }; - } - - // Get deliverables for this phase from the config - let deliverables: Vec<Deliverable> = config - .deliverables - .get(phase) - .map(|defs| { - defs.iter() - .map(|d| Deliverable { - id: d.id.clone(), - name: d.name.clone(), - priority: match d.priority.as_str() { - "recommended" => DeliverablePriority::Recommended, - "optional" => DeliverablePriority::Optional, - _ => DeliverablePriority::Required, - }, - description: format!("{} deliverable", d.name), - }) - .collect() - }) - .unwrap_or_default(); - - // Determine if repository is required (typically for execute-like phases) - let requires_repository = phase == "execute" || phase == "plan"; - - // Determine if tasks are required (typically for execute phase) - let requires_tasks = phase == "execute"; - - // Find the phase name for better guidance - let phase_name = config - .phases - .iter() - .find(|p| p.id == phase) - .map(|p| p.name.clone()) - .unwrap_or_else(|| phase.to_string()); - - let guidance = if deliverables.is_empty() { - format!("Complete the {} phase. No specific deliverables are required.", phase_name) - } else { - let deliverable_names: Vec<_> = deliverables.iter().map(|d| d.name.clone()).collect(); - format!( - "Complete the {} phase by producing the following deliverables: {}", - phase_name, - deliverable_names.join(", ") - ) - }; - - PhaseDeliverables { - phase: phase.to_string(), - deliverables, - requires_repository, - requires_tasks, - guidance, - } -} - -/// Get phase deliverables, checking custom config first, then falling back to built-in types -pub fn get_phase_deliverables_with_config( - phase: &str, - contract_type: &str, - phase_config: Option<&PhaseConfig>, -) -> PhaseDeliverables { - // If we have a custom phase config, use it - if let Some(config) = phase_config { - return get_phase_deliverables_from_config(phase, config); - } - - // Otherwise, fall back to built-in contract types - get_phase_deliverables_for_type(phase, contract_type) -} - -/// Get deliverables for 'simple' contract type -fn get_simple_type_deliverables(phase: &str) -> PhaseDeliverables { - match phase { - "plan" => PhaseDeliverables { - phase: "plan".to_string(), - deliverables: vec![Deliverable { - id: "plan-document".to_string(), - name: "Plan".to_string(), - priority: DeliverablePriority::Required, - description: "Implementation plan detailing the approach and tasks".to_string(), - }], - requires_repository: true, - requires_tasks: false, - guidance: "Create a plan document that outlines the implementation approach. A repository must be configured before moving to Execute phase.".to_string(), - }, - "execute" => PhaseDeliverables { - phase: "execute".to_string(), - deliverables: vec![Deliverable { - id: "pull-request".to_string(), - name: "Pull Request".to_string(), - priority: DeliverablePriority::Required, - description: "Pull request with the implemented changes".to_string(), - }], - requires_repository: true, - requires_tasks: true, - guidance: "Execute the plan and create a PR with the implemented changes. Complete all tasks to finish the contract.".to_string(), - }, - _ => PhaseDeliverables { - phase: phase.to_string(), - deliverables: vec![], - requires_repository: false, - requires_tasks: false, - guidance: "Unknown phase for simple contract type".to_string(), - }, - } -} - -/// Get deliverables for 'specification' contract type -fn get_specification_type_deliverables(phase: &str) -> PhaseDeliverables { - match phase { - "research" => PhaseDeliverables { - phase: "research".to_string(), - deliverables: vec![Deliverable { - id: "research-notes".to_string(), - name: "Research Notes".to_string(), - priority: DeliverablePriority::Required, - description: "Document findings and insights during research".to_string(), - }], - requires_repository: false, - requires_tasks: false, - guidance: "Focus on understanding the problem space and document your findings in the Research Notes before moving to Specify phase.".to_string(), - }, - "specify" => PhaseDeliverables { - phase: "specify".to_string(), - deliverables: vec![Deliverable { - id: "requirements-document".to_string(), - name: "Requirements Document".to_string(), - priority: DeliverablePriority::Required, - description: "Define functional and non-functional requirements".to_string(), - }], - requires_repository: false, - requires_tasks: false, - guidance: "Define what needs to be built with clear requirements in the Requirements Document. Ensure specifications are detailed enough for planning.".to_string(), - }, - "plan" => PhaseDeliverables { - phase: "plan".to_string(), - deliverables: vec![Deliverable { - id: "plan-document".to_string(), - name: "Plan".to_string(), - priority: DeliverablePriority::Required, - description: "Implementation plan detailing the approach and tasks".to_string(), - }], - requires_repository: true, - requires_tasks: false, - guidance: "Create a plan document that outlines the implementation approach. A repository must be configured before moving to Execute phase.".to_string(), - }, - "execute" => PhaseDeliverables { - phase: "execute".to_string(), - deliverables: vec![Deliverable { - id: "pull-request".to_string(), - name: "Pull Request".to_string(), - priority: DeliverablePriority::Required, - description: "Pull request with the implemented changes".to_string(), - }], - requires_repository: true, - requires_tasks: true, - guidance: "Execute the plan and create a PR with the implemented changes. Complete all tasks before moving to Review phase.".to_string(), - }, - "review" => PhaseDeliverables { - phase: "review".to_string(), - deliverables: vec![Deliverable { - id: "release-notes".to_string(), - name: "Release Notes".to_string(), - priority: DeliverablePriority::Required, - description: "Document changes for release communication".to_string(), - }], - requires_repository: false, - requires_tasks: false, - guidance: "Review completed work and document the release in the Release Notes. The contract can be completed after review.".to_string(), - }, - _ => PhaseDeliverables { - phase: phase.to_string(), - deliverables: vec![], - requires_repository: false, - requires_tasks: false, - guidance: "Unknown phase for specification contract type".to_string(), - }, - } -} - -/// Get deliverables for 'execute' contract type -fn get_execute_type_deliverables(phase: &str) -> PhaseDeliverables { - match phase { - "execute" => PhaseDeliverables { - phase: "execute".to_string(), - deliverables: vec![], // No deliverables for execute-only contract type - requires_repository: true, - requires_tasks: true, - guidance: "Execute the tasks directly. No deliverable documents are required for this contract type.".to_string(), - }, - _ => PhaseDeliverables { - phase: phase.to_string(), - deliverables: vec![], - requires_repository: false, - requires_tasks: false, - guidance: "The 'execute' contract type only supports the 'execute' phase.".to_string(), - }, - } -} - -/// Build a phase checklist comparing expected vs actual deliverables -pub fn get_phase_checklist_for_type( - phase: &str, - completed_deliverables: &[String], - tasks: &[TaskInfo], - has_repository: bool, - contract_type: &str, -) -> PhaseChecklist { - let phase_config = get_phase_deliverables_for_type(phase, contract_type); - - // Build deliverable status list - let deliverables: Vec<DeliverableStatus> = phase_config - .deliverables - .iter() - .map(|d| DeliverableStatus { - id: d.id.clone(), - name: d.name.clone(), - priority: d.priority, - completed: completed_deliverables.contains(&d.id), - }) - .collect(); - - // Calculate task stats for execute phase - let task_stats = if phase == "execute" { - let total = tasks.len(); - let pending = tasks.iter().filter(|t| t.status == "pending").count(); - let running = tasks.iter().filter(|t| t.status == "running").count(); - let done = tasks.iter().filter(|t| t.status == "done").count(); - let failed = tasks - .iter() - .filter(|t| t.status == "failed" || t.status == "error") - .count(); - - Some(TaskStats { - total, - pending, - running, - done, - failed, - }) - } else { - None - }; - - // Calculate completion percentage - let mut completed_items = 0; - let mut total_items = 0; - - // Count required and recommended deliverables (not optional) - for status in &deliverables { - if status.priority != DeliverablePriority::Optional { - total_items += 1; - if status.completed { - completed_items += 1; - } - } - } - - // Count repository if required - if phase_config.requires_repository { - total_items += 1; - if has_repository { - completed_items += 1; - } - } - - // Count tasks if in execute phase - if let Some(ref stats) = task_stats { - if stats.total > 0 { - total_items += 1; - if stats.done == stats.total && stats.total > 0 { - completed_items += 1; - } - } - } - - let completion_percentage = if total_items > 0 { - ((completed_items as f64 / total_items as f64) * 100.0) as u8 - } else { - 100 // No requirements means complete - }; - - // Generate suggestions - let mut suggestions = Vec::new(); - - // Suggest missing deliverables - for status in &deliverables { - if !status.completed { - match status.priority { - DeliverablePriority::Required => { - suggestions.push(format!( - "Mark '{}' as complete using mark_deliverable_complete (required)", - status.name - )); - } - DeliverablePriority::Recommended => { - suggestions.push(format!( - "Consider completing '{}' (recommended)", - status.name - )); - } - DeliverablePriority::Optional => {} - } - } - } - - // Suggest repository if needed - if phase_config.requires_repository && !has_repository { - suggestions.push("Configure a repository for task execution".to_string()); - } - - // Suggest task actions for execute phase - if let Some(ref stats) = task_stats { - if stats.total == 0 { - suggestions.push("Create tasks to implement the plan".to_string()); - } else if stats.pending > 0 { - suggestions.push(format!("Run {} pending task(s)", stats.pending)); - } else if stats.running > 0 { - suggestions.push(format!( - "Wait for {} running task(s) to complete", - stats.running - )); - } else if stats.failed > 0 { - suggestions.push(format!("Address {} failed task(s)", stats.failed)); - } else if stats.done == stats.total && stats.total > 0 { - suggestions.push("All tasks complete. Mark deliverables and advance phase.".to_string()); - } - } - - // Generate summary - let summary = generate_phase_summary( - phase, - &deliverables, - has_repository, - &task_stats, - completion_percentage, - ); - - PhaseChecklist { - phase: phase.to_string(), - deliverables, - has_repository, - repository_required: phase_config.requires_repository, - task_stats, - completion_percentage, - summary, - suggestions, - } -} - -fn generate_phase_summary( - phase: &str, - deliverables: &[DeliverableStatus], - has_repository: bool, - task_stats: &Option<TaskStats>, - completion_percentage: u8, -) -> String { - let completed_count = deliverables.iter().filter(|d| d.completed).count(); - let total_count = deliverables.len(); - - match phase { - "research" => { - if completed_count == 0 { - "Research phase needs documentation. Mark deliverables complete when ready." - .to_string() - } else { - format!( - "{}/{} deliverables complete. Ready to transition to Specify phase.", - completed_count, total_count - ) - } - } - "specify" => { - let has_required = deliverables - .iter() - .filter(|d| d.priority == DeliverablePriority::Required) - .all(|d| d.completed); - - if !has_required { - "Specify phase requires completing the Requirements Document deliverable." - .to_string() - } else { - "Specifications ready. Consider transitioning to Plan phase.".to_string() - } - } - "plan" => { - let has_required = deliverables - .iter() - .filter(|d| d.priority == DeliverablePriority::Required) - .all(|d| d.completed); - - if !has_required { - "Plan phase requires completing the Plan deliverable.".to_string() - } else if !has_repository { - "Repository not configured. Configure a repository before Execute phase." - .to_string() - } else { - "Planning complete. Ready to transition to Execute phase.".to_string() - } - } - "execute" => { - if let Some(stats) = task_stats { - if stats.total == 0 { - "No tasks created. Create tasks to implement the plan.".to_string() - } else if stats.done == stats.total { - "All tasks complete! Mark deliverables and advance to Review phase (or complete contract).".to_string() - } else { - format!( - "{}/{} tasks completed ({}% done)", - stats.done, - stats.total, - if stats.total > 0 { - (stats.done * 100) / stats.total - } else { - 0 - } - ) - } - } else { - "Execute phase in progress.".to_string() - } - } - "review" => { - let has_required = deliverables - .iter() - .filter(|d| d.priority == DeliverablePriority::Required) - .all(|d| d.completed); - - if !has_required { - "Review phase requires completing the Release Notes deliverable.".to_string() - } else { - "Review documentation complete. Contract can be marked as done.".to_string() - } - } - _ => format!("Phase {} - {}% complete", phase, completion_percentage), - } -} - -/// Result of checking if deliverables are met for the current phase -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct DeliverableCheckResult { - /// Whether all required deliverables are met - pub deliverables_met: bool, - /// Whether the phase is ready to advance (includes all readiness checks) - pub ready_to_advance: bool, - /// Current phase - pub phase: String, - /// Next phase (if available) - pub next_phase: Option<String>, - /// List of required deliverables and their status - pub required_deliverables: Vec<DeliverableItem>, - /// List of what's missing (if any) - pub missing: Vec<String>, - /// Human-readable summary - pub summary: String, - /// Whether auto-progress is recommended - pub auto_progress_recommended: bool, -} - -/// A single deliverable item status -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct DeliverableItem { - /// ID of the deliverable - pub id: String, - /// Name of the deliverable - pub name: String, - /// Type: "deliverable", "repository", "tasks" - pub deliverable_type: String, - /// Whether it's met - pub met: bool, - /// Additional details - pub details: Option<String>, -} - -/// Check if all required deliverables for the current phase are met -pub fn check_deliverables_met( - phase: &str, - contract_type: &str, - completed_deliverables: &[String], - tasks: &[TaskInfo], - has_repository: bool, -) -> DeliverableCheckResult { - let mut required_items = Vec::new(); - let mut missing = Vec::new(); - - // Get the deliverables for this contract type and phase - let phase_config = get_phase_deliverables_for_type(phase, contract_type); - - // Check required deliverables for this phase - for deliverable in &phase_config.deliverables { - if deliverable.priority == DeliverablePriority::Required { - let is_complete = completed_deliverables.contains(&deliverable.id); - - required_items.push(DeliverableItem { - id: deliverable.id.clone(), - name: deliverable.name.clone(), - deliverable_type: "deliverable".to_string(), - met: is_complete, - details: if is_complete { - Some("Marked complete".to_string()) - } else { - None - }, - }); - - if !is_complete { - missing.push(format!( - "Mark '{}' as complete (required)", - deliverable.name - )); - } - } - } - - // Check repository for phases that require it - if phase_config.requires_repository { - required_items.push(DeliverableItem { - id: "repository".to_string(), - name: "Repository".to_string(), - deliverable_type: "repository".to_string(), - met: has_repository, - details: if has_repository { - Some("Repository configured".to_string()) - } else { - None - }, - }); - - if !has_repository { - missing.push("Configure a repository".to_string()); - } - } - - // Check tasks for execute phase - if phase_config.requires_tasks { - let total_tasks = tasks.len(); - let done_tasks = tasks.iter().filter(|t| t.status == "done").count(); - let tasks_complete = total_tasks > 0 && done_tasks == total_tasks; - - required_items.push(DeliverableItem { - id: "tasks".to_string(), - name: "Tasks Completed".to_string(), - deliverable_type: "tasks".to_string(), - met: tasks_complete, - details: Some(format!("{}/{} tasks done", done_tasks, total_tasks)), - }); - - if !tasks_complete { - if total_tasks == 0 { - missing.push("Create and complete tasks".to_string()); - } else { - missing.push(format!( - "Complete remaining {} task(s)", - total_tasks - done_tasks - )); - } - } - } - - let deliverables_met = required_items.iter().all(|d| d.met); - let next_phase = get_next_phase_for_contract(contract_type, phase); - let ready_to_advance = deliverables_met && next_phase.is_some(); - - let summary = if deliverables_met { - if let Some(ref next) = next_phase { - format!( - "All deliverables met for {} phase. Ready to advance to {} phase.", - phase, next - ) - } else { - format!( - "All deliverables met for {} phase. This is the final phase.", - phase - ) - } - } else { - format!( - "{} deliverable(s) still needed for {} phase.", - missing.len(), - phase - ) - }; - - DeliverableCheckResult { - deliverables_met, - ready_to_advance, - phase: phase.to_string(), - next_phase, - required_deliverables: required_items, - missing, - summary, - auto_progress_recommended: deliverables_met && ready_to_advance, - } -} - -/// Get the next phase based on contract type -pub fn get_next_phase_for_contract(contract_type: &str, current_phase: &str) -> Option<String> { - match contract_type { - "simple" => match current_phase { - "plan" => Some("execute".to_string()), - "execute" => None, // Terminal phase for simple contracts - _ => None, - }, - "execute" => None, // Execute-only contracts don't have phase transitions - "specification" | _ => match current_phase { - "research" => Some("specify".to_string()), - "specify" => Some("plan".to_string()), - "plan" => Some("execute".to_string()), - "execute" => Some("review".to_string()), - "review" => None, // Final phase - _ => None, - }, - } -} - -/// Determine if the contract should auto-progress to the next phase -pub fn should_auto_progress( - phase: &str, - contract_type: &str, - completed_deliverables: &[String], - tasks: &[TaskInfo], - has_repository: bool, - autonomous_loop: bool, -) -> AutoProgressDecision { - let check = check_deliverables_met( - phase, - contract_type, - completed_deliverables, - tasks, - has_repository, - ); - - if !check.deliverables_met { - return AutoProgressDecision { - should_progress: false, - next_phase: None, - reason: format!("Deliverables not met: {}", check.missing.join(", ")), - action: AutoProgressAction::WaitForDeliverables, - }; - } - - if check.next_phase.is_none() { - return AutoProgressDecision { - should_progress: false, - next_phase: None, - reason: "This is the terminal phase. Contract can be completed.".to_string(), - action: AutoProgressAction::CompleteContract, - }; - } - - if autonomous_loop { - AutoProgressDecision { - should_progress: true, - next_phase: check.next_phase, - reason: "All deliverables met and autonomous_loop is enabled.".to_string(), - action: AutoProgressAction::AdvancePhase, - } - } else { - AutoProgressDecision { - should_progress: false, - next_phase: check.next_phase, - reason: "All deliverables met. Suggest advancing to next phase.".to_string(), - action: AutoProgressAction::SuggestAdvance, - } - } -} - -/// Result of auto-progress decision -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AutoProgressDecision { - /// Whether to automatically progress - pub should_progress: bool, - /// The next phase to progress to - pub next_phase: Option<String>, - /// Reason for the decision - pub reason: String, - /// Recommended action - pub action: AutoProgressAction, -} - -/// Actions that can be taken based on auto-progress decision -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum AutoProgressAction { - /// Wait for required deliverables - WaitForDeliverables, - /// Automatically advance to next phase - AdvancePhase, - /// Suggest user to advance (when not autonomous) - SuggestAdvance, - /// Contract is complete, mark as done - CompleteContract, -} - -/// Generate enhanced prompt guidance for deliverable checking -pub fn generate_deliverable_prompt_guidance( - phase: &str, - contract_type: &str, - check_result: &DeliverableCheckResult, -) -> String { - let mut guidance = String::new(); - - guidance.push_str("\n## Phase Deliverables Status\n\n"); - guidance.push_str(&format!( - "**Current Phase**: {} | **Contract Type**: {}\n\n", - capitalize(phase), - contract_type - )); - - // Show required deliverables checklist - guidance.push_str("### Required Deliverables Checklist\n"); - for item in &check_result.required_deliverables { - let status = if item.met { "[x]" } else { "[ ]" }; - let details = item - .details - .as_ref() - .map(|d| format!(" - {}", d)) - .unwrap_or_default(); - guidance.push_str(&format!( - "{} **{}** ({}){}\n", - status, item.name, item.deliverable_type, details - )); - } - - // Show status and next actions - guidance.push_str("\n### Status\n"); - if check_result.deliverables_met { - guidance.push_str("**All deliverables are met.**\n\n"); - if let Some(ref next) = check_result.next_phase { - guidance.push_str(&format!("Ready to advance to **{}** phase.\n", next)); - if check_result.auto_progress_recommended { - guidance.push_str(&format!("\n**ACTION REQUIRED**: Since all deliverables are met, you should call `advance_phase` with `new_phase=\"{}\"` to progress the contract.\n", next)); - } - } else { - guidance.push_str( - "This is the terminal phase. The contract can be marked as completed.\n", - ); - } - } else { - guidance.push_str("**Deliverables not yet met.**\n\n"); - guidance.push_str("Missing:\n"); - for item in &check_result.missing { - guidance.push_str(&format!("- {}\n", item)); - } - guidance.push_str( - "\nUse `mark_deliverable_complete` to mark deliverables as complete when ready.\n", - ); - } - - guidance -} - -/// Format checklist as markdown for LLM context -pub fn format_checklist_markdown(checklist: &PhaseChecklist) -> String { - let mut md = format!( - "## Phase Progress ({} Phase)\n\n", - capitalize(&checklist.phase) - ); - - // Deliverables - md.push_str("### Deliverables\n"); - for status in &checklist.deliverables { - let check = if status.completed { "+" } else { "-" }; - let priority_label = match status.priority { - DeliverablePriority::Required => " (required)", - DeliverablePriority::Recommended => " (recommended)", - DeliverablePriority::Optional => " (optional)", - }; - - if status.completed { - md.push_str(&format!("[{}] {} - completed\n", check, status.name)); - } else { - md.push_str(&format!("[{}] {}{}\n", check, status.name, priority_label)); - } - } - - // Repository status - if checklist.repository_required { - let check = if checklist.has_repository { "+" } else { "-" }; - md.push_str(&format!("[{}] Repository configured (required)\n", check)); - } - - // Task stats for execute phase - if let Some(ref stats) = checklist.task_stats { - md.push_str("\n### Task Progress\n"); - md.push_str(&format!("- Total: {}\n", stats.total)); - md.push_str(&format!("- Done: {}\n", stats.done)); - if stats.pending > 0 { - md.push_str(&format!("- Pending: {}\n", stats.pending)); - } - if stats.running > 0 { - md.push_str(&format!("- Running: {}\n", stats.running)); - } - if stats.failed > 0 { - md.push_str(&format!("- Failed: {}\n", stats.failed)); - } - } - - // Summary - md.push_str(&format!( - "\n**Status**: {} ({}% complete)\n", - checklist.summary, checklist.completion_percentage - )); - - // Suggestions - if !checklist.suggestions.is_empty() { - md.push_str("\n**Next Steps**:\n"); - for suggestion in &checklist.suggestions { - md.push_str(&format!("- {}\n", suggestion)); - } - } - - md -} - -fn capitalize(s: &str) -> String { - let mut chars = s.chars(); - match chars.next() { - None => String::new(), - Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_get_phase_deliverables_simple() { - let plan = get_phase_deliverables_for_type("plan", "simple"); - assert_eq!(plan.phase, "plan"); - assert!(plan.requires_repository); - assert_eq!(plan.deliverables.len(), 1); - assert_eq!(plan.deliverables[0].id, "plan-document"); - assert_eq!(plan.deliverables[0].priority, DeliverablePriority::Required); - - let execute = get_phase_deliverables_for_type("execute", "simple"); - assert_eq!(execute.phase, "execute"); - assert!(execute.requires_repository); - assert!(execute.requires_tasks); - assert_eq!(execute.deliverables.len(), 1); - assert_eq!(execute.deliverables[0].id, "pull-request"); - } - - #[test] - fn test_get_phase_deliverables_specification() { - let research = get_phase_deliverables_for_type("research", "specification"); - assert_eq!(research.deliverables.len(), 1); - assert_eq!(research.deliverables[0].id, "research-notes"); - - let specify = get_phase_deliverables_for_type("specify", "specification"); - assert_eq!(specify.deliverables.len(), 1); - assert_eq!(specify.deliverables[0].id, "requirements-document"); - - let review = get_phase_deliverables_for_type("review", "specification"); - assert_eq!(review.deliverables.len(), 1); - assert_eq!(review.deliverables[0].id, "release-notes"); - } - - #[test] - fn test_get_phase_deliverables_execute_type() { - let execute = get_phase_deliverables_for_type("execute", "execute"); - assert!(execute.deliverables.is_empty()); - assert!(execute.requires_repository); - assert!(execute.requires_tasks); - } - - #[test] - fn test_check_deliverables_met() { - // No deliverables marked complete - let result = check_deliverables_met("plan", "simple", &[], &[], true); - assert!(!result.deliverables_met); - assert!(!result.missing.is_empty()); - - // Deliverable marked complete - let completed = vec!["plan-document".to_string()]; - let result = check_deliverables_met("plan", "simple", &completed, &[], true); - assert!(result.deliverables_met); - assert!(result.ready_to_advance); - } - - #[test] - fn test_phase_checklist() { - let completed = vec!["plan-document".to_string()]; - let checklist = get_phase_checklist_for_type("plan", &completed, &[], true, "simple"); - assert_eq!(checklist.completion_percentage, 100); - assert!(checklist.deliverables[0].completed); - } -} diff --git a/makima/src/llm/task_output.rs b/makima/src/llm/task_output.rs deleted file mode 100644 index c7f6990..0000000 --- a/makima/src/llm/task_output.rs +++ /dev/null @@ -1,485 +0,0 @@ -//! Task output processing and task derivation utilities. -//! -//! This module provides utilities for: -//! - Parsing task lists from markdown documents -//! - Analyzing completed task outputs -//! - Suggesting follow-up actions based on task results - -use regex::Regex; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// A parsed task from a markdown document -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ParsedTask { - /// Task name/title - pub name: String, - /// Task description or plan - pub description: Option<String>, - /// Group/phase this task belongs to - pub group: Option<String>, - /// Order within the group (0-indexed) - pub order: usize, - /// Whether this task was marked as completed in source - pub completed: bool, - /// Dependencies (names of other tasks) - pub dependencies: Vec<String>, -} - -/// Result of parsing tasks from a document -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskParseResult { - /// Successfully parsed tasks - pub tasks: Vec<ParsedTask>, - /// Groups/phases found - pub groups: Vec<String>, - /// Total tasks found - pub total: usize, - /// Any parsing warnings - pub warnings: Vec<String>, -} - -/// Impact on contract phase -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PhaseImpact { - /// Current phase - pub phase: String, - /// Whether phase targets are now met - pub targets_met: bool, - /// Tasks remaining in phase - pub tasks_remaining: usize, - /// Suggestion for phase transition - pub transition_suggestion: Option<String>, -} - -/// Suggested action based on task output -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum SuggestedAction { - /// Create a follow-up task - CreateTask { - name: String, - plan: String, - chain_from: Option<Uuid>, - }, - /// Create a new file from template - CreateFile { - template_id: String, - name: String, - seed_content: Option<String>, - }, - /// Update an existing file - UpdateFile { - file_id: Uuid, - file_name: String, - additions: String, - }, - /// Advance to next phase - AdvancePhase { - to_phase: String, - }, - /// Run the next chained task - RunNextTask { - task_id: Uuid, - task_name: String, - }, - /// Mark the contract as completed - MarkContractComplete { - contract_id: Uuid, - }, -} - -/// Analysis of a completed task's output -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TaskOutputAnalysis { - /// Summary of what was accomplished - pub summary: String, - /// Files that were created/modified (from diff) - pub files_affected: Vec<String>, - /// Suggested next actions - pub next_steps: Vec<SuggestedAction>, - /// Impact on contract phase - pub phase_impact: Option<PhaseImpact>, -} - -/// Parse tasks from a markdown task breakdown document -/// -/// Supports formats like: -/// - `[ ] Task name` -/// - `[x] Completed task` -/// - `1. Task name` -/// - `- Task name` -/// -/// Groups are detected from `## Phase/Section` headings. -pub fn parse_tasks_from_breakdown(content: &str) -> TaskParseResult { - let mut tasks = Vec::new(); - let mut groups = Vec::new(); - let mut warnings = Vec::new(); - let mut current_group: Option<String> = None; - let mut task_order = 0; - - // Patterns for task items - let checkbox_pattern = Regex::new(r"^\s*[-*]\s*\[([ xX])\]\s*(.+)$").unwrap(); - let numbered_checkbox = Regex::new(r"^\s*\d+\.\s*\[([ xX])\]\s*(.+)$").unwrap(); - let numbered_pattern = Regex::new(r"^\s*\d+\.\s+(.+)$").unwrap(); - let bullet_pattern = Regex::new(r"^\s*[-*]\s+(.+)$").unwrap(); - let heading_pattern = Regex::new(r"^##\s+(?:Phase\s*\d*:?\s*)?(.+)$").unwrap(); - - // Patterns for dependencies (inline) - let depends_pattern = Regex::new(r"(?i)\(?\s*(?:depends on|after|requires):?\s*([^)]+)\)?").unwrap(); - - for line in content.lines() { - let trimmed = line.trim(); - - // Skip empty lines - if trimmed.is_empty() { - continue; - } - - // Check for section headings - if let Some(caps) = heading_pattern.captures(trimmed) { - let group_name = caps[1].trim().to_string(); - if !groups.contains(&group_name) { - groups.push(group_name.clone()); - } - current_group = Some(group_name); - task_order = 0; - continue; - } - - // Try to parse as a task - let mut task_name: Option<String> = None; - let mut completed = false; - - // Try checkbox patterns first (more specific) - if let Some(caps) = checkbox_pattern.captures(trimmed) { - completed = &caps[1] != " "; - task_name = Some(caps[2].trim().to_string()); - } else if let Some(caps) = numbered_checkbox.captures(trimmed) { - completed = &caps[1] != " "; - task_name = Some(caps[2].trim().to_string()); - } else if let Some(caps) = numbered_pattern.captures(trimmed) { - task_name = Some(caps[1].trim().to_string()); - } else if let Some(caps) = bullet_pattern.captures(trimmed) { - // Only treat as task if it looks like a task (has actionable verbs) - let text = caps[1].trim(); - if looks_like_task(text) { - task_name = Some(text.to_string()); - } - } - - if let Some(name) = task_name { - // Skip items that are clearly not tasks - if name.to_lowercase().starts_with("note:") || - name.to_lowercase().starts_with("todo:") && name.len() < 10 || - name.starts_with('#') { - continue; - } - - // Extract dependencies if present - let dependencies = if let Some(dep_caps) = depends_pattern.captures(&name) { - dep_caps[1] - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() - } else { - Vec::new() - }; - - // Clean task name (remove dependency info) - let clean_name = depends_pattern.replace(&name, "").trim().to_string(); - - // Extract description if there's a colon - let (final_name, description) = if let Some(idx) = clean_name.find(':') { - let (n, d) = clean_name.split_at(idx); - (n.trim().to_string(), Some(d[1..].trim().to_string())) - } else { - (clean_name, None) - }; - - tasks.push(ParsedTask { - name: final_name, - description, - group: current_group.clone(), - order: task_order, - completed, - dependencies, - }); - - task_order += 1; - } - } - - let total = tasks.len(); - - // Add warnings - if tasks.is_empty() { - warnings.push("No tasks found in document. Ensure tasks are formatted as checkbox items (- [ ] Task) or numbered lists (1. Task).".to_string()); - } - - TaskParseResult { - tasks, - groups, - total, - warnings, - } -} - -/// Check if text looks like a task (has action verbs at word boundaries) -fn looks_like_task(text: &str) -> bool { - let lower = text.to_lowercase(); - let action_verbs = [ - "add", "create", "implement", "build", "write", "fix", "update", - "refactor", "test", "configure", "set up", "setup", "deploy", - "integrate", "migrate", "design", "review", "document", "remove", - "delete", "modify", "change", "improve", "optimize", "enable", - "disable", "install", "initialize", "define", "extend", "extract", - ]; - - // Check if text starts with an action verb (followed by space or end) - for verb in &action_verbs { - if lower.starts_with(verb) { - // Check for word boundary after verb - let after = &lower[verb.len()..]; - if after.is_empty() || after.starts_with(' ') || after.starts_with('_') { - return true; - } - } - // Check if verb appears after space with word boundary - let pattern = format!(" {} ", verb); - let pattern_end = format!(" {}", verb); - if lower.contains(&pattern) { - return true; - } - // Check if verb is at the end of string after a space - if lower.ends_with(&pattern_end) && lower.len() > pattern_end.len() { - return true; - } - } - false -} - -/// Analyze a completed task's output to suggest next actions -pub fn analyze_task_output( - _task_id: Uuid, - task_name: &str, - task_result: Option<&str>, - task_diff: Option<&str>, - contract_phase: &str, - total_tasks: usize, - completed_tasks: usize, - next_task: Option<(Uuid, String)>, - dev_notes_file: Option<(Uuid, String)>, -) -> TaskOutputAnalysis { - let mut next_steps = Vec::new(); - let mut files_affected = Vec::new(); - - // Parse files from diff if available - if let Some(diff) = task_diff { - files_affected = extract_files_from_diff(diff); - } - - // Generate summary - let summary = if let Some(result) = task_result { - if result.len() > 200 { - format!("{}...", &result[..200]) - } else { - result.to_string() - } - } else { - format!("Task '{}' completed", task_name) - }; - - // If there's a next chained task, suggest running it - if let Some((next_id, next_name)) = next_task { - next_steps.push(SuggestedAction::RunNextTask { - task_id: next_id, - task_name: next_name, - }); - } - - // Suggest updating Dev Notes if in execute phase and file exists - if contract_phase == "execute" { - if let Some((file_id, file_name)) = dev_notes_file { - let additions = format!( - "\n## Task: {}\n\n{}\n\n### Files Modified\n{}\n", - task_name, - summary, - files_affected.iter() - .map(|f| format!("- {}", f)) - .collect::<Vec<_>>() - .join("\n") - ); - - next_steps.push(SuggestedAction::UpdateFile { - file_id, - file_name, - additions, - }); - } else { - // Suggest creating Dev Notes - next_steps.push(SuggestedAction::CreateFile { - template_id: "dev-notes".to_string(), - name: "Development Notes".to_string(), - seed_content: Some(format!( - "# Development Notes\n\n## Task: {}\n\n{}\n", - task_name, summary - )), - }); - } - } - - // Calculate phase impact - let new_completed = completed_tasks + 1; - let targets_met = new_completed >= total_tasks && total_tasks > 0; - let tasks_remaining = total_tasks.saturating_sub(new_completed); - - let transition_suggestion = if targets_met && contract_phase == "execute" { - Some("All tasks complete. Ready to advance to Review phase.".to_string()) - } else { - None - }; - - // If targets are met, suggest phase transition - if targets_met && contract_phase == "execute" { - next_steps.push(SuggestedAction::AdvancePhase { - to_phase: "review".to_string(), - }); - } - - let phase_impact = Some(PhaseImpact { - phase: contract_phase.to_string(), - targets_met, - tasks_remaining, - transition_suggestion, - }); - - TaskOutputAnalysis { - summary, - files_affected, - next_steps, - phase_impact, - } -} - -/// Extract file paths from a git diff -fn extract_files_from_diff(diff: &str) -> Vec<String> { - let mut files = Vec::new(); - let file_pattern = Regex::new(r"^(?:diff --git a/|[+]{3} b/|[-]{3} a/)(.+)$").unwrap(); - - for line in diff.lines() { - if let Some(caps) = file_pattern.captures(line) { - let path = caps[1].trim().to_string(); - // Skip /dev/null and duplicates - if path != "/dev/null" && !files.contains(&path) { - // Clean up path (remove a/ or b/ prefix from git diff) - let clean_path = path.trim_start_matches("a/").trim_start_matches("b/").to_string(); - if !files.contains(&clean_path) { - files.push(clean_path); - } - } - } - } - - files -} - -/// Format parsed tasks for display -pub fn format_parsed_tasks(result: &TaskParseResult) -> String { - let mut output = String::new(); - - if result.tasks.is_empty() { - output.push_str("No tasks found in the document.\n"); - for warning in &result.warnings { - output.push_str(&format!("Warning: {}\n", warning)); - } - return output; - } - - output.push_str(&format!("Found {} task(s)", result.total)); - if !result.groups.is_empty() { - output.push_str(&format!(" in {} group(s)", result.groups.len())); - } - output.push_str(":\n\n"); - - let mut current_group: Option<&str> = None; - for (i, task) in result.tasks.iter().enumerate() { - // Print group header if changed - if task.group.as_deref() != current_group { - current_group = task.group.as_deref(); - if let Some(group) = current_group { - output.push_str(&format!("**{}**\n", group)); - } - } - - let status = if task.completed { "[x]" } else { "[ ]" }; - output.push_str(&format!("{}. {} {}", i + 1, status, task.name)); - - if !task.dependencies.is_empty() { - output.push_str(&format!(" (depends on: {})", task.dependencies.join(", "))); - } - - output.push('\n'); - } - - output -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_checkbox_tasks() { - let content = r#" -## Phase 1: Setup -- [ ] Set up project structure -- [x] Configure dev environment - -## Phase 2: Features -1. [ ] Implement authentication -2. [ ] Add user dashboard -"#; - - let result = parse_tasks_from_breakdown(content); - assert_eq!(result.total, 4); - assert_eq!(result.groups.len(), 2); - assert!(!result.tasks[0].completed); - assert!(result.tasks[1].completed); - } - - #[test] - fn test_parse_with_dependencies() { - let content = r#" -- [ ] Task A -- [ ] Task B (depends on: Task A) -"#; - - let result = parse_tasks_from_breakdown(content); - assert_eq!(result.tasks[1].dependencies, vec!["Task A"]); - } - - #[test] - fn test_extract_files_from_diff() { - let diff = r#" -diff --git a/src/main.rs b/src/main.rs ---- a/src/main.rs -+++ b/src/main.rs -@@ -1,3 +1,4 @@ -+fn new_function() {} -"#; - - let files = extract_files_from_diff(diff); - assert!(files.contains(&"src/main.rs".to_string())); - } - - #[test] - fn test_looks_like_task() { - assert!(looks_like_task("Add authentication")); - assert!(looks_like_task("Create user model")); - assert!(looks_like_task("implement feature X")); - assert!(!looks_like_task("This is a note")); - assert!(!looks_like_task("Summary of changes")); - } -} diff --git a/makima/src/llm/templates.rs b/makima/src/llm/templates.rs deleted file mode 100644 index 48b7515..0000000 --- a/makima/src/llm/templates.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! Contract type template definitions. -//! -//! Defines the available contract types and their workflow phases. - -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -// ============================================================================= -// Contract Type Templates (Workflow Definitions) -// ============================================================================= - -/// A contract type template defining a workflow -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractTypeTemplate { - /// Unique identifier (e.g., 'simple', 'specification', 'feature-development') - pub id: String, - /// Display name - pub name: String, - /// What this contract type is for - pub description: String, - /// Ordered list of phases in the workflow - pub phases: Vec<String>, - /// Starting phase - pub default_phase: String, - /// True for built-in types ('simple', 'specification') - pub is_builtin: bool, -} - -/// Get all available contract type templates -pub fn all_contract_types() -> Vec<ContractTypeTemplate> { - vec![ - simple_contract_type(), - specification_contract_type(), - execute_contract_type(), - ] -} - -/// Simple contract type with basic plan/execute workflow -fn simple_contract_type() -> ContractTypeTemplate { - ContractTypeTemplate { - id: "simple".to_string(), - name: "Simple".to_string(), - description: "A basic workflow for straightforward tasks with planning and execution phases." - .to_string(), - phases: vec!["plan".to_string(), "execute".to_string()], - default_phase: "plan".to_string(), - is_builtin: true, - } -} - -/// Specification contract type with full research-to-review workflow -fn specification_contract_type() -> ContractTypeTemplate { - ContractTypeTemplate { - id: "specification".to_string(), - name: "Specification".to_string(), - description: "A comprehensive workflow for complex projects requiring research, specification, planning, execution, and review.".to_string(), - phases: vec![ - "research".to_string(), - "specify".to_string(), - "plan".to_string(), - "execute".to_string(), - "review".to_string(), - ], - default_phase: "research".to_string(), - is_builtin: true, - } -} - -/// Execute-only contract type for immediate task execution -fn execute_contract_type() -> ContractTypeTemplate { - ContractTypeTemplate { - id: "execute".to_string(), - name: "Execute".to_string(), - description: "A minimal workflow with only an execute phase for immediate task execution without planning documents.".to_string(), - phases: vec!["execute".to_string()], - default_phase: "execute".to_string(), - is_builtin: true, - } -} diff --git a/makima/src/llm/tools.rs b/makima/src/llm/tools.rs deleted file mode 100644 index c192398..0000000 --- a/makima/src/llm/tools.rs +++ /dev/null @@ -1,1675 +0,0 @@ -//! Tool definitions for file editing via LLM. - -use jaq_interpret::FilterT; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use crate::db::models::{BodyElement, ChartType, TranscriptEntry}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Tool { - pub name: String, - pub description: String, - pub parameters: serde_json::Value, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolCall { - pub id: String, - pub name: String, - pub arguments: serde_json::Value, -} - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct ToolResult { - pub success: bool, - pub message: String, -} - -/// Available tools for file editing -pub static AVAILABLE_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = - once_cell::sync::Lazy::new(|| { - vec![ - Tool { - name: "add_heading".to_string(), - description: "Add a heading element to the file body".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "level": { - "type": "integer", - "description": "Heading level (1-6)", - "minimum": 1, - "maximum": 6 - }, - "text": { - "type": "string", - "description": "The heading text" - }, - "position": { - "type": "integer", - "description": "Optional position to insert at (0-indexed). If not specified, appends to end." - } - }, - "required": ["level", "text"] - }), - }, - Tool { - name: "add_paragraph".to_string(), - description: "Add a paragraph element to the file body".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "The paragraph text" - }, - "position": { - "type": "integer", - "description": "Optional position to insert at (0-indexed). If not specified, appends to end." - } - }, - "required": ["text"] - }), - }, - Tool { - name: "add_code".to_string(), - description: "Add a code block element to the file body".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "The code content" - }, - "language": { - "type": "string", - "description": "Optional programming language for syntax highlighting (e.g., 'javascript', 'python', 'rust')" - }, - "position": { - "type": "integer", - "description": "Optional position to insert at (0-indexed). If not specified, appends to end." - } - }, - "required": ["content"] - }), - }, - Tool { - name: "add_list".to_string(), - description: "Add a list element (ordered or unordered) to the file body".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { "type": "string" }, - "description": "Array of list item strings" - }, - "ordered": { - "type": "boolean", - "description": "If true, creates a numbered list; if false (default), creates a bullet list" - }, - "position": { - "type": "integer", - "description": "Optional position to insert at (0-indexed). If not specified, appends to end." - } - }, - "required": ["items"] - }), - }, - Tool { - name: "add_chart".to_string(), - description: "Add a chart visualization to the file body. Supports line, bar, pie, and area charts.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "chart_type": { - "type": "string", - "enum": ["line", "bar", "pie", "area"], - "description": "Type of chart to create" - }, - "title": { - "type": "string", - "description": "Optional chart title" - }, - "data": { - "type": "array", - "description": "Array of data points. Each point should have a 'name' field and one or more numeric value fields.", - "items": { - "type": "object" - } - }, - "config": { - "type": "object", - "description": "Optional chart configuration (colors, axes, etc.)" - }, - "position": { - "type": "integer", - "description": "Optional position to insert at (0-indexed). If not specified, appends to end." - } - }, - "required": ["chart_type", "data"] - }), - }, - Tool { - name: "remove_element".to_string(), - description: "Remove an element from the file body by index".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "index": { - "type": "integer", - "description": "Index of element to remove (0-indexed)" - } - }, - "required": ["index"] - }), - }, - Tool { - name: "update_element".to_string(), - description: "Update an existing element in the file body. IMPORTANT: You must provide ALL required fields. For heading: type, level (1-6), text. For paragraph: type, text. For code: type, content, language (optional). For list: type, items (array of strings), ordered (boolean). For chart: type, chartType (line/bar/pie/area), data (array of objects).".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "index": { - "type": "integer", - "description": "Index of element to update (0-indexed)" - }, - "element_type": { - "type": "string", - "enum": ["heading", "paragraph", "code", "list", "chart"], - "description": "Type of element" - }, - "text": { - "type": "string", - "description": "Text content (required for heading and paragraph)" - }, - "level": { - "type": "integer", - "description": "Heading level 1-6 (required for heading)" - }, - "content": { - "type": "string", - "description": "Code content (required for code)" - }, - "language": { - "type": "string", - "description": "Programming language for syntax highlighting (optional for code)" - }, - "items": { - "type": "array", - "items": { "type": "string" }, - "description": "List items (required for list)" - }, - "ordered": { - "type": "boolean", - "description": "If true, numbered list; if false, bullet list (for list)" - }, - "chartType": { - "type": "string", - "enum": ["line", "bar", "pie", "area"], - "description": "Chart type (required for chart)" - }, - "data": { - "type": "array", - "description": "Chart data array (required for chart)", - "items": { "type": "object" } - }, - "title": { - "type": "string", - "description": "Chart title (optional for chart)" - } - }, - "required": ["index", "element_type"] - }), - }, - Tool { - name: "reorder_elements".to_string(), - description: "Move an element from one position to another".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "from_index": { - "type": "integer", - "description": "Current index of the element" - }, - "to_index": { - "type": "integer", - "description": "New index for the element" - } - }, - "required": ["from_index", "to_index"] - }), - }, - Tool { - name: "set_summary".to_string(), - description: "Set the file summary text".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "summary": { - "type": "string", - "description": "The summary text" - } - }, - "required": ["summary"] - }), - }, - Tool { - name: "parse_csv".to_string(), - description: "Parse CSV data into JSON format suitable for charts".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "csv": { - "type": "string", - "description": "CSV data string with header row" - } - }, - "required": ["csv"] - }), - }, - Tool { - name: "clear_body".to_string(), - description: "Clear all elements from the file body".to_string(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - Tool { - name: "jq".to_string(), - description: "Transform JSON data using jq expressions. Useful for filtering, mapping, grouping, and aggregating data before creating charts.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "input": { - "description": "The JSON data to transform (can be an array or object)" - }, - "filter": { - "type": "string", - "description": "The jq filter expression. Examples: '.[] | select(.value > 10)', 'group_by(.category) | map({name: .[0].category, count: length})', '[.[] | {name: .label, value: .amount}]'" - } - }, - "required": ["input", "filter"] - }), - }, - // Interactive tools - Tool { - name: "ask_user".to_string(), - description: "Ask the user one or more questions. Use this when you need clarification, want to offer choices, or need user input before proceeding. Questions can be single-select (user picks one option) or multi-select (user can pick multiple options). The question text supports markdown formatting. The conversation will pause until the user responds.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "questions": { - "type": "array", - "description": "List of questions to ask the user", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for this question (e.g., 'chart_type', 'color_scheme')" - }, - "question": { - "type": "string", - "description": "The question to ask the user. Supports markdown formatting (bold, code, lists, etc.)" - }, - "options": { - "type": "array", - "items": { "type": "string" }, - "description": "Multiple choice options for the user to select from" - }, - "allowMultiple": { - "type": "boolean", - "description": "If true, user can select multiple options (multi-select). If false or omitted, user selects exactly one option (single-select). Default: false" - }, - "allowCustom": { - "type": "boolean", - "description": "If true, user can provide a custom text answer instead of selecting from options. Default: true" - } - }, - "required": ["id", "question", "options"] - } - } - }, - "required": ["questions"] - }), - }, - // Content viewing tools - Tool { - name: "view_body".to_string(), - description: "View the complete body structure with full content of all elements. Returns detailed information about each element including type, index, and full text/data.".to_string(), - parameters: json!({ - "type": "object", - "properties": {}, - "required": [] - }), - }, - Tool { - name: "read_element".to_string(), - description: "Read the full content of a specific body element by its index. Use this to get complete details of a single element.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "index": { - "type": "integer", - "description": "Index of the element to read (0-indexed)" - } - }, - "required": ["index"] - }), - }, - Tool { - name: "view_transcript".to_string(), - description: "View the complete transcript of the file. Returns all transcript entries with speaker names, text, and timestamps.".to_string(), - parameters: json!({ - "type": "object", - "properties": {}, - "required": [] - }), - }, - // Version history tools - Tool { - name: "list_versions".to_string(), - description: "List all available versions of the current document. Returns version numbers, sources (user/llm/system), timestamps, and change descriptions.".to_string(), - parameters: json!({ - "type": "object", - "properties": {}, - "required": [] - }), - }, - Tool { - name: "read_version".to_string(), - description: "Read the content of a specific historical version of the document. This is read-only and does not modify the current document.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "version": { - "type": "integer", - "description": "The version number to read" - } - }, - "required": ["version"] - }), - }, - Tool { - name: "restore_version".to_string(), - description: "Restore the document to a previous version. This creates a new version with the content from the target version. The current content will be preserved as a historical version.".to_string(), - parameters: json!({ - "type": "object", - "properties": { - "target_version": { - "type": "integer", - "description": "The version number to restore to" - }, - "reason": { - "type": "string", - "description": "Optional reason for the restore (will be recorded in change description)" - } - }, - "required": ["target_version"] - }), - }, - ] - }); - -/// Request for version-related operations that require async database access -#[derive(Debug, Clone)] -pub enum VersionToolRequest { - /// List all versions of the current file - ListVersions, - /// Read a specific version - ReadVersion { version: i32 }, - /// Restore to a specific version - RestoreVersion { target_version: i32, reason: Option<String> }, -} - -/// A question to ask the user -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct UserQuestion { - /// Unique identifier for this question - pub id: String, - /// The question text - pub question: String, - /// Multiple choice options - pub options: Vec<String>, - /// Whether multiple options can be selected - #[serde(default)] - pub allow_multiple: bool, - /// Whether a custom answer is allowed - #[serde(default = "default_allow_custom")] - pub allow_custom: bool, -} - -fn default_allow_custom() -> bool { - true -} - -/// User's answer to a question -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct UserAnswer { - /// Question ID this answers - pub id: String, - /// Selected option(s) or custom answer - pub answers: Vec<String>, -} - -/// Result of executing a tool call with modified file state -#[derive(Debug)] -pub struct ToolExecutionResult { - pub result: ToolResult, - pub new_body: Option<Vec<BodyElement>>, - pub new_summary: Option<String>, - pub parsed_data: Option<serde_json::Value>, - /// Request for async version operations (handled by chat handler) - pub version_request: Option<VersionToolRequest>, - /// Questions to ask the user (pauses conversation until answered) - pub pending_questions: Option<Vec<UserQuestion>>, -} - -/// Execute a tool call and return the result along with any state changes -pub fn execute_tool_call( - call: &ToolCall, - current_body: &[BodyElement], - current_summary: Option<&str>, - transcript: &[TranscriptEntry], -) -> ToolExecutionResult { - match call.name.as_str() { - "add_heading" => execute_add_heading(call, current_body), - "add_paragraph" => execute_add_paragraph(call, current_body), - "add_code" => execute_add_code(call, current_body), - "add_list" => execute_add_list(call, current_body), - "add_chart" => execute_add_chart(call, current_body), - "remove_element" => execute_remove_element(call, current_body), - "update_element" => execute_update_element(call, current_body), - "reorder_elements" => execute_reorder_elements(call, current_body), - "set_summary" => execute_set_summary(call, current_summary), - "parse_csv" => execute_parse_csv(call), - "clear_body" => execute_clear_body(), - "jq" => execute_jq(call), - // Interactive tools - "ask_user" => execute_ask_user(call), - // Content viewing tools - "view_body" => execute_view_body(current_body), - "read_element" => execute_read_element(call, current_body), - "view_transcript" => execute_view_transcript(transcript), - // Version history tools - return request for async handling - "list_versions" => execute_list_versions(), - "read_version" => execute_read_version(call), - "restore_version" => execute_restore_version(call), - _ => ToolExecutionResult { - result: ToolResult { - success: false, - message: format!("Unknown tool: {}", call.name), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }, - } -} - -fn execute_ask_user(call: &ToolCall) -> ToolExecutionResult { - let questions_value = call.arguments.get("questions"); - - let Some(questions_array) = questions_value.and_then(|v| v.as_array()) else { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: "Missing or invalid 'questions' parameter".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - }; - - let mut questions: Vec<UserQuestion> = Vec::new(); - - for q in questions_array { - let id = q.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let question = q.get("question").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let options: Vec<String> = q - .get("options") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|o| o.as_str()) - .map(|s| s.to_string()) - .collect() - }) - .unwrap_or_default(); - let allow_multiple = q.get("allowMultiple").and_then(|v| v.as_bool()).unwrap_or(false); - let allow_custom = q.get("allowCustom").and_then(|v| v.as_bool()).unwrap_or(true); - - if id.is_empty() || question.is_empty() || options.is_empty() { - continue; - } - - questions.push(UserQuestion { - id, - question, - options, - allow_multiple, - allow_custom, - }); - } - - if questions.is_empty() { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: "No valid questions provided".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - } - - let question_count = questions.len(); - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!("Asking user {} question(s). Waiting for response...", question_count), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: Some(questions), - } -} - -fn execute_add_heading(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { - let level = call.arguments.get("level").and_then(|v| v.as_u64()).unwrap_or(1) as u8; - let text = call - .arguments - .get("text") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let position = call.arguments.get("position").and_then(|v| v.as_u64()); - - let element = BodyElement::Heading { level, text: text.clone() }; - let mut new_body = current_body.to_vec(); - - if let Some(pos) = position { - let pos = pos as usize; - if pos <= new_body.len() { - new_body.insert(pos, element); - } else { - new_body.push(element); - } - } else { - new_body.push(element); - } - - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!("Added heading: {}", text), - }, - new_body: Some(new_body), - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - } -} - -fn execute_add_paragraph(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { - let text = call - .arguments - .get("text") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let position = call.arguments.get("position").and_then(|v| v.as_u64()); - - let element = BodyElement::Paragraph { text: text.clone() }; - let mut new_body = current_body.to_vec(); - - if let Some(pos) = position { - let pos = pos as usize; - if pos <= new_body.len() { - new_body.insert(pos, element); - } else { - new_body.push(element); - } - } else { - new_body.push(element); - } - - let preview = if text.len() > 50 { - format!("{}...", &text[..50]) - } else { - text - }; - - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!("Added paragraph: {}", preview), - }, - new_body: Some(new_body), - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - } -} - -fn execute_add_code(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { - let language = call - .arguments - .get("language") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let content = call - .arguments - .get("content") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let position = call.arguments.get("position").and_then(|v| v.as_u64()); - - let element = BodyElement::Code { - language: language.clone(), - content: content.clone(), - }; - let mut new_body = current_body.to_vec(); - - if let Some(pos) = position { - let pos = pos as usize; - if pos <= new_body.len() { - new_body.insert(pos, element); - } else { - new_body.push(element); - } - } else { - new_body.push(element); - } - - let lang_str = language.as_deref().unwrap_or("plain"); - let preview: String = content.chars().take(50).collect(); - - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!("Added code block ({}): {}", lang_str, preview), - }, - new_body: Some(new_body), - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - } -} - -fn execute_add_list(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { - let ordered = call - .arguments - .get("ordered") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let items: Vec<String> = call - .arguments - .get("items") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default(); - let position = call.arguments.get("position").and_then(|v| v.as_u64()); - - let element = BodyElement::List { - ordered, - items: items.clone(), - }; - let mut new_body = current_body.to_vec(); - - if let Some(pos) = position { - let pos = pos as usize; - if pos <= new_body.len() { - new_body.insert(pos, element); - } else { - new_body.push(element); - } - } else { - new_body.push(element); - } - - let list_type = if ordered { "ordered" } else { "unordered" }; - - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!("Added {} list with {} items", list_type, items.len()), - }, - new_body: Some(new_body), - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - } -} - -fn execute_add_chart(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { - let chart_type_str = call - .arguments - .get("chart_type") - .and_then(|v| v.as_str()) - .unwrap_or("bar"); - - let chart_type = match chart_type_str { - "line" => ChartType::Line, - "bar" => ChartType::Bar, - "pie" => ChartType::Pie, - "area" => ChartType::Area, - _ => ChartType::Bar, - }; - - let title = call - .arguments - .get("title") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let data = call - .arguments - .get("data") - .cloned() - .unwrap_or(json!([])); - - let config = call.arguments.get("config").cloned(); - let position = call.arguments.get("position").and_then(|v| v.as_u64()); - - let element = BodyElement::Chart { - chart_type, - title: title.clone(), - data, - config, - }; - - let mut new_body = current_body.to_vec(); - - if let Some(pos) = position { - let pos = pos as usize; - if pos <= new_body.len() { - new_body.insert(pos, element); - } else { - new_body.push(element); - } - } else { - new_body.push(element); - } - - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!( - "Added {} chart{}", - chart_type_str, - title.map(|t| format!(": {}", t)).unwrap_or_default() - ), - }, - new_body: Some(new_body), - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - } -} - -fn execute_remove_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { - let index = call.arguments.get("index").and_then(|v| v.as_u64()); - - let Some(index) = index else { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: "Missing index parameter".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - }; - - let index = index as usize; - if index >= current_body.len() { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: format!("Index {} out of bounds (body has {} elements)", index, current_body.len()), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - } - - let mut new_body = current_body.to_vec(); - new_body.remove(index); - - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!("Removed element at index {}", index), - }, - new_body: Some(new_body), - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - } -} - -fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { - let index = call.arguments.get("index").and_then(|v| v.as_u64()); - let element_type = call.arguments.get("element_type").and_then(|v| v.as_str()); - - let Some(index) = index else { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: "Missing index parameter".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - }; - - let Some(element_type) = element_type else { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: "Missing element_type parameter".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - }; - - let index = index as usize; - if index >= current_body.len() { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: format!("Index {} out of bounds (body has {} elements)", index, current_body.len()), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - } - - // Build the element based on type - let new_element = match element_type { - "heading" => { - let level = call.arguments.get("level").and_then(|v| v.as_u64()).unwrap_or(1) as u8; - let text = call.arguments.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(); - BodyElement::Heading { level, text } - } - "paragraph" => { - let text = call.arguments.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(); - BodyElement::Paragraph { text } - } - "code" => { - let language = call.arguments.get("language").and_then(|v| v.as_str()).map(|s| s.to_string()); - let content = call.arguments.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(); - BodyElement::Code { language, content } - } - "list" => { - let ordered = call.arguments.get("ordered").and_then(|v| v.as_bool()).unwrap_or(false); - let items = call.arguments.get("items") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) - .unwrap_or_default(); - BodyElement::List { ordered, items } - } - "chart" => { - let chart_type_str = call.arguments.get("chartType").and_then(|v| v.as_str()).unwrap_or("bar"); - let chart_type = match chart_type_str { - "line" => ChartType::Line, - "bar" => ChartType::Bar, - "pie" => ChartType::Pie, - "area" => ChartType::Area, - _ => ChartType::Bar, - }; - let title = call.arguments.get("title").and_then(|v| v.as_str()).map(|s| s.to_string()); - let data = call.arguments.get("data").cloned().unwrap_or(json!([])); - let config = call.arguments.get("config").cloned(); - BodyElement::Chart { chart_type, title, data, config } - } - _ => { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: format!("Unknown element_type: {}. Must be heading, paragraph, code, list, or chart.", element_type), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - } - }; - - let mut new_body = current_body.to_vec(); - new_body[index] = new_element; - - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!("Updated element at index {} to {}", index, element_type), - }, - new_body: Some(new_body), - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - } -} - -fn execute_reorder_elements(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { - let from_index = call.arguments.get("from_index").and_then(|v| v.as_u64()); - let to_index = call.arguments.get("to_index").and_then(|v| v.as_u64()); - - let (Some(from), Some(to)) = (from_index, to_index) else { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: "Missing from_index or to_index parameter".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - }; - - let from = from as usize; - let to = to as usize; - - if from >= current_body.len() || to >= current_body.len() { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: format!( - "Index out of bounds: from={}, to={}, body has {} elements", - from, to, current_body.len() - ), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - } - - let mut new_body = current_body.to_vec(); - let element = new_body.remove(from); - new_body.insert(to, element); - - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!("Moved element from index {} to {}", from, to), - }, - new_body: Some(new_body), - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - } -} - -fn execute_set_summary(call: &ToolCall, _current_summary: Option<&str>) -> ToolExecutionResult { - let summary = call - .arguments - .get("summary") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - ToolExecutionResult { - result: ToolResult { - success: true, - message: "Summary updated".to_string(), - }, - new_body: None, - new_summary: Some(summary), - parsed_data: None, - version_request: None, - pending_questions: None, - } -} - -fn execute_parse_csv(call: &ToolCall) -> ToolExecutionResult { - let csv = call - .arguments - .get("csv") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let lines: Vec<&str> = csv.lines().collect(); - if lines.is_empty() { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: "Empty CSV data".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - } - - let headers: Vec<&str> = lines[0].split(',').map(|s| s.trim()).collect(); - let mut data: Vec<serde_json::Value> = Vec::new(); - - for line in lines.iter().skip(1) { - if line.trim().is_empty() { - continue; - } - let values: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); - let mut row = serde_json::Map::new(); - - for (i, header) in headers.iter().enumerate() { - if let Some(value) = values.get(i) { - // Try to parse as number, otherwise use string - if let Ok(num) = value.parse::<f64>() { - row.insert(header.to_string(), json!(num)); - } else { - row.insert(header.to_string(), json!(value)); - } - } - } - - data.push(serde_json::Value::Object(row)); - } - - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!("Parsed {} rows from CSV", data.len()), - }, - new_body: None, - new_summary: None, - parsed_data: Some(json!(data)), - version_request: None, - pending_questions: None, - } -} - -fn execute_clear_body() -> ToolExecutionResult { - ToolExecutionResult { - result: ToolResult { - success: true, - message: "Cleared all body elements".to_string(), - }, - new_body: Some(vec![]), - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - } -} - -fn execute_jq(call: &ToolCall) -> ToolExecutionResult { - let input = match call.arguments.get("input") { - Some(v) => v.clone(), - None => { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: "Missing input parameter".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - } - }; - - let filter = match call.arguments.get("filter").and_then(|v| v.as_str()) { - Some(f) => f, - None => { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: "Missing filter parameter".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - } - }; - - // Parse the jq filter - let mut defs = jaq_interpret::ParseCtx::new(Vec::new()); - defs.insert_natives(jaq_core::core()); - defs.insert_defs(jaq_std::std()); - - let (parsed_filter, errs) = jaq_parse::parse(filter, jaq_parse::main()); - if !errs.is_empty() { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: format!("Invalid jq filter: {:?}", errs), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - } - - let Some(parsed_filter) = parsed_filter else { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: "Failed to parse jq filter".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - }; - - // Compile the filter - let compiled = defs.compile(parsed_filter); - if !defs.errs.is_empty() { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: format!("Failed to compile jq filter ({} errors)", defs.errs.len()), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - } - - // Convert serde_json::Value to jaq Value - let jaq_input = json_to_jaq(&input); - - // Execute the filter - let inputs = jaq_interpret::RcIter::new(std::iter::empty()); - let mut results: Vec<serde_json::Value> = Vec::new(); - - for output in compiled.run((jaq_interpret::Ctx::new([], &inputs), jaq_input)) { - match output { - Ok(val) => { - results.push(jaq_to_json(&val)); - } - Err(e) => { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: format!("jq execution error: {:?}", e), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - } - } - } - - // Return single value or array based on results - let output = if results.len() == 1 { - results.into_iter().next().unwrap() - } else { - json!(results) - }; - - let preview = { - let s = output.to_string(); - if s.len() > 100 { - format!("{}...", &s[..100]) - } else { - s - } - }; - - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!("jq transform complete: {}", preview), - }, - new_body: None, - new_summary: None, - parsed_data: Some(output), - version_request: None, - pending_questions: None, - } -} - -// ============================================================================= -// Content Viewing Tool Execution Functions -// ============================================================================= - -fn execute_view_body(current_body: &[BodyElement]) -> ToolExecutionResult { - if current_body.is_empty() { - return ToolExecutionResult { - result: ToolResult { - success: true, - message: "Body is empty (no elements)".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: Some(json!([])), - version_request: None, - pending_questions: None, - }; - } - - let elements: Vec<serde_json::Value> = current_body - .iter() - .enumerate() - .map(|(i, element)| { - match element { - BodyElement::Heading { level, text } => json!({ - "index": i, - "type": "heading", - "level": level, - "text": text - }), - BodyElement::Paragraph { text } => json!({ - "index": i, - "type": "paragraph", - "text": text - }), - BodyElement::Code { language, content } => json!({ - "index": i, - "type": "code", - "language": language, - "content": content - }), - BodyElement::List { ordered, items } => json!({ - "index": i, - "type": "list", - "ordered": ordered, - "items": items - }), - BodyElement::Chart { chart_type, title, data, config } => json!({ - "index": i, - "type": "chart", - "chartType": format!("{:?}", chart_type).to_lowercase(), - "title": title, - "data": data, - "config": config - }), - BodyElement::Image { src, alt, caption } => json!({ - "index": i, - "type": "image", - "src": src, - "alt": alt, - "caption": caption - }), - BodyElement::Markdown { content } => json!({ - "index": i, - "type": "markdown", - "content": content - }), - } - }) - .collect(); - - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!("Body contains {} element(s)", current_body.len()), - }, - new_body: None, - new_summary: None, - parsed_data: Some(json!(elements)), - version_request: None, - pending_questions: None, - } -} - -fn execute_read_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult { - let index = call.arguments.get("index").and_then(|v| v.as_u64()); - - let Some(index) = index else { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: "Missing index parameter".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - }; - - let index = index as usize; - if index >= current_body.len() { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: format!("Index {} out of bounds (body has {} elements)", index, current_body.len()), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - } - - let element = ¤t_body[index]; - let element_data = match element { - BodyElement::Heading { level, text } => json!({ - "index": index, - "type": "heading", - "level": level, - "text": text - }), - BodyElement::Paragraph { text } => json!({ - "index": index, - "type": "paragraph", - "text": text - }), - BodyElement::Code { language, content } => json!({ - "index": index, - "type": "code", - "language": language, - "content": content - }), - BodyElement::List { ordered, items } => json!({ - "index": index, - "type": "list", - "ordered": ordered, - "items": items - }), - BodyElement::Chart { chart_type, title, data, config } => json!({ - "index": index, - "type": "chart", - "chartType": format!("{:?}", chart_type).to_lowercase(), - "title": title, - "data": data, - "config": config - }), - BodyElement::Image { src, alt, caption } => json!({ - "index": index, - "type": "image", - "src": src, - "alt": alt, - "caption": caption - }), - BodyElement::Markdown { content } => json!({ - "index": index, - "type": "markdown", - "content": content - }), - }; - - let type_str = match element { - BodyElement::Heading { .. } => "heading", - BodyElement::Paragraph { .. } => "paragraph", - BodyElement::Code { .. } => "code", - BodyElement::List { .. } => "list", - BodyElement::Chart { .. } => "chart", - BodyElement::Image { .. } => "image", - BodyElement::Markdown { .. } => "markdown", - }; - - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!("Element {} is a {}", index, type_str), - }, - new_body: None, - new_summary: None, - parsed_data: Some(element_data), - version_request: None, - pending_questions: None, - } -} - -fn execute_view_transcript(transcript: &[TranscriptEntry]) -> ToolExecutionResult { - if transcript.is_empty() { - return ToolExecutionResult { - result: ToolResult { - success: true, - message: "Transcript is empty".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: Some(json!([])), - version_request: None, - pending_questions: None, - }; - } - - let entries: Vec<serde_json::Value> = transcript - .iter() - .enumerate() - .map(|(i, entry)| { - json!({ - "index": i, - "speaker": entry.speaker, - "text": entry.text, - "start": entry.start, - "end": entry.end - }) - }) - .collect(); - - // Calculate duration from timestamps - let duration_info = if let (Some(first), Some(last)) = (transcript.first(), transcript.last()) { - let duration_secs = last.end - first.start; - let minutes = (duration_secs / 60.0).floor() as u32; - let seconds = (duration_secs % 60.0).round() as u32; - format!(" (duration: {}:{:02})", minutes, seconds) - } else { - String::new() - }; - - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!("Transcript has {} entries{}", transcript.len(), duration_info), - }, - new_body: None, - new_summary: None, - parsed_data: Some(json!(entries)), - version_request: None, - pending_questions: None, - } -} - -// ============================================================================= -// Version History Tool Execution Functions -// ============================================================================= -// These return version_request instead of performing the operation directly, -// because they require async database access which is handled in the chat handler. - -fn execute_list_versions() -> ToolExecutionResult { - ToolExecutionResult { - result: ToolResult { - success: true, - message: "Listing versions...".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: Some(VersionToolRequest::ListVersions), - pending_questions: None, - } -} - -fn execute_read_version(call: &ToolCall) -> ToolExecutionResult { - let version = call.arguments.get("version").and_then(|v| v.as_i64()); - - let Some(version) = version else { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: "Missing version parameter".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - }; - - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!("Reading version {}...", version), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: Some(VersionToolRequest::ReadVersion { version: version as i32 }), - pending_questions: None, - } -} - -fn execute_restore_version(call: &ToolCall) -> ToolExecutionResult { - let target_version = call.arguments.get("target_version").and_then(|v| v.as_i64()); - let reason = call - .arguments - .get("reason") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - let Some(target_version) = target_version else { - return ToolExecutionResult { - result: ToolResult { - success: false, - message: "Missing target_version parameter".to_string(), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: None, - pending_questions: None, - }; - }; - - ToolExecutionResult { - result: ToolResult { - success: true, - message: format!("Restoring to version {}...", target_version), - }, - new_body: None, - new_summary: None, - parsed_data: None, - version_request: Some(VersionToolRequest::RestoreVersion { - target_version: target_version as i32, - reason, - }), - pending_questions: None, - } -} - -/// Convert serde_json::Value to jaq_interpret::Val -fn json_to_jaq(value: &serde_json::Value) -> jaq_interpret::Val { - match value { - serde_json::Value::Null => jaq_interpret::Val::Null, - serde_json::Value::Bool(b) => jaq_interpret::Val::Bool(*b), - serde_json::Value::Number(n) => { - if let Some(i) = n.as_i64() { - jaq_interpret::Val::Int(i as isize) - } else if let Some(f) = n.as_f64() { - jaq_interpret::Val::Float(f) - } else { - jaq_interpret::Val::Null - } - } - serde_json::Value::String(s) => jaq_interpret::Val::Str(s.clone().into()), - serde_json::Value::Array(arr) => { - jaq_interpret::Val::Arr(std::rc::Rc::new(arr.iter().map(json_to_jaq).collect())) - } - serde_json::Value::Object(obj) => { - let mut map: indexmap::IndexMap<std::rc::Rc<String>, jaq_interpret::Val, ahash::RandomState> = - indexmap::IndexMap::with_hasher(ahash::RandomState::new()); - for (k, v) in obj { - map.insert(std::rc::Rc::new(k.clone()), json_to_jaq(v)); - } - jaq_interpret::Val::Obj(std::rc::Rc::new(map)) - } - } -} - -/// Convert jaq_interpret::Val to serde_json::Value -fn jaq_to_json(value: &jaq_interpret::Val) -> serde_json::Value { - match value { - jaq_interpret::Val::Null => serde_json::Value::Null, - jaq_interpret::Val::Bool(b) => json!(*b), - jaq_interpret::Val::Int(i) => json!(*i), - jaq_interpret::Val::Float(f) => json!(*f), - jaq_interpret::Val::Num(n) => { - // Try to parse the number string - if let Ok(i) = n.parse::<i64>() { - json!(i) - } else if let Ok(f) = n.parse::<f64>() { - json!(f) - } else { - json!(n.as_ref()) - } - } - jaq_interpret::Val::Str(s) => json!(s.as_ref()), - jaq_interpret::Val::Arr(arr) => { - json!(arr.iter().map(jaq_to_json).collect::<Vec<_>>()) - } - jaq_interpret::Val::Obj(obj) => { - let mut map = serde_json::Map::new(); - for (k, v) in obj.iter() { - map.insert((**k).clone(), jaq_to_json(v)); - } - serde_json::Value::Object(map) - } - } -} diff --git a/makima/src/llm/transcript_analyzer.rs b/makima/src/llm/transcript_analyzer.rs deleted file mode 100644 index 82aa69d..0000000 --- a/makima/src/llm/transcript_analyzer.rs +++ /dev/null @@ -1,292 +0,0 @@ -//! Transcript analyzer for extracting requirements, decisions, and action items. - -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use crate::db::models::TranscriptEntry; - -/// An extracted requirement from the transcript -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ExtractedRequirement { - pub text: String, - pub speaker: String, - pub timestamp: f32, - pub confidence: f32, - pub category: Option<String>, // functional, technical, non-functional, business -} - -/// An extracted decision from the transcript -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ExtractedDecision { - pub text: String, - pub speaker: String, - pub timestamp: f32, - pub confidence: f32, - pub context: Option<String>, -} - -/// An extracted action item from the transcript -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ExtractedActionItem { - pub text: String, - pub speaker: String, - pub timestamp: f32, - pub assignee: Option<String>, - pub priority: Option<String>, -} - -/// Result of transcript analysis -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct TranscriptAnalysisResult { - pub requirements: Vec<ExtractedRequirement>, - pub decisions: Vec<ExtractedDecision>, - pub action_items: Vec<ExtractedActionItem>, - pub key_topics: Vec<String>, - pub suggested_contract_name: Option<String>, - pub suggested_description: Option<String>, - pub speaker_summary: Vec<SpeakerStats>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct SpeakerStats { - pub speaker: String, - pub word_count: usize, - pub speaking_time_seconds: f32, - pub contribution_percentage: f32, -} - -/// Format transcript entries into readable text for LLM analysis -pub fn format_transcript_for_analysis(entries: &[TranscriptEntry]) -> String { - entries - .iter() - .map(|e| format!("[{:.1}s] {}: {}", e.start, e.speaker, e.text)) - .collect::<Vec<_>>() - .join("\n") -} - -/// Calculate speaker statistics from transcript -pub fn calculate_speaker_stats(entries: &[TranscriptEntry]) -> Vec<SpeakerStats> { - use std::collections::HashMap; - - let mut stats: HashMap<String, (usize, f32)> = HashMap::new(); - - for entry in entries { - let word_count = entry.text.split_whitespace().count(); - let duration = entry.end - entry.start; - - let (count, time) = stats.entry(entry.speaker.clone()).or_insert((0, 0.0)); - *count += word_count; - *time += duration; - } - - let total_words: usize = stats.values().map(|(c, _)| c).sum(); - let total_time: f32 = stats.values().map(|(_, t)| t).sum(); - - // Suppress unused variable warning - let _ = total_time; - - stats - .into_iter() - .map(|(speaker, (word_count, speaking_time))| SpeakerStats { - speaker, - word_count, - speaking_time_seconds: speaking_time, - contribution_percentage: if total_words > 0 { - (word_count as f32 / total_words as f32) * 100.0 - } else { - 0.0 - }, - }) - .collect() -} - -/// Build the analysis prompt for the LLM -pub fn build_analysis_prompt(transcript_text: &str) -> String { - format!(r#"Analyze this meeting/conversation transcript and extract structured information. - -TRANSCRIPT: -{} - -Extract the following information in JSON format: - -1. **Requirements**: Statements where someone expresses a need, want, or must-have. Look for phrases like: - - "we need to...", "it should...", "must have...", "requirement is..." - - "the system should...", "users need to be able to..." - -2. **Decisions**: Explicit decisions made during the conversation. Look for: - - "let's go with...", "we decided...", "we'll use...", "agreed to..." - - "the decision is...", "we're going with..." - -3. **Action Items**: Tasks or todos mentioned. Look for: - - "someone needs to...", "we should...", "next step is..." - - "I'll do...", "can you...", "TODO:..." - -4. **Key Topics**: Main subjects discussed - -5. **Suggested Contract Name**: A short name (3-5 words) that captures the main goal - -6. **Suggested Description**: A 1-2 sentence description of what should be built/done - -Return your analysis as JSON with this structure: -{{ - "requirements": [ - {{"text": "...", "speaker": "Speaker X", "timestamp": 12.5, "confidence": 0.9, "category": "functional"}} - ], - "decisions": [ - {{"text": "...", "speaker": "Speaker X", "timestamp": 45.2, "confidence": 0.85, "context": "..."}} - ], - "action_items": [ - {{"text": "...", "speaker": "Speaker X", "timestamp": 78.0, "assignee": null, "priority": "high"}} - ], - "key_topics": ["topic1", "topic2"], - "suggested_contract_name": "...", - "suggested_description": "..." -}} - -Be conservative - only extract items with high confidence. If nothing is found for a category, return an empty array."#, transcript_text) -} - -/// Parse LLM response into analysis result -pub fn parse_analysis_response(response: &str, speaker_stats: Vec<SpeakerStats>) -> Result<TranscriptAnalysisResult, String> { - // Try to extract JSON from the response (it might be wrapped in markdown code blocks) - let json_str = extract_json_from_response(response)?; - - #[derive(Deserialize)] - struct LlmResponse { - requirements: Option<Vec<ExtractedRequirement>>, - decisions: Option<Vec<ExtractedDecision>>, - action_items: Option<Vec<ExtractedActionItem>>, - key_topics: Option<Vec<String>>, - suggested_contract_name: Option<String>, - suggested_description: Option<String>, - } - - let parsed: LlmResponse = serde_json::from_str(&json_str) - .map_err(|e| format!("Failed to parse LLM response as JSON: {}", e))?; - - Ok(TranscriptAnalysisResult { - requirements: parsed.requirements.unwrap_or_default(), - decisions: parsed.decisions.unwrap_or_default(), - action_items: parsed.action_items.unwrap_or_default(), - key_topics: parsed.key_topics.unwrap_or_default(), - suggested_contract_name: parsed.suggested_contract_name, - suggested_description: parsed.suggested_description, - speaker_summary: speaker_stats, - }) -} - -/// Extract JSON from LLM response (handles markdown code blocks) -fn extract_json_from_response(response: &str) -> Result<String, String> { - // Try to find JSON in code blocks first - if let Some(start) = response.find("```json") { - if let Some(end) = response[start..].find("```\n").or_else(|| response[start..].rfind("```")) { - let json_start = start + 7; // Skip "```json" - let json_end = start + end; - if json_end > json_start { - return Ok(response[json_start..json_end].trim().to_string()); - } - } - } - - // Try plain code blocks - if let Some(start) = response.find("```") { - let after_start = start + 3; - if let Some(end) = response[after_start..].find("```") { - let json_str = &response[after_start..after_start + end]; - // Skip language identifier if present - let json_str = if let Some(newline) = json_str.find('\n') { - &json_str[newline + 1..] - } else { - json_str - }; - return Ok(json_str.trim().to_string()); - } - } - - // Try to find raw JSON (starts with { or [) - if let Some(start) = response.find('{') { - if let Some(end) = response.rfind('}') { - if end > start { - return Ok(response[start..=end].to_string()); - } - } - } - - Err("Could not find JSON in LLM response".to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_format_transcript() { - let entries = vec![ - TranscriptEntry { - id: "1".to_string(), - speaker: "Speaker 0".to_string(), - start: 0.0, - end: 2.5, - text: "Hello world".to_string(), - is_final: true, - }, - ]; - - let formatted = format_transcript_for_analysis(&entries); - assert!(formatted.contains("[0.0s] Speaker 0: Hello world")); - } - - #[test] - fn test_speaker_stats() { - let entries = vec![ - TranscriptEntry { - id: "1".to_string(), - speaker: "Speaker 0".to_string(), - start: 0.0, - end: 5.0, - text: "One two three four five".to_string(), - is_final: true, - }, - TranscriptEntry { - id: "2".to_string(), - speaker: "Speaker 1".to_string(), - start: 5.0, - end: 10.0, - text: "Six seven eight nine ten".to_string(), - is_final: true, - }, - ]; - - let stats = calculate_speaker_stats(&entries); - assert_eq!(stats.len(), 2); - - for s in &stats { - assert_eq!(s.word_count, 5); - assert_eq!(s.speaking_time_seconds, 5.0); - assert!((s.contribution_percentage - 50.0).abs() < 0.1); - } - } - - #[test] - fn test_extract_json_from_response() { - let response = r#"Here is the analysis: -```json -{"key": "value"} -``` -Done."#; - - let json = extract_json_from_response(response).unwrap(); - assert_eq!(json, r#"{"key": "value"}"#); - } - - #[test] - fn test_extract_raw_json() { - let response = r#"Analysis: {"key": "value"}"#; - let json = extract_json_from_response(response).unwrap(); - assert_eq!(json, r#"{"key": "value"}"#); - } -} diff --git a/makima/src/server/handlers/chat.rs b/makima/src/server/handlers/chat.rs deleted file mode 100644 index 9d8cd19..0000000 --- a/makima/src/server/handlers/chat.rs +++ /dev/null @@ -1,1210 +0,0 @@ -//! Chat endpoint for LLM-powered file editing. - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{models::BodyElement, repository::{self, RepositoryError}}; -use crate::llm::{ - claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, - execute_tool_call, - groq::{GroqClient, GroqError, Message, ToolCallResponse}, - LlmModel, ToolCall, ToolResult, UserQuestion, VersionToolRequest, AVAILABLE_TOOLS, -}; -use crate::server::state::{FileUpdateNotification, SharedState}; - -/// Maximum number of tool-calling rounds to prevent infinite loops -const MAX_TOOL_ROUNDS: usize = 20; - -/// Context limits for different models (in tokens) -/// Claude models have 200K context, Groq models vary -const CLAUDE_CONTEXT_LIMIT: usize = 200_000; -const GROQ_CONTEXT_LIMIT: usize = 32_000; - -/// Threshold for triggering context compaction (90% of limit) -const CONTEXT_COMPACTION_THRESHOLD: f32 = 0.90; - -/// Approximate characters per token (rough estimate for English text) -const CHARS_PER_TOKEN: usize = 4; - -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChatHistoryMessage { - /// Role: "user" or "assistant" - pub role: String, - /// Message content - pub content: String, -} - -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChatRequest { - /// The user's message/instruction - pub message: String, - /// Optional model selection: "claude-sonnet" (default), "claude-opus", or "groq" - #[serde(default)] - pub model: Option<String>, - /// Optional conversation history for context continuity - #[serde(default)] - pub history: Option<Vec<ChatHistoryMessage>>, - /// Optional focused element index (for targeted editing) - #[serde(default)] - pub focused_element_index: Option<usize>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChatResponse { - /// The LLM's response message - pub response: String, - /// Tool calls that were executed - pub tool_calls: Vec<ToolCallInfo>, - /// Updated file body after tool execution - pub updated_body: Vec<BodyElement>, - /// Updated summary (if changed) - pub updated_summary: Option<String>, - /// Questions pending user answers (pauses conversation) - #[serde(skip_serializing_if = "Option::is_none")] - pub pending_questions: Option<Vec<UserQuestion>>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ToolCallInfo { - pub name: String, - pub result: ToolResult, -} - -/// Enum to hold LLM clients -enum LlmClient { - Groq(GroqClient), - Claude(ClaudeClient), -} - -/// Unified result from LLM call -struct LlmResult { - content: Option<String>, - tool_calls: Vec<ToolCall>, - raw_tool_calls: Vec<ToolCallResponse>, - finish_reason: String, -} - -/// Chat with a file using LLM tool calling -#[utoipa::path( - post, - path = "/api/v1/files/{id}/chat", - request_body = ChatRequest, - responses( - (status = 200, description = "Chat completed successfully", body = ChatResponse), - (status = 404, description = "File not found"), - (status = 500, description = "Internal server error") - ), - params( - ("id" = Uuid, Path, description = "File ID") - ), - tag = "chat" -)] -pub async fn chat_handler( - State(state): State<SharedState>, - Path(id): Path<Uuid>, - Json(request): Json<ChatRequest>, -) -> impl IntoResponse { - // Check if database is configured - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(serde_json::json!({ - "error": "Database not configured" - })), - ) - .into_response(); - }; - - // Get the file - let file = match repository::get_file(pool, id).await { - Ok(Some(file)) => file, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "File not found" - })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Database error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": format!("Database error: {}", e) - })), - ) - .into_response(); - } - }; - - // Parse model selection (default to Claude Sonnet) - let model = request - .model - .as_ref() - .and_then(|m| LlmModel::from_str(m)) - .unwrap_or_default(); - - tracing::info!("Using LLM model: {:?}", model); - - // Initialize the appropriate LLM client - let llm_client = match model { - LlmModel::ClaudeSonnet => { - match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(serde_json::json!({ - "error": "ANTHROPIC_API_KEY not configured" - })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": format!("Claude client error: {}", e) - })), - ) - .into_response(); - } - } - } - LlmModel::ClaudeOpus => { - match ClaudeClient::from_env(ClaudeModel::Opus) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(serde_json::json!({ - "error": "ANTHROPIC_API_KEY not configured" - })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": format!("Claude client error: {}", e) - })), - ) - .into_response(); - } - } - } - LlmModel::GroqKimi => { - match GroqClient::from_env() { - Ok(client) => LlmClient::Groq(client), - Err(GroqError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(serde_json::json!({ - "error": "GROQ_API_KEY not configured" - })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": format!("Groq client error: {}", e) - })), - ) - .into_response(); - } - } - } - }; - - // Build context about the file - let file_context = build_file_context(&file); - - // Build focused element context if specified - let focused_context = build_focused_element_context(&file.body, request.focused_element_index); - - // Build agentic system prompt - let system_prompt = format!( - r#"You are an intelligent document editing agent. You help users view, analyze, and modify document files. - -## Your Capabilities -You have access to tools for: -- **Viewing content**: view_body (see all elements), read_element (inspect specific element), view_transcript (read full transcript) -- **Adding content**: add_heading, add_paragraph, add_code, add_list, add_chart -- **Modifying content**: update_element, remove_element, reorder_elements, clear_body -- **Document metadata**: set_summary -- **Data processing**: parse_csv (convert CSV to JSON), jq (transform JSON data) -- **Version history**: list_versions, read_version, restore_version -- **Templates**: suggest_templates (get phase-appropriate templates), apply_template (apply a template structure) - -## Agentic Behavior Guidelines - -### 1. Analyze Before Acting -- For complex requests, first gather information using view_body, view_transcript, or read_element -- Understand the current state of the document before making changes -- For simple, direct requests (e.g., "add a heading called X"), you can act immediately without prior inspection - -### 2. Plan Multi-Step Operations -- Break complex tasks into logical steps -- For data visualization: parse_csv → (optionally jq to transform) → add_chart -- For restructuring: view_body → understand structure → make targeted changes - -### 3. Handle Errors Gracefully -- If a tool call fails, analyze the error message -- Try an alternative approach or different parameters -- Don't repeat the exact same failing call - -### 4. Know When to Stop -- Stop when you've completed the user's request -- Stop when you've provided the requested information -- Provide a clear summary of what you did in your final response - -### 5. Be Efficient -- Don't over-analyze simple requests -- Use the minimum number of tool calls needed -- Combine operations when possible - -## Current Document Context -{file_context} -{focused_context} -## Important Notes -- Body element indices are 0-based -- When updating elements, provide ALL required fields for that element type -- The transcript is read-only (you cannot modify it, only read it) -- Changes are saved automatically after tool execution"#, - file_context = file_context, - focused_context = focused_context - ); - - // Build initial messages (Groq/OpenAI format - will be converted for Claude) - let mut messages = vec![ - Message { - role: "system".to_string(), - content: Some(system_prompt), - tool_calls: None, - tool_call_id: None, - }, - ]; - - // Add conversation history if provided (for context continuity) - if let Some(history) = &request.history { - for hist_msg in history { - messages.push(Message { - role: hist_msg.role.clone(), - content: Some(hist_msg.content.clone()), - tool_calls: None, - tool_call_id: None, - }); - } - tracing::info!( - history_messages = history.len(), - "Loaded conversation history" - ); - } - - // Add current user message - messages.push(Message { - role: "user".to_string(), - content: Some(request.message.clone()), - tool_calls: None, - tool_call_id: None, - }); - - // State for tracking changes - let mut current_body = file.body.clone(); - let mut current_summary = file.summary.clone(); - let mut all_tool_call_infos: Vec<ToolCallInfo> = Vec::new(); - let mut final_response: Option<String> = None; - // Track if a version restore already happened (to avoid double-saving) - let mut version_restored = false; - // Track if there were modifications after a restore - let mut has_changes_after_restore = false; - // Track consecutive failures for agentic retry logic - let mut consecutive_failures = 0; - const MAX_CONSECUTIVE_FAILURES: usize = 3; - // Track pending user questions (pauses the conversation) - let mut pending_questions: Option<Vec<UserQuestion>> = None; - - // Multi-turn agentic tool calling loop - for round in 0..MAX_TOOL_ROUNDS { - tracing::info!( - round = round, - body_elements = current_body.len(), - total_tool_calls = all_tool_call_infos.len(), - "Agentic loop iteration" - ); - - // Check if we've hit too many consecutive failures - if consecutive_failures >= MAX_CONSECUTIVE_FAILURES { - tracing::warn!("Breaking loop due to {} consecutive failures", consecutive_failures); - final_response = Some(format!( - "I encountered multiple consecutive errors and stopped to avoid an infinite loop. \ - Please try rephrasing your request or check if the document state is as expected." - )); - break; - } - - // Check context usage and compact if nearing limit - if is_context_near_limit(&messages, &model) { - let estimated_tokens = estimate_total_tokens(&messages); - tracing::warn!( - estimated_tokens = estimated_tokens, - round = round, - "Context nearing limit, compacting conversation" - ); - compact_conversation(&mut messages, &all_tool_call_infos); - - // Log the new token count - let new_tokens = estimate_total_tokens(&messages); - tracing::info!( - tokens_before = estimated_tokens, - tokens_after = new_tokens, - tokens_saved = estimated_tokens - new_tokens, - "Conversation compacted" - ); - } - - // Call the appropriate LLM API - let result = match &llm_client { - LlmClient::Groq(groq) => { - match groq.chat_with_tools(messages.clone(), &AVAILABLE_TOOLS).await { - Ok(r) => LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls: r.raw_tool_calls, - finish_reason: r.finish_reason, - }, - Err(e) => { - tracing::error!("Groq API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": format!("LLM API error: {}", e) - })), - ) - .into_response(); - } - } - } - LlmClient::Claude(claude_client) => { - // Convert messages to Claude format - let claude_messages = claude::groq_messages_to_claude(&messages); - match claude_client.chat_with_tools(claude_messages, &AVAILABLE_TOOLS).await { - Ok(r) => { - // Convert Claude tool uses to Groq-style ToolCallResponse for consistency - let raw_tool_calls: Vec<ToolCallResponse> = r - .tool_calls - .iter() - .map(|tc| ToolCallResponse { - id: tc.id.clone(), - call_type: "function".to_string(), - function: crate::llm::groq::FunctionCall { - name: tc.name.clone(), - arguments: tc.arguments.to_string(), - }, - }) - .collect(); - - LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls, - finish_reason: r.stop_reason, - } - } - Err(e) => { - tracing::error!("Claude API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": format!("LLM API error: {}", e) - })), - ) - .into_response(); - } - } - } - }; - - // Check if there are tool calls to execute - if result.tool_calls.is_empty() { - // No more tool calls - capture the final response and exit loop - final_response = result.content; - break; - } - - // Add assistant message with tool calls to conversation - messages.push(Message { - role: "assistant".to_string(), - content: result.content.clone(), - tool_calls: Some(result.raw_tool_calls.clone()), - tool_call_id: None, - }); - - // Execute each tool call and add results to conversation - for (i, tool_call) in result.tool_calls.iter().enumerate() { - tracing::info!( - tool = %tool_call.name, - round = round, - "Executing tool call" - ); - - let mut execution_result = - execute_tool_call(tool_call, ¤t_body, current_summary.as_deref(), &file.transcript); - - // Handle version tool requests that need async database access - if let Some(version_request) = &execution_result.version_request { - let version_result = handle_version_request( - pool, - id, - version_request, - ¤t_body, - current_summary.as_deref(), - file.version, - ) - .await; - - // Update execution result with actual version operation result - execution_result.result = version_result.result; - execution_result.parsed_data = version_result.data; - - // Apply state changes from restore operation - if let Some(new_body) = version_result.new_body { - current_body = new_body; - // Mark that a restore happened - file was already saved - version_restored = true; - } - if let Some(new_summary) = version_result.new_summary { - current_summary = Some(new_summary); - } - } - - // Apply state changes from regular tools - if let Some(new_body) = execution_result.new_body { - current_body = new_body; - // If this is a regular tool (not a version operation), track it - if execution_result.version_request.is_none() && version_restored { - has_changes_after_restore = true; - } - } - if let Some(new_summary) = execution_result.new_summary { - current_summary = Some(new_summary); - if execution_result.version_request.is_none() && version_restored { - has_changes_after_restore = true; - } - } - - // Track consecutive failures for agentic behavior - if execution_result.result.success { - consecutive_failures = 0; - } else { - consecutive_failures += 1; - tracing::warn!( - tool = %tool_call.name, - consecutive_failures = consecutive_failures, - "Tool call failed" - ); - } - - // Check for pending user questions (pauses the conversation) - if let Some(questions) = execution_result.pending_questions { - tracing::info!( - question_count = questions.len(), - "LLM requesting user input, pausing conversation" - ); - pending_questions = Some(questions); - // Track this tool call before breaking - all_tool_call_infos.push(ToolCallInfo { - name: tool_call.name.clone(), - result: execution_result.result, - }); - break; // Exit inner loop - } - - // Build tool result message content with enhanced context for agentic reasoning - let result_content = if let Some(parsed_data) = &execution_result.parsed_data { - // Include parsed data in the result for the LLM to use - serde_json::json!({ - "success": execution_result.result.success, - "message": execution_result.result.message, - "data": parsed_data - }) - .to_string() - } else if !execution_result.result.success { - // On failure, include hints for the LLM - let hint = if consecutive_failures >= MAX_CONSECUTIVE_FAILURES { - " [HINT: Multiple consecutive failures detected. Consider a different approach or verify your parameters.]" - } else { - "" - }; - serde_json::json!({ - "success": false, - "message": format!("{}{}", execution_result.result.message, hint), - "currentBodyElementCount": current_body.len() - }) - .to_string() - } else { - serde_json::json!({ - "success": execution_result.result.success, - "message": execution_result.result.message - }) - .to_string() - }; - - // Add tool result message - // Use the appropriate ID format for each provider - let tool_call_id = match &llm_client { - LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(), - LlmClient::Claude(_) => tool_call.id.clone(), - }; - - messages.push(Message { - role: "tool".to_string(), - content: Some(result_content), - tool_calls: None, - tool_call_id: Some(tool_call_id), - }); - - // Track for response - all_tool_call_infos.push(ToolCallInfo { - name: tool_call.name.clone(), - result: execution_result.result, - }); - } - - // If user questions are pending, pause the conversation - if pending_questions.is_some() { - final_response = result.content; - break; - } - - // If finish reason indicates completion, exit loop - let finish_lower = result.finish_reason.to_lowercase(); - if finish_lower == "stop" || finish_lower == "end_turn" { - final_response = result.content; - break; - } - } - - // Save changes to database if any tools were executed - // Skip if a version restore already happened (file was already saved during restore) - // UNLESS there were additional modifications after the restore - if !all_tool_call_infos.is_empty() && (!version_restored || has_changes_after_restore) { - let update_req = crate::db::models::UpdateFileRequest { - name: None, - description: None, - transcript: None, - summary: current_summary.clone(), - body: Some(current_body.clone()), - version: None, // Internal update, skip version check - repo_file_path: None, - }; - - match repository::update_file(pool, id, update_req).await { - Ok(Some(updated_file)) => { - // Broadcast update notification for LLM changes - let mut updated_fields = vec!["body".to_string()]; - if current_summary.is_some() { - updated_fields.push("summary".to_string()); - } - state.broadcast_file_update(FileUpdateNotification { - file_id: id, - version: updated_file.version, - updated_fields, - updated_by: "llm".to_string(), - }); - } - Ok(None) => { - // File was deleted during processing - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "File not found" - })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to save file changes: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": format!("Failed to save changes: {}", e) - })), - ) - .into_response(); - } - } - } - - // Build response - let response_text = final_response.unwrap_or_else(|| { - if all_tool_call_infos.is_empty() { - "I couldn't understand your request. Please try rephrasing.".to_string() - } else { - format!( - "Done! Executed {} tool{}.", - all_tool_call_infos.len(), - if all_tool_call_infos.len() == 1 { "" } else { "s" } - ) - } - }); - - ( - StatusCode::OK, - Json(ChatResponse { - response: response_text, - tool_calls: all_tool_call_infos, - updated_body: current_body, - updated_summary: current_summary, - pending_questions, - }), - ) - .into_response() -} - -fn build_file_context(file: &crate::db::models::File) -> String { - let mut context = format!("File: {}\n", file.name); - - if let Some(ref desc) = file.description { - context.push_str(&format!("Description: {}\n", desc)); - } - - if let Some(ref summary) = file.summary { - context.push_str(&format!("Summary: {}\n", summary)); - } - - // Include contract phase context if file belongs to a contract - if let Some(ref phase) = file.contract_phase { - context.push_str(&format!("\n## Contract Context\n")); - context.push_str(&format!("This file belongs to a contract in the '{}' phase.\n", phase)); - context.push_str("You can use 'suggest_templates' to get phase-appropriate templates, "); - context.push_str("or 'apply_template' to apply a template structure.\n"); - context.push_str(&format!( - "Templates for '{}' phase include: {}\n", - phase, - match phase.as_str() { - "research" => "research-notes, competitor-analysis, user-research", - "specify" => "requirements, user-stories, acceptance-criteria", - "plan" => "architecture, technical-design, task-breakdown", - "execute" => "dev-notes, test-plan, implementation-log", - "review" => "review-checklist, release-notes, retrospective", - _ => "(use suggest_templates to see available)", - } - )); - } - - context.push_str(&format!("\nTranscript entries: {}\n", file.transcript.len())); - context.push_str(&format!("Body elements: {}\n", file.body.len())); - - // Add body overview - if !file.body.is_empty() { - context.push_str("\nCurrent body elements:\n"); - for (i, element) in file.body.iter().enumerate() { - let desc = match element { - BodyElement::Heading { level, text } => format!("H{}: {}", level, text), - BodyElement::Paragraph { text } => { - let preview: String = text.chars().take(50).collect(); - if text.chars().count() > 50 { - format!("Paragraph: {}...", preview) - } else { - format!("Paragraph: {}", preview) - } - } - BodyElement::Code { language, content } => { - let lang = language.as_deref().unwrap_or("plain"); - let preview: String = content.chars().take(50).collect(); - if content.chars().count() > 50 { - format!("Code ({}): {}...", lang, preview) - } else { - format!("Code ({}): {}", lang, preview) - } - } - BodyElement::List { ordered, items } => { - let list_type = if *ordered { "ordered" } else { "unordered" }; - format!("List ({}): {} items", list_type, items.len()) - } - BodyElement::Chart { chart_type, title, .. } => { - format!( - "Chart ({:?}){}", - chart_type, - title.as_ref().map(|t| format!(": {}", t)).unwrap_or_default() - ) - } - BodyElement::Image { alt, .. } => { - format!("Image{}", alt.as_ref().map(|a| format!(": {}", a)).unwrap_or_default()) - } - BodyElement::Markdown { content } => { - let preview: String = content.chars().take(50).collect(); - if content.chars().count() > 50 { - format!("Markdown: {}...", preview) - } else { - format!("Markdown: {}", preview) - } - } - }; - context.push_str(&format!(" [{}] {}\n", i, desc)); - } - } - - // Add transcript preview if available - if !file.transcript.is_empty() { - context.push_str("\nTranscript preview (first 5 entries):\n"); - for entry in file.transcript.iter().take(5) { - context.push_str(&format!(" - {}: {}\n", entry.speaker, entry.text)); - } - if file.transcript.len() > 5 { - context.push_str(&format!(" ... and {} more entries\n", file.transcript.len() - 5)); - } - } - - context -} - -/// Build context for a focused element -fn build_focused_element_context(body: &[BodyElement], focused_index: Option<usize>) -> String { - let Some(index) = focused_index else { - return String::new(); - }; - - let Some(element) = body.get(index) else { - return format!( - "\n## Focused Element\nNote: User focused on element [{}] but it doesn't exist (document has {} elements).\n", - index, - body.len() - ); - }; - - let (element_type, full_content) = match element { - BodyElement::Heading { level, text } => { - (format!("Heading (level {})", level), text.clone()) - } - BodyElement::Paragraph { text } => { - ("Paragraph".to_string(), text.clone()) - } - BodyElement::Code { language, content } => { - let lang = language.as_deref().unwrap_or("plain"); - (format!("Code ({})", lang), content.clone()) - } - BodyElement::List { ordered, items } => { - let list_type = if *ordered { "Ordered list" } else { "Unordered list" }; - let content = items.iter() - .enumerate() - .map(|(i, item)| format!("{}. {}", i + 1, item)) - .collect::<Vec<_>>() - .join("\n"); - (list_type.to_string(), content) - } - BodyElement::Chart { chart_type, title, .. } => { - let title_str = title.as_deref().unwrap_or("untitled"); - (format!("Chart ({:?})", chart_type), title_str.to_string()) - } - BodyElement::Image { alt, caption, .. } => { - let desc = alt.as_deref().or(caption.as_deref()).unwrap_or("no description"); - ("Image".to_string(), desc.to_string()) - } - BodyElement::Markdown { content } => { - ("Markdown".to_string(), content.clone()) - } - }; - - format!( - r#" -## Focused Element -The user is focusing on element [{}]: {} -Full content of focused element: ---- -{} ---- -When the user's request is ambiguous about which element to modify, prioritize this focused element. -"#, - index, element_type, full_content - ) -} - -/// Result of handling a version tool request -struct VersionRequestResult { - result: ToolResult, - data: Option<serde_json::Value>, - new_body: Option<Vec<BodyElement>>, - new_summary: Option<String>, -} - -/// Handle version tool requests that require async database access -async fn handle_version_request( - pool: &sqlx::PgPool, - file_id: Uuid, - request: &VersionToolRequest, - _current_body: &[BodyElement], - _current_summary: Option<&str>, - current_version: i32, -) -> VersionRequestResult { - match request { - VersionToolRequest::ListVersions => { - match repository::list_file_versions(pool, file_id).await { - Ok(versions) => { - let version_data: Vec<serde_json::Value> = versions - .iter() - .map(|v| { - serde_json::json!({ - "version": v.version, - "source": v.source, - "createdAt": v.created_at.to_rfc3339(), - "changeDescription": v.change_description, - }) - }) - .collect(); - - VersionRequestResult { - result: ToolResult { - success: true, - message: format!("Found {} versions. Current version is {}.", versions.len(), current_version), - }, - data: Some(serde_json::json!({ - "currentVersion": current_version, - "versions": version_data, - })), - new_body: None, - new_summary: None, - } - } - Err(e) => VersionRequestResult { - result: ToolResult { - success: false, - message: format!("Failed to list versions: {}", e), - }, - data: None, - new_body: None, - new_summary: None, - }, - } - } - VersionToolRequest::ReadVersion { version } => { - match repository::get_file_version(pool, file_id, *version).await { - Ok(Some(ver)) => { - // Convert body elements to a readable format - let body_preview: Vec<String> = ver - .body - .iter() - .enumerate() - .map(|(i, element)| { - let desc = match element { - BodyElement::Heading { level, text } => format!("H{}: {}", level, text), - BodyElement::Paragraph { text } => { - let preview: String = text.chars().take(100).collect(); - if text.chars().count() > 100 { - format!("Paragraph: {}...", preview) - } else { - format!("Paragraph: {}", preview) - } - } - BodyElement::Code { language, content } => { - let lang = language.as_deref().unwrap_or("plain"); - let preview: String = content.chars().take(100).collect(); - if content.chars().count() > 100 { - format!("Code ({}): {}...", lang, preview) - } else { - format!("Code ({}): {}", lang, preview) - } - } - BodyElement::List { ordered, items } => { - let list_type = if *ordered { "ordered" } else { "unordered" }; - format!("List ({}): {} items", list_type, items.len()) - } - BodyElement::Chart { chart_type, title, .. } => { - format!( - "Chart ({:?}){}", - chart_type, - title.as_ref().map(|t| format!(": {}", t)).unwrap_or_default() - ) - } - BodyElement::Image { alt, .. } => { - format!("Image{}", alt.as_ref().map(|a| format!(": {}", a)).unwrap_or_default()) - } - BodyElement::Markdown { content } => { - let preview: String = content.chars().take(100).collect(); - if content.chars().count() > 100 { - format!("Markdown: {}...", preview) - } else { - format!("Markdown: {}", preview) - } - } - }; - format!("[{}] {}", i, desc) - }) - .collect(); - - VersionRequestResult { - result: ToolResult { - success: true, - message: format!( - "Version {} from {} (source: {}). {} body elements.", - ver.version, - ver.created_at.format("%Y-%m-%d %H:%M"), - ver.source, - ver.body.len() - ), - }, - data: Some(serde_json::json!({ - "version": ver.version, - "source": ver.source, - "createdAt": ver.created_at.to_rfc3339(), - "summary": ver.summary, - "bodyPreview": body_preview, - "changeDescription": ver.change_description, - })), - new_body: None, - new_summary: None, - } - } - Ok(None) => VersionRequestResult { - result: ToolResult { - success: false, - message: format!("Version {} not found", version), - }, - data: None, - new_body: None, - new_summary: None, - }, - Err(e) => VersionRequestResult { - result: ToolResult { - success: false, - message: format!("Failed to read version: {}", e), - }, - data: None, - new_body: None, - new_summary: None, - }, - } - } - VersionToolRequest::RestoreVersion { target_version, reason } => { - // Set change description if provided - if let Some(reason) = reason { - let _ = repository::set_change_description(pool, reason).await; - } - - match repository::restore_file_version(pool, file_id, *target_version, current_version).await { - Ok(Some(restored_file)) => { - VersionRequestResult { - result: ToolResult { - success: true, - message: format!( - "Restored to version {}. New version is {}.", - target_version, restored_file.version - ), - }, - data: Some(serde_json::json!({ - "previousVersion": current_version, - "restoredFromVersion": target_version, - "newVersion": restored_file.version, - })), - new_body: Some(restored_file.body), - new_summary: restored_file.summary, - } - } - Ok(None) => VersionRequestResult { - result: ToolResult { - success: false, - message: format!("Version {} not found", target_version), - }, - data: None, - new_body: None, - new_summary: None, - }, - Err(RepositoryError::VersionConflict { expected, actual }) => { - VersionRequestResult { - result: ToolResult { - success: false, - message: format!( - "Version conflict: expected {}, actual {}. Document was modified.", - expected, actual - ), - }, - data: None, - new_body: None, - new_summary: None, - } - } - Err(e) => VersionRequestResult { - result: ToolResult { - success: false, - message: format!("Failed to restore version: {}", e), - }, - data: None, - new_body: None, - new_summary: None, - }, - } - } - } -} - -/// Estimate the token count of a message -fn estimate_message_tokens(message: &Message) -> usize { - let mut chars = 0; - - // Count content characters - if let Some(ref content) = message.content { - chars += content.len(); - } - - // Count tool call characters (rough estimate) - if let Some(ref tool_calls) = message.tool_calls { - for tc in tool_calls { - chars += tc.function.name.len(); - chars += tc.function.arguments.len(); - } - } - - // Count tool call ID - if let Some(ref id) = message.tool_call_id { - chars += id.len(); - } - - // Add overhead for role and structure - chars += message.role.len() + 20; - - // Convert to tokens - chars / CHARS_PER_TOKEN -} - -/// Estimate total token count of all messages -fn estimate_total_tokens(messages: &[Message]) -> usize { - messages.iter().map(estimate_message_tokens).sum() -} - -/// Check if context is nearing the limit -fn is_context_near_limit(messages: &[Message], model: &LlmModel) -> bool { - let estimated_tokens = estimate_total_tokens(messages); - let limit = match model { - LlmModel::ClaudeSonnet | LlmModel::ClaudeOpus => CLAUDE_CONTEXT_LIMIT, - LlmModel::GroqKimi => GROQ_CONTEXT_LIMIT, - }; - let threshold = (limit as f32 * CONTEXT_COMPACTION_THRESHOLD) as usize; - - estimated_tokens >= threshold -} - -/// Compact the conversation by summarizing older messages -/// Keeps: system message, last N user/assistant exchanges, and a summary of older content -fn compact_conversation(messages: &mut Vec<Message>, tool_call_history: &[ToolCallInfo]) { - // Keep at least system message + 4 recent messages (2 exchanges) - const MIN_MESSAGES_TO_KEEP: usize = 5; - - if messages.len() <= MIN_MESSAGES_TO_KEEP { - return; - } - - // Extract system message (always first) - let system_message = messages.remove(0); - - // Calculate how many messages to summarize - // Keep the last ~1/3 of messages for recent context - let messages_to_keep = std::cmp::max(4, messages.len() / 3); - let messages_to_summarize = messages.len() - messages_to_keep; - - if messages_to_summarize < 2 { - // Not enough to summarize, just put system message back - messages.insert(0, system_message); - return; - } - - // Extract messages to summarize - let old_messages: Vec<Message> = messages.drain(..messages_to_summarize).collect(); - - // Build summary of old messages - let mut summary_parts: Vec<String> = Vec::new(); - - // Summarize user requests - let user_requests: Vec<&str> = old_messages - .iter() - .filter(|m| m.role == "user") - .filter_map(|m| m.content.as_deref()) - .collect(); - - if !user_requests.is_empty() { - summary_parts.push(format!( - "Previous user requests: {}", - user_requests.join("; ") - )); - } - - // Summarize tool calls executed so far - if !tool_call_history.is_empty() { - let tool_summary: Vec<String> = tool_call_history - .iter() - .map(|tc| { - if tc.result.success { - format!("{}(ok)", tc.name) - } else { - format!("{}(failed: {})", tc.name, tc.result.message) - } - }) - .collect(); - - summary_parts.push(format!( - "Tools executed: {}", - tool_summary.join(", ") - )); - } - - // Count assistant responses that were summarized - let assistant_responses = old_messages - .iter() - .filter(|m| m.role == "assistant" && m.content.is_some()) - .count(); - - if assistant_responses > 0 { - summary_parts.push(format!( - "({} previous assistant responses omitted for brevity)", - assistant_responses - )); - } - - // Create compacted context message - let compacted_content = format!( - "[CONTEXT SUMMARY - Earlier conversation compacted to save tokens]\n{}", - summary_parts.join("\n") - ); - - // Rebuild messages: system + summary + remaining recent messages - let mut new_messages = vec![ - system_message, - Message { - role: "user".to_string(), - content: Some(compacted_content), - tool_calls: None, - tool_call_id: None, - }, - Message { - role: "assistant".to_string(), - content: Some("Understood. I have context from the previous conversation and will continue from here.".to_string()), - tool_calls: None, - tool_call_id: None, - }, - ]; - - new_messages.append(messages); - *messages = new_messages; - - tracing::info!( - summarized_messages = messages_to_summarize, - remaining_messages = messages.len(), - "Compacted conversation to save context" - ); -} diff --git a/makima/src/server/handlers/mesh_chat.rs b/makima/src/server/handlers/mesh_chat.rs deleted file mode 100644 index 638a4d3..0000000 --- a/makima/src/server/handlers/mesh_chat.rs +++ /dev/null @@ -1,2264 +0,0 @@ -//! Chat endpoint for LLM-powered task orchestration. -//! -//! This handler provides an agentic loop for managing tasks, daemons, and -//! overlay operations through LLM tool calling. - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{models::CreateTaskRequest, repository}; -use crate::llm::{ - claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, - groq::{GroqClient, GroqError, Message, ToolCallResponse}, - parse_mesh_tool_call, LlmModel, MeshToolRequest, ToolCall, ToolResult, UserQuestion, - MESH_TOOLS, -}; -use crate::server::auth::Authenticated; -use crate::server::state::{DaemonCommand, SharedState, TaskUpdateNotification}; - -/// Maximum number of tool-calling rounds to prevent infinite loops -const MAX_TOOL_ROUNDS: usize = 30; - -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct MeshChatHistoryMessage { - /// Role: "user" or "assistant" - pub role: String, - /// Message content - pub content: String, -} - -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct MeshChatRequest { - /// The user's message/instruction - pub message: String, - /// Optional model selection: "claude-sonnet" (default), "claude-opus", or "groq" - #[serde(default)] - pub model: Option<String>, - /// Optional conversation history for context continuity (deprecated - now loaded from DB) - #[serde(default)] - pub history: Option<Vec<MeshChatHistoryMessage>>, - /// Context type: "mesh", "task", or "subtask" - #[serde(default)] - pub context_type: Option<String>, - /// Task ID if context is task/subtask - #[serde(default)] - pub context_task_id: Option<Uuid>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct MeshChatResponse { - /// The LLM's response message - pub response: String, - /// Tool calls that were executed - pub tool_calls: Vec<MeshToolCallInfo>, - /// Questions pending user answers (pauses conversation) - #[serde(skip_serializing_if = "Option::is_none")] - pub pending_questions: Option<Vec<UserQuestion>>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct MeshToolCallInfo { - pub name: String, - pub result: ToolResult, -} - -/// Enum to hold LLM clients -enum LlmClient { - Groq(GroqClient), - Claude(ClaudeClient), -} - -/// Unified result from LLM call -struct LlmResult { - content: Option<String>, - tool_calls: Vec<ToolCall>, - raw_tool_calls: Vec<ToolCallResponse>, - finish_reason: String, -} - -/// Chat with mesh orchestrator at the top level (no specific task context) -#[utoipa::path( - post, - path = "/api/v1/mesh/chat", - request_body = MeshChatRequest, - responses( - (status = 200, description = "Chat completed successfully", body = MeshChatResponse), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Mesh" -)] -pub async fn mesh_toplevel_chat_handler( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(request): Json<MeshChatRequest>, -) -> impl IntoResponse { - // Check if database is configured - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Parse model selection (default to Claude Sonnet) - let model = request - .model - .as_ref() - .and_then(|m| LlmModel::from_str(m)) - .unwrap_or(LlmModel::ClaudeSonnet); - - tracing::info!("Mesh top-level chat using LLM model: {:?}", model); - - // Initialize the appropriate LLM client - let llm_client = match model { - LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::GroqKimi => match GroqClient::from_env() { - Ok(client) => LlmClient::Groq(client), - Err(GroqError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "GROQ_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Groq client error: {}", e) })), - ) - .into_response(); - } - }, - }; - - // Build context about all tasks and daemons - let mesh_context = build_mesh_overview_context(pool, &state, auth.owner_id).await; - - // Build agentic system prompt for top-level mesh orchestration - let system_prompt = format!( - r#"You are an intelligent task orchestration agent. You help users manage and coordinate tasks running on connected daemons with Claude Code containers. - -## Your Capabilities -You have access to tools for: -- **Task Lifecycle**: create_task, run_task, pause_task, resume_task, interrupt_task, discard_task -- **Task Queries**: query_task_status, list_tasks, list_subtasks, list_siblings, list_daemons -- **File Access**: list_files, read_file (read documents from the files system) -- **Task Communication**: send_message_to_task, update_task_plan -- **Overlay/Merge Operations**: peek_sibling_overlay, get_overlay_diff, preview_merge, merge_subtask, complete_task, set_merge_mode - -## Current Mesh Overview -{mesh_context} - -## Agentic Behavior Guidelines - -### 1. Analyze Before Acting -- For complex orchestration requests, first gather information using query_task_status, list_tasks, or list_daemons -- Understand the current state before making changes -- For simple, direct requests (e.g., "create a new task"), you can act immediately - -### 2. Plan Multi-Step Operations -- Break complex orchestration into logical steps -- For parallel execution: create multiple subtasks, then run them on different daemons -- For sequential execution: create subtasks and run them in order - -### 3. Create and Manage Tasks -- Use create_task to create new top-level tasks or subtasks -- Assign appropriate priorities and plans -- **Repository Default**: When creating tasks, use the daemon's working directory as the repository_url by default (shown as "Default Repository" above). Only omit repository_url if the task doesn't involve code, or use a different URL if the user explicitly requests it. -- If a working directory is a git repository, use it as the repository_url for code-related tasks - -### 4. Coordinate Multiple Tasks -- Use list_tasks to see all tasks and their statuses -- Use list_daemons to see available compute resources -- Balance workload across daemons - -### 5. Be Efficient -- Don't over-analyze simple requests -- Use the minimum number of tool calls needed -- Provide clear summaries of actions taken - -## Important Notes -- Task IDs are UUIDs - ensure you use the correct format -- Running a task requires at least one connected daemon -- When creating subtasks, specify the parent_task_id -- Always confirm destructive operations (discard_task) with the user"#, - mesh_context = mesh_context - ); - - // Run the shared agentic loop - run_mesh_agentic_loop(pool, &state, &llm_client, system_prompt, &request, auth.owner_id).await -} - -/// Chat with task mesh orchestrator using LLM tool calling (scoped by owner) -#[utoipa::path( - post, - path = "/api/v1/mesh/tasks/{id}/chat", - request_body = MeshChatRequest, - responses( - (status = 200, description = "Chat completed successfully", body = MeshChatResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Task not found"), - (status = 500, description = "Internal server error") - ), - params( - ("id" = Uuid, Path, description = "Task ID (context for orchestration)") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Mesh" -)] -pub async fn mesh_chat_handler( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(task_id): Path<Uuid>, - Json(request): Json<MeshChatRequest>, -) -> impl IntoResponse { - // Check if database is configured - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Get the context task (scoped by owner) - let task = match repository::get_task_for_owner(pool, task_id, auth.owner_id).await { - Ok(Some(task)) => task, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Task not found" })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Database error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Database error: {}", e) })), - ) - .into_response(); - } - }; - - // Parse model selection (default to Claude Sonnet) - let model = request - .model - .as_ref() - .and_then(|m| LlmModel::from_str(m)) - .unwrap_or(LlmModel::ClaudeSonnet); - - tracing::info!("Mesh chat using LLM model: {:?}", model); - - // Initialize the appropriate LLM client - let llm_client = match model { - LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::GroqKimi => match GroqClient::from_env() { - Ok(client) => LlmClient::Groq(client), - Err(GroqError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "GROQ_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Groq client error: {}", e) })), - ) - .into_response(); - } - }, - }; - - // Build context about the current task and mesh state - let task_context = build_task_context(&task); - - // Build agentic system prompt for task orchestration - let system_prompt = format!( - r#"You are an intelligent task orchestration agent. You help users manage and coordinate tasks running on connected daemons with Claude Code containers. - -## Your Capabilities -You have access to tools for: -- **Task Lifecycle**: create_task, run_task, pause_task, resume_task, interrupt_task, discard_task -- **Task Queries**: query_task_status, list_tasks, list_subtasks, list_siblings, list_daemons -- **File Access**: list_files, read_file (read documents from the files system) -- **Task Communication**: send_message_to_task, update_task_plan -- **Overlay/Merge Operations**: peek_sibling_overlay, get_overlay_diff, preview_merge, merge_subtask, complete_task, set_merge_mode - -## Current Context -{task_context} - -## Agentic Behavior Guidelines - -### 1. Analyze Before Acting -- For complex orchestration requests, first gather information using query_task_status, list_tasks, or list_daemons -- Understand the current state before making changes -- For simple, direct requests (e.g., "pause this task"), you can act immediately - -### 2. Plan Multi-Step Operations -- Break complex orchestration into logical steps -- For parallel execution: create multiple subtasks, then run them on different daemons -- For sequential execution: create subtasks and run them in order - -### 3. Monitor Task Progress -- Use query_task_status to check on running tasks -- Watch for status changes and react accordingly -- Handle failures gracefully (retry, escalate, or report) - -### 4. Coordinate Sibling Tasks -- Use peek_sibling_overlay to see what other tasks have changed -- Preview merges before completing to catch conflicts -- Coordinate timing when multiple tasks need to merge - -### 5. Be Efficient -- Don't over-analyze simple requests -- Use the minimum number of tool calls needed -- Provide clear summaries of actions taken - -## Important Notes -- Task IDs are UUIDs - ensure you use the correct format -- Running a task requires at least one connected daemon -- Overlay operations require the task to have been run at least once -- Always confirm destructive operations (discard_task) with the user -- When creating subtasks for this task, use parent_task_id: {task_id}"#, - task_context = task_context, - task_id = task_id - ); - - // Run the shared agentic loop - run_mesh_agentic_loop(pool, &state, &llm_client, system_prompt, &request, auth.owner_id).await -} - -fn build_task_context(task: &crate::db::models::Task) -> String { - let mut context = format!( - "Current Task: {} (ID: {})\n", - task.name, task.id - ); - context.push_str(&format!("Status: {}\n", task.status)); - context.push_str(&format!("Priority: {}\n", task.priority)); - - if let Some(ref desc) = task.description { - context.push_str(&format!("Description: {}\n", desc)); - } - - // Truncate plan preview if too long - let plan_preview = if task.plan.len() > 200 { - format!("{}...", &task.plan[..200]) - } else { - task.plan.clone() - }; - context.push_str(&format!("Plan: {}\n", plan_preview)); - - if let Some(ref summary) = task.progress_summary { - context.push_str(&format!("Progress: {}\n", summary)); - } - - if let Some(ref error) = task.error_message { - context.push_str(&format!("Error: {}\n", error)); - } - - // Repository info - if let Some(ref url) = task.repository_url { - context.push_str(&format!("Repository: {}\n", url)); - } - if let Some(ref branch) = task.base_branch { - context.push_str(&format!("Base branch: {}\n", branch)); - } - - context -} - -/// Build overview context for top-level mesh orchestration -async fn build_mesh_overview_context(pool: &sqlx::PgPool, state: &SharedState, owner_id: Uuid) -> String { - let mut context = String::new(); - - // Get task counts by status - match repository::list_tasks_for_owner(pool, owner_id).await { - Ok(tasks) => { - let total = tasks.len(); - let pending = tasks.iter().filter(|t| t.status == "pending").count(); - let running = tasks.iter().filter(|t| t.status == "running").count(); - let paused = tasks.iter().filter(|t| t.status == "paused").count(); - let done = tasks.iter().filter(|t| t.status == "done").count(); - let failed = tasks.iter().filter(|t| t.status == "failed").count(); - - context.push_str(&format!( - "Tasks: {} total ({} pending, {} running, {} paused, {} done, {} failed)\n", - total, pending, running, paused, done, failed - )); - - // List recent/active tasks - if !tasks.is_empty() { - context.push_str("\nRecent Tasks:\n"); - for task in tasks.iter().take(5) { - context.push_str(&format!( - " - {} (ID: {}, Status: {})\n", - task.name, task.id, task.status - )); - } - if tasks.len() > 5 { - context.push_str(&format!(" ... and {} more\n", tasks.len() - 5)); - } - } - } - Err(e) => { - context.push_str(&format!("Error fetching tasks: {}\n", e)); - } - } - - // Get connected daemons for this owner - let owner_daemons: Vec<_> = state.daemon_connections.iter() - .filter(|e| e.value().owner_id == owner_id) - .collect(); - let daemon_count = owner_daemons.len(); - context.push_str(&format!("\nConnected Daemons: {}\n", daemon_count)); - - for entry in owner_daemons.iter().take(3) { - let daemon = entry.value(); - let working_dir = daemon.working_directory.as_deref().unwrap_or("not set"); - context.push_str(&format!( - " - {} (ID: {}, Working Directory: {})\n", - daemon.hostname.as_deref().unwrap_or("unknown"), - daemon.id, - working_dir - )); - } - - // Add default repository guidance if there's exactly one daemon with a working directory - let daemons_with_working_dir: Vec<_> = owner_daemons.iter() - .filter(|e| e.value().working_directory.is_some()) - .collect(); - - if daemons_with_working_dir.len() == 1 { - if let Some(dir) = &daemons_with_working_dir[0].value().working_directory { - context.push_str(&format!( - "\nDefault Repository: {} (use this as repository_url when creating tasks unless user specifies otherwise)\n", - dir - )); - } - } - - context -} - -/// Run the shared agentic loop for mesh chat -async fn run_mesh_agentic_loop( - pool: &sqlx::PgPool, - state: &SharedState, - llm_client: &LlmClient, - system_prompt: String, - request: &MeshChatRequest, - owner_id: Uuid, -) -> axum::response::Response { - // Get or create conversation for storing messages - let conversation = match repository::get_or_create_active_conversation(pool, owner_id).await { - Ok(c) => c, - Err(e) => { - tracing::error!("Failed to get/create conversation: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to initialize conversation: {}", e) })), - ) - .into_response(); - } - }; - - // Build initial messages - let mut messages = vec![Message { - role: "system".to_string(), - content: Some(system_prompt), - tool_calls: None, - tool_call_id: None, - }]; - - // Load conversation history from database (or use provided for backwards compatibility) - if let Some(history) = &request.history { - // Legacy: use provided history - for hist_msg in history { - messages.push(Message { - role: hist_msg.role.clone(), - content: Some(hist_msg.content.clone()), - tool_calls: None, - tool_call_id: None, - }); - } - tracing::info!( - history_messages = history.len(), - "Loaded mesh conversation history from request (legacy)" - ); - } else { - // New: load from database - match repository::list_chat_messages(pool, conversation.id, Some(50)).await { - Ok(db_messages) => { - for msg in db_messages { - messages.push(Message { - role: msg.role.clone(), - content: Some(msg.content.clone()), - tool_calls: None, - tool_call_id: None, - }); - } - tracing::info!( - history_messages = messages.len() - 1, // minus system message - "Loaded mesh conversation history from database" - ); - } - Err(e) => { - tracing::warn!("Failed to load chat history: {}", e); - // Continue without history - } - } - } - - // Add current user message - messages.push(Message { - role: "user".to_string(), - content: Some(request.message.clone()), - tool_calls: None, - tool_call_id: None, - }); - - // State for tracking - let mut all_tool_call_infos: Vec<MeshToolCallInfo> = Vec::new(); - let mut final_response: Option<String> = None; - let mut consecutive_failures = 0; - const MAX_CONSECUTIVE_FAILURES: usize = 3; - let mut pending_questions: Option<Vec<UserQuestion>> = None; - - // Multi-turn agentic tool calling loop - for round in 0..MAX_TOOL_ROUNDS { - tracing::info!( - round = round, - total_tool_calls = all_tool_call_infos.len(), - "Mesh agentic loop iteration" - ); - - // Check consecutive failures - if consecutive_failures >= MAX_CONSECUTIVE_FAILURES { - tracing::warn!( - "Breaking mesh loop due to {} consecutive failures", - consecutive_failures - ); - final_response = Some( - "I encountered multiple consecutive errors and stopped. \ - Please check the task state and try again." - .to_string(), - ); - break; - } - - // Call the appropriate LLM API - let result = match llm_client { - LlmClient::Groq(groq) => { - match groq.chat_with_tools(messages.clone(), &MESH_TOOLS).await { - Ok(r) => LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls: r.raw_tool_calls, - finish_reason: r.finish_reason, - }, - Err(e) => { - tracing::error!("Groq API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - LlmClient::Claude(claude_client) => { - let claude_messages = claude::groq_messages_to_claude(&messages); - match claude_client - .chat_with_tools(claude_messages, &MESH_TOOLS) - .await - { - Ok(r) => { - let raw_tool_calls: Vec<ToolCallResponse> = r - .tool_calls - .iter() - .map(|tc| ToolCallResponse { - id: tc.id.clone(), - call_type: "function".to_string(), - function: crate::llm::groq::FunctionCall { - name: tc.name.clone(), - arguments: tc.arguments.to_string(), - }, - }) - .collect(); - - LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls, - finish_reason: r.stop_reason, - } - } - Err(e) => { - tracing::error!("Claude API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - }; - - // Check if there are tool calls to execute - if result.tool_calls.is_empty() { - final_response = result.content; - break; - } - - // Add assistant message with tool calls to conversation - messages.push(Message { - role: "assistant".to_string(), - content: result.content.clone(), - tool_calls: Some(result.raw_tool_calls.clone()), - tool_call_id: None, - }); - - // Execute each tool call - for (i, tool_call) in result.tool_calls.iter().enumerate() { - tracing::info!(tool = %tool_call.name, round = round, "Executing mesh tool call"); - - // Parse the tool call - let mut execution_result = parse_mesh_tool_call(tool_call); - - // Handle async mesh tool requests - if let Some(mesh_request) = execution_result.request.take() { - let async_result = handle_mesh_request(pool, state, mesh_request, owner_id).await; - execution_result.success = async_result.success; - execution_result.message = async_result.message; - execution_result.data = async_result.data; - } - - // Track consecutive failures - if execution_result.success { - consecutive_failures = 0; - } else { - consecutive_failures += 1; - tracing::warn!( - tool = %tool_call.name, - consecutive_failures = consecutive_failures, - "Mesh tool call failed" - ); - } - - // Check for pending user questions - if let Some(questions) = execution_result.pending_questions { - tracing::info!( - question_count = questions.len(), - "Mesh LLM requesting user input" - ); - pending_questions = Some(questions); - all_tool_call_infos.push(MeshToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message.clone(), - }, - }); - break; - } - - // Build tool result message - let result_content = if let Some(data) = &execution_result.data { - json!({ - "success": execution_result.success, - "message": execution_result.message, - "data": data - }) - .to_string() - } else { - json!({ - "success": execution_result.success, - "message": execution_result.message - }) - .to_string() - }; - - // Add tool result message - let tool_call_id = match llm_client { - LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(), - LlmClient::Claude(_) => tool_call.id.clone(), - }; - - messages.push(Message { - role: "tool".to_string(), - content: Some(result_content), - tool_calls: None, - tool_call_id: Some(tool_call_id), - }); - - // Track for response - all_tool_call_infos.push(MeshToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message, - }, - }); - } - - // If user questions are pending, pause - if pending_questions.is_some() { - final_response = result.content; - break; - } - - // If finish reason indicates completion, exit loop - let finish_lower = result.finish_reason.to_lowercase(); - if finish_lower == "stop" || finish_lower == "end_turn" { - final_response = result.content; - break; - } - } - - // Build response - let response_text = final_response.unwrap_or_else(|| { - if all_tool_call_infos.is_empty() { - "I couldn't understand your request. Please try rephrasing.".to_string() - } else { - format!( - "Done! Executed {} tool{}.", - all_tool_call_infos.len(), - if all_tool_call_infos.len() == 1 { - "" - } else { - "s" - } - ) - } - }); - - // Save messages to database (only if not using legacy history mode) - if request.history.is_none() { - let context_type = request.context_type.clone().unwrap_or_else(|| "mesh".to_string()); - - // Validate context_task_id exists before using it (to avoid FK constraint violation) - let context_task_id = if let Some(task_id) = request.context_task_id { - match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(_)) => Some(task_id), - Ok(None) => { - tracing::warn!("context_task_id {} not found, ignoring", task_id); - None - } - Err(e) => { - tracing::warn!("Failed to validate context_task_id {}: {}", task_id, e); - None - } - } - } else { - None - }; - - // Save user message - if let Err(e) = repository::add_chat_message( - pool, - conversation.id, - "user", - &request.message, - &context_type, - context_task_id, - None, - None, - ) - .await - { - tracing::warn!("Failed to save user message to DB: {}", e); - } - - // Serialize tool calls for storage - let tool_calls_json = if all_tool_call_infos.is_empty() { - None - } else { - Some(serde_json::to_value(&all_tool_call_infos).unwrap_or_default()) - }; - - // Serialize pending questions for storage - let pending_questions_json = pending_questions - .as_ref() - .map(|q| serde_json::to_value(q).unwrap_or_default()); - - // Save assistant message - if let Err(e) = repository::add_chat_message( - pool, - conversation.id, - "assistant", - &response_text, - &context_type, - context_task_id, - tool_calls_json, - pending_questions_json, - ) - .await - { - tracing::warn!("Failed to save assistant message to DB: {}", e); - } - - tracing::info!( - conversation_id = %conversation.id, - context_type = %context_type, - "Saved mesh chat messages to database" - ); - } - - ( - StatusCode::OK, - Json(MeshChatResponse { - response: response_text, - tool_calls: all_tool_call_infos, - pending_questions, - }), - ) - .into_response() -} - -/// Result from handling an async mesh tool request -struct MeshRequestResult { - success: bool, - message: String, - data: Option<serde_json::Value>, -} - -/// Handle async mesh tool requests that require database/daemon access -async fn handle_mesh_request( - pool: &sqlx::PgPool, - state: &SharedState, - request: MeshToolRequest, - owner_id: Uuid, -) -> MeshRequestResult { - match request { - MeshToolRequest::CreateTask { - name, - plan, - parent_task_id, - repository_url, - base_branch, - merge_mode, - priority, - } => { - // Subtasks inherit contract_id from parent task - let contract_id = if let Some(parent_id) = parent_task_id { - match repository::get_task(pool, parent_id).await { - Ok(Some(parent_task)) => { - match parent_task.contract_id { - Some(cid) => cid, - None => { - return MeshRequestResult { - success: false, - message: "Parent task has no contract_id".to_string(), - data: None, - }; - } - } - } - Ok(None) => { - return MeshRequestResult { - success: false, - message: format!("Parent task {} not found", parent_id), - data: None, - }; - } - Err(e) => { - return MeshRequestResult { - success: false, - message: format!("Failed to look up parent task: {}", e), - data: None, - }; - } - } - } else { - // Root tasks created via LLM chat require a contract_id - // TODO: Add contract_id to create_task tool definition - return MeshRequestResult { - success: false, - message: "Cannot create root task without contract_id. Use parent_task_id to create subtasks.".to_string(), - data: None, - }; - }; - - // Check if repository_url matches a daemon's working directory (for this owner) - let is_daemon_working_dir = repository_url.as_ref().map(|url| { - state.daemon_connections.iter().any(|entry| { - entry.value().owner_id == owner_id && - entry.value().working_directory.as_ref() == Some(url) - }) - }).unwrap_or(false); - - // Derive completion_action from merge_mode, or default to "branch" if using daemon working dir - let (completion_action, target_repo_path) = if let Some(ref mode) = merge_mode { - // Explicit merge_mode provided - derive from it - let action = match mode.as_str() { - "pr" => "pr".to_string(), - "auto" => "merge".to_string(), - "manual" => "branch".to_string(), - _ => "none".to_string(), - }; - // If using daemon working dir and action involves the repo, set target_repo_path - let target = if is_daemon_working_dir && action != "none" { - repository_url.clone() - } else { - None - }; - (Some(action), target) - } else if is_daemon_working_dir { - // No merge_mode but using daemon working dir - default to "pr" - (Some("pr".to_string()), repository_url.clone()) - } else { - (None, None) - }; - - let create_req = CreateTaskRequest { - contract_id: Some(contract_id), - name: name.clone(), - description: None, - plan, - parent_task_id, - repository_url, - base_branch, - target_branch: None, - merge_mode, - priority: priority.unwrap_or(0), - target_repo_path, - completion_action, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, owner_id, create_req).await { - Ok(task) => MeshRequestResult { - success: true, - message: format!("Created task '{}' with ID {}", name, task.id), - data: Some(json!({ - "taskId": task.id, - "name": task.name, - "status": task.status, - })), - }, - Err(e) => MeshRequestResult { - success: false, - message: format!("Failed to create task: {}", e), - data: None, - }, - } - } - - MeshToolRequest::RunTask { task_id, daemon_id } => { - // Get task to check status - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return MeshRequestResult { - success: false, - message: format!("Task {} not found", task_id), - data: None, - } - } - Err(e) => { - return MeshRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - if task.status != "pending" && task.status != "paused" { - return MeshRequestResult { - success: false, - message: format!( - "Task cannot be run - status is '{}' (must be 'pending' or 'paused')", - task.status - ), - data: None, - }; - } - - // Find a daemon to run on (must belong to this owner) - let target_daemon_id = if let Some(id) = daemon_id { - // Verify the specified daemon belongs to this owner - if !state.daemon_connections.iter().any(|d| d.value().id == id && d.value().owner_id == owner_id) { - return MeshRequestResult { - success: false, - message: "Specified daemon not found or not accessible.".to_string(), - data: None, - }; - } - id - } else { - // Find any connected daemon for this owner - let daemon = state.daemon_connections.iter().find(|d| d.value().owner_id == owner_id); - match daemon { - Some(d) => d.value().id, - None => { - return MeshRequestResult { - success: false, - message: "No daemons connected for your account. Cannot run task.".to_string(), - data: None, - } - } - } - }; - - // Check if this is an orchestrator (depth 0 with subtasks) - let subtask_count = match repository::list_subtasks_for_owner(pool, task_id, owner_id).await { - Ok(subtasks) => subtasks.len(), - Err(_) => 0, - }; - let is_orchestrator = task.depth == 0 && subtask_count > 0; - - // IMPORTANT: Update database FIRST to assign daemon_id before sending command - // This prevents race conditions where the task starts but daemon_id is not set - let update_req = crate::db::models::UpdateTaskRequest { - status: Some("starting".to_string()), - daemon_id: Some(target_daemon_id), - version: Some(task.version), - ..Default::default() - }; - - let updated_task = match repository::update_task_for_owner(pool, task_id, owner_id, update_req).await { - Ok(Some(t)) => t, - Ok(None) => { - return MeshRequestResult { - success: false, - message: format!("Task {} not found", task_id), - data: None, - } - } - Err(e) => { - return MeshRequestResult { - success: false, - message: format!("Failed to update task: {}", e), - data: None, - } - } - }; - - // Get local_only and auto_merge_local from contract if task has one - let (local_only, auto_merge_local) = if let Some(contract_id) = task.contract_id { - match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(contract)) => (contract.local_only, contract.auto_merge_local), - _ => (false, false), - } - } else { - (false, false) - }; - - // Send SpawnTask command to daemon - let command = DaemonCommand::SpawnTask { - task_id, - task_name: task.name.clone(), - plan: task.plan.clone(), - repo_url: task.repository_url.clone(), - base_branch: task.base_branch.clone(), - target_branch: task.target_branch.clone(), - parent_task_id: task.parent_task_id, - depth: task.depth, - is_orchestrator, - target_repo_path: task.target_repo_path.clone(), - completion_action: task.completion_action.clone(), - continue_from_task_id: task.continue_from_task_id, - copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()), - contract_id: task.contract_id, - is_supervisor: task.is_supervisor, - autonomous_loop: false, - resume_session: false, - conversation_history: None, - patch_data: None, - patch_base_sha: None, - local_only, - auto_merge_local, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: task.directive_id, - }; - - match state.send_daemon_command(target_daemon_id, command).await { - Ok(()) => { - state.broadcast_task_update(TaskUpdateNotification { - task_id, - owner_id: Some(task.owner_id), - version: updated_task.version, - status: "starting".to_string(), - updated_fields: vec!["status".to_string(), "daemon_id".to_string()], - updated_by: "system".to_string(), - }); - - MeshRequestResult { - success: true, - message: format!("Task {} is now running on daemon {}", task_id, target_daemon_id), - data: Some(json!({ - "taskId": task_id, - "daemonId": target_daemon_id, - "status": "starting", - })), - } - } - Err(e) => { - // Rollback: clear daemon_id and reset status since command failed - let rollback_req = crate::db::models::UpdateTaskRequest { - status: Some("pending".to_string()), - clear_daemon_id: true, - ..Default::default() - }; - let _ = repository::update_task_for_owner(pool, task_id, owner_id, rollback_req).await; - - MeshRequestResult { - success: false, - message: format!("Failed to start task: {}", e), - data: None, - } - } - } - } - - MeshToolRequest::PauseTask { task_id } => { - // Get task and its daemon - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return MeshRequestResult { - success: false, - message: format!("Task {} not found", task_id), - data: None, - } - } - Err(e) => { - return MeshRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - if task.status != "running" { - return MeshRequestResult { - success: false, - message: format!("Task is not running (status: {})", task.status), - data: None, - }; - } - - if let Some(daemon_id) = task.daemon_id { - let command = DaemonCommand::PauseTask { task_id }; - if let Err(e) = state.send_daemon_command(daemon_id, command).await { - return MeshRequestResult { - success: false, - message: format!("Failed to pause task: {}", e), - data: None, - }; - } - } - - // Update status - let update_req = crate::db::models::UpdateTaskRequest { - status: Some("paused".to_string()), - version: Some(task.version), - ..Default::default() - }; - - if let Ok(Some(updated)) = repository::update_task_for_owner(pool, task_id, owner_id, update_req).await { - state.broadcast_task_update(TaskUpdateNotification { - task_id, - owner_id: Some(task.owner_id), - version: updated.version, - status: "paused".to_string(), - updated_fields: vec!["status".to_string()], - updated_by: "system".to_string(), - }); - } - - MeshRequestResult { - success: true, - message: format!("Task {} paused", task_id), - data: Some(json!({ "taskId": task_id, "status": "paused" })), - } - } - - MeshToolRequest::ResumeTask { task_id } => { - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return MeshRequestResult { - success: false, - message: format!("Task {} not found", task_id), - data: None, - } - } - Err(e) => { - return MeshRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - if task.status != "paused" { - return MeshRequestResult { - success: false, - message: format!("Task is not paused (status: {})", task.status), - data: None, - }; - } - - if let Some(daemon_id) = task.daemon_id { - let command = DaemonCommand::ResumeTask { task_id }; - if let Err(e) = state.send_daemon_command(daemon_id, command).await { - return MeshRequestResult { - success: false, - message: format!("Failed to resume task: {}", e), - data: None, - }; - } - } - - // Update status - let update_req = crate::db::models::UpdateTaskRequest { - status: Some("running".to_string()), - version: Some(task.version), - ..Default::default() - }; - - if let Ok(Some(updated)) = repository::update_task_for_owner(pool, task_id, owner_id, update_req).await { - state.broadcast_task_update(TaskUpdateNotification { - task_id, - owner_id: Some(task.owner_id), - version: updated.version, - status: "running".to_string(), - updated_fields: vec!["status".to_string()], - updated_by: "system".to_string(), - }); - } - - MeshRequestResult { - success: true, - message: format!("Task {} resumed", task_id), - data: Some(json!({ "taskId": task_id, "status": "running" })), - } - } - - MeshToolRequest::InterruptTask { task_id, graceful } => { - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return MeshRequestResult { - success: false, - message: format!("Task {} not found", task_id), - data: None, - } - } - Err(e) => { - return MeshRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - if let Some(daemon_id) = task.daemon_id { - let command = DaemonCommand::InterruptTask { task_id, graceful }; - if let Err(e) = state.send_daemon_command(daemon_id, command).await { - return MeshRequestResult { - success: false, - message: format!("Failed to interrupt task: {}", e), - data: None, - }; - } - } - - // Update status - let update_req = crate::db::models::UpdateTaskRequest { - status: Some("paused".to_string()), - version: Some(task.version), - ..Default::default() - }; - - if let Ok(Some(updated)) = repository::update_task_for_owner(pool, task_id, owner_id, update_req).await { - state.broadcast_task_update(TaskUpdateNotification { - task_id, - owner_id: Some(task.owner_id), - version: updated.version, - status: "paused".to_string(), - updated_fields: vec!["status".to_string()], - updated_by: "system".to_string(), - }); - } - - MeshRequestResult { - success: true, - message: format!( - "Task {} {}interrupted", - task_id, - if graceful { "gracefully " } else { "" } - ), - data: Some(json!({ "taskId": task_id, "status": "paused" })), - } - } - - MeshToolRequest::DiscardTask { task_id } => { - match repository::delete_task_for_owner(pool, task_id, owner_id).await { - Ok(true) => MeshRequestResult { - success: true, - message: format!("Task {} discarded", task_id), - data: Some(json!({ "taskId": task_id, "deleted": true })), - }, - Ok(false) => MeshRequestResult { - success: false, - message: format!("Task {} not found", task_id), - data: None, - }, - Err(e) => MeshRequestResult { - success: false, - message: format!("Failed to delete task: {}", e), - data: None, - }, - } - } - - MeshToolRequest::QueryTaskStatus { task_id } => { - match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(task)) => MeshRequestResult { - success: true, - message: format!("Task '{}' is {}", task.name, task.status), - data: Some(json!({ - "taskId": task.id, - "name": task.name, - "status": task.status, - "priority": task.priority, - "description": task.description, - "plan": task.plan, - "progressSummary": task.progress_summary, - "errorMessage": task.error_message, - "repositoryUrl": task.repository_url, - "baseBranch": task.base_branch, - "targetBranch": task.target_branch, - "mergeMode": task.merge_mode, - "prUrl": task.pr_url, - "daemonId": task.daemon_id, - "containerId": task.container_id, - "createdAt": task.created_at, - "startedAt": task.started_at, - "completedAt": task.completed_at, - })), - }, - Ok(None) => MeshRequestResult { - success: false, - message: format!("Task {} not found", task_id), - data: None, - }, - Err(e) => MeshRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - MeshToolRequest::ListTasks { - status_filter, - parent_task_id, - } => { - // TODO: Add filtering support to repository - match repository::list_tasks_for_owner(pool, owner_id).await { - Ok(mut tasks) => { - // Apply filters - if let Some(ref status) = status_filter { - tasks.retain(|t| &t.status == status); - } - if let Some(ref parent_id) = parent_task_id { - tasks.retain(|t| t.parent_task_id.as_ref() == Some(parent_id)); - } - - let task_data: Vec<serde_json::Value> = tasks - .iter() - .map(|t| { - json!({ - "taskId": t.id, - "name": t.name, - "status": t.status, - "priority": t.priority, - "parentTaskId": t.parent_task_id, - }) - }) - .collect(); - - MeshRequestResult { - success: true, - message: format!("Found {} tasks", tasks.len()), - data: Some(json!({ "tasks": task_data })), - } - } - Err(e) => MeshRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - MeshToolRequest::ListSubtasks { task_id } => { - match repository::list_subtasks_for_owner(pool, task_id, owner_id).await { - Ok(subtasks) => { - let subtask_data: Vec<serde_json::Value> = subtasks - .iter() - .map(|t| { - json!({ - "taskId": t.id, - "name": t.name, - "status": t.status, - "priority": t.priority, - }) - }) - .collect(); - - MeshRequestResult { - success: true, - message: format!("Found {} subtasks", subtasks.len()), - data: Some(json!({ "subtasks": subtask_data })), - } - } - Err(e) => MeshRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - MeshToolRequest::ListSiblings { task_id } => { - // Get task to find parent - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return MeshRequestResult { - success: false, - message: format!("Task {} not found", task_id), - data: None, - } - } - Err(e) => { - return MeshRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - let Some(parent_id) = task.parent_task_id else { - return MeshRequestResult { - success: true, - message: "Task has no parent, so no siblings".to_string(), - data: Some(json!({ "siblings": [] })), - }; - }; - - // Get all subtasks of parent, excluding current task - match repository::list_subtasks_for_owner(pool, parent_id, owner_id).await { - Ok(siblings) => { - let sibling_data: Vec<serde_json::Value> = siblings - .iter() - .filter(|t| t.id != task_id) - .map(|t| { - json!({ - "taskId": t.id, - "name": t.name, - "status": t.status, - "priority": t.priority, - }) - }) - .collect(); - - MeshRequestResult { - success: true, - message: format!("Found {} sibling tasks", sibling_data.len()), - data: Some(json!({ "siblings": sibling_data })), - } - } - Err(e) => MeshRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - MeshToolRequest::ListDaemons => { - // Only list daemons belonging to this owner - let daemons: Vec<serde_json::Value> = state - .daemon_connections - .iter() - .filter(|entry| entry.value().owner_id == owner_id) - .map(|entry| { - let d = entry.value(); - json!({ - "daemonId": d.id, - "connectionId": d.connection_id, - "hostname": d.hostname, - "machineId": d.machine_id, - }) - }) - .collect(); - - MeshRequestResult { - success: true, - message: format!("{} daemon(s) connected", daemons.len()), - data: Some(json!({ "daemons": daemons })), - } - } - - MeshToolRequest::ListDaemonDirectories => { - let mut directories: Vec<serde_json::Value> = Vec::new(); - - // Only list directories from daemons belonging to this owner - for entry in state.daemon_connections.iter() { - let daemon = entry.value(); - - // Only include daemons belonging to this owner - if daemon.owner_id != owner_id { - continue; - } - - // Add working directory if available - if let Some(ref working_dir) = daemon.working_directory { - directories.push(json!({ - "path": working_dir, - "label": "Working Directory", - "directoryType": "working", - "hostname": daemon.hostname, - })); - } - - // Add home directory if available - if let Some(ref home_dir) = daemon.home_directory { - directories.push(json!({ - "path": home_dir, - "label": "Makima Home", - "directoryType": "home", - "hostname": daemon.hostname, - })); - } - } - - MeshRequestResult { - success: true, - message: format!("Found {} available directories", directories.len()), - data: Some(json!({ "directories": directories })), - } - } - - MeshToolRequest::ListFiles => { - match repository::list_files_for_owner(pool, owner_id).await { - Ok(files) => { - let file_data: Vec<serde_json::Value> = files - .iter() - .map(|f| { - json!({ - "fileId": f.id, - "name": f.name, - "description": f.description, - "createdAt": f.created_at, - "updatedAt": f.updated_at, - }) - }) - .collect(); - - MeshRequestResult { - success: true, - message: format!("Found {} files", files.len()), - data: Some(json!({ "files": file_data })), - } - } - Err(e) => MeshRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - MeshToolRequest::ReadFile { file_id } => { - match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(file)) => { - // Convert body elements to readable text - let body_content: Vec<serde_json::Value> = file - .body - .iter() - .map(|elem| { - match elem { - crate::db::models::BodyElement::Heading { level, text } => { - json!({ "type": "heading", "level": level, "text": text }) - } - crate::db::models::BodyElement::Paragraph { text } => { - json!({ "type": "paragraph", "text": text }) - } - crate::db::models::BodyElement::Code { language, content } => { - json!({ "type": "code", "language": language, "content": content }) - } - crate::db::models::BodyElement::List { ordered, items } => { - json!({ "type": "list", "ordered": ordered, "items": items }) - } - crate::db::models::BodyElement::Chart { chart_type, title, data, config: _ } => { - let data_count = data.as_array().map(|arr| arr.len()).unwrap_or(0); - json!({ "type": "chart", "chartType": chart_type, "title": title, "dataPoints": data_count }) - } - crate::db::models::BodyElement::Image { src, alt, caption } => { - json!({ "type": "image", "src": src, "alt": alt, "caption": caption }) - } - crate::db::models::BodyElement::Markdown { content } => { - json!({ "type": "markdown", "content": content }) - } - } - }) - .collect(); - - // Also build a plain text version for easier reading - let plain_text: String = file - .body - .iter() - .filter_map(|elem| { - match elem { - crate::db::models::BodyElement::Heading { level, text } => { - Some(format!("{} {}", "#".repeat(*level as usize), text)) - } - crate::db::models::BodyElement::Paragraph { text } => { - Some(text.clone()) - } - crate::db::models::BodyElement::Code { language, content } => { - let lang = language.as_deref().unwrap_or(""); - Some(format!("```{}\n{}\n```", lang, content)) - } - crate::db::models::BodyElement::List { ordered, items } => { - let list_text: Vec<String> = items.iter().enumerate().map(|(i, item)| { - if *ordered { - format!("{}. {}", i + 1, item) - } else { - format!("- {}", item) - } - }).collect(); - Some(list_text.join("\n")) - } - crate::db::models::BodyElement::Markdown { content } => { - Some(content.clone()) - } - _ => None, - } - }) - .collect::<Vec<_>>() - .join("\n\n"); - - // Convert transcript entries to JSON - let transcript: Vec<serde_json::Value> = file - .transcript - .iter() - .map(|entry| { - json!({ - "id": entry.id, - "speaker": entry.speaker, - "start": entry.start, - "end": entry.end, - "text": entry.text, - }) - }) - .collect(); - - // Build a plain text transcript for easier reading - let transcript_text: String = file - .transcript - .iter() - .map(|entry| { - format!("[{:.1}s] {}: {}", entry.start, entry.speaker, entry.text) - }) - .collect::<Vec<_>>() - .join("\n"); - - MeshRequestResult { - success: true, - message: format!("Read file '{}'", file.name), - data: Some(json!({ - "fileId": file.id, - "name": file.name, - "description": file.description, - "summary": file.summary, - "body": body_content, - "plainText": plain_text, - "transcript": transcript, - "transcriptText": transcript_text, - "transcriptCount": file.transcript.len(), - "createdAt": file.created_at, - "updatedAt": file.updated_at, - })), - } - } - Ok(None) => MeshRequestResult { - success: false, - message: format!("File {} not found", file_id), - data: None, - }, - Err(e) => MeshRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - MeshToolRequest::SendMessageToTask { task_id, message } => { - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return MeshRequestResult { - success: false, - message: format!("Task {} not found", task_id), - data: None, - } - } - Err(e) => { - return MeshRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - if task.status != "running" { - return MeshRequestResult { - success: false, - message: format!("Task is not running (status: {})", task.status), - data: None, - }; - } - - if let Some(daemon_id) = task.daemon_id { - let command = DaemonCommand::SendMessage { task_id, message }; - match state.send_daemon_command(daemon_id, command).await { - Ok(()) => MeshRequestResult { - success: true, - message: "Message sent to task".to_string(), - data: Some(json!({ "taskId": task_id })), - }, - Err(e) => MeshRequestResult { - success: false, - message: format!("Failed to send message: {}", e), - data: None, - }, - } - } else { - MeshRequestResult { - success: false, - message: "Task has no daemon assigned".to_string(), - data: None, - } - } - } - - MeshToolRequest::UpdateTaskPlan { - task_id, - new_plan, - interrupt_if_running, - } => { - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return MeshRequestResult { - success: false, - message: format!("Task {} not found", task_id), - data: None, - } - } - Err(e) => { - return MeshRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - // Interrupt if running and requested - if task.status == "running" && interrupt_if_running { - if let Some(daemon_id) = task.daemon_id { - let command = DaemonCommand::InterruptTask { - task_id, - graceful: true, - }; - let _ = state.send_daemon_command(daemon_id, command).await; - } - } - - let update_req = crate::db::models::UpdateTaskRequest { - plan: Some(new_plan), - version: Some(task.version), - ..Default::default() - }; - - match repository::update_task_for_owner(pool, task_id, owner_id, update_req).await { - Ok(Some(updated)) => { - state.broadcast_task_update(TaskUpdateNotification { - task_id, - owner_id: Some(task.owner_id), - version: updated.version, - status: updated.status.clone(), - updated_fields: vec!["plan".to_string()], - updated_by: "system".to_string(), - }); - MeshRequestResult { - success: true, - message: "Task plan updated".to_string(), - data: Some(json!({ "taskId": task_id })), - } - } - Ok(None) => MeshRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - }, - Err(e) => MeshRequestResult { - success: false, - message: format!("Failed to update task: {}", e), - data: None, - }, - } - } - - // Overlay operations - these require daemon communication - // For now, return placeholder responses since daemon implementation is separate - MeshToolRequest::PeekSiblingOverlay { sibling_task_id } => MeshRequestResult { - success: false, - message: format!( - "Overlay operations require a connected daemon. Task {} may not have overlay data yet.", - sibling_task_id - ), - data: None, - }, - - MeshToolRequest::GetOverlayDiff { task_id } => MeshRequestResult { - success: false, - message: format!( - "Overlay operations require a connected daemon. Task {} may not have overlay data yet.", - task_id - ), - data: None, - }, - - MeshToolRequest::PreviewMerge { task_id } => MeshRequestResult { - success: false, - message: format!( - "Merge preview requires a connected daemon. Task {} may not have overlay data yet.", - task_id - ), - data: None, - }, - - MeshToolRequest::MergeSubtask { task_id } => MeshRequestResult { - success: false, - message: format!( - "Merge operations require a connected daemon. Task {}", - task_id - ), - data: None, - }, - - MeshToolRequest::CompleteTask { task_id } => { - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return MeshRequestResult { - success: false, - message: format!("Task {} not found", task_id), - data: None, - } - } - Err(e) => { - return MeshRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - // Update status to done - let update_req = crate::db::models::UpdateTaskRequest { - status: Some("done".to_string()), - version: Some(task.version), - ..Default::default() - }; - - match repository::update_task_for_owner(pool, task_id, owner_id, update_req).await { - Ok(Some(updated)) => { - state.broadcast_task_update(TaskUpdateNotification { - task_id, - owner_id: Some(task.owner_id), - version: updated.version, - status: "done".to_string(), - updated_fields: vec!["status".to_string()], - updated_by: "system".to_string(), - }); - let merge_mode = task.merge_mode.unwrap_or_else(|| "pr".to_string()); - MeshRequestResult { - success: true, - message: format!( - "Task {} completed. Merge mode: {}", - task_id, - &merge_mode - ), - data: Some(json!({ - "taskId": task_id, - "status": "done", - "mergeMode": merge_mode, - })), - } - } - Ok(None) => MeshRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - }, - Err(e) => MeshRequestResult { - success: false, - message: format!("Failed to complete task: {}", e), - data: None, - }, - } - } - - MeshToolRequest::SetMergeMode { task_id, mode } => { - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return MeshRequestResult { - success: false, - message: format!("Task {} not found", task_id), - data: None, - } - } - Err(e) => { - return MeshRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - let update_req = crate::db::models::UpdateTaskRequest { - merge_mode: Some(mode.clone()), - version: Some(task.version), - ..Default::default() - }; - - match repository::update_task_for_owner(pool, task_id, owner_id, update_req).await { - Ok(Some(updated)) => { - state.broadcast_task_update(TaskUpdateNotification { - task_id, - owner_id: Some(task.owner_id), - version: updated.version, - status: updated.status, - updated_fields: vec!["merge_mode".to_string()], - updated_by: "system".to_string(), - }); - MeshRequestResult { - success: true, - message: format!("Merge mode set to '{}'", mode), - data: Some(json!({ "taskId": task_id, "mergeMode": mode })), - } - } - Ok(None) => MeshRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - }, - Err(e) => MeshRequestResult { - success: false, - message: format!("Failed to update merge mode: {}", e), - data: None, - }, - } - } - - // Supervisor-only tools - these should be handled via the supervisor.sh script, - // not through the mesh chat. Return an informative error. - MeshToolRequest::GetAllContractTasks { contract_id } => { - MeshRequestResult { - success: false, - message: format!( - "get_all_contract_tasks is a supervisor-only tool. Use supervisor.sh to access this functionality. Contract: {}", - contract_id - ), - data: None, - } - } - MeshToolRequest::WaitForTaskCompletion { task_id, timeout_seconds } => { - MeshRequestResult { - success: false, - message: format!( - "wait_for_task_completion is a supervisor-only tool. Use supervisor.sh to access this functionality. Task: {}, Timeout: {}s", - task_id, timeout_seconds - ), - data: None, - } - } - MeshToolRequest::ReadTaskWorktree { task_id, file_path } => { - MeshRequestResult { - success: false, - message: format!( - "read_task_worktree is a supervisor-only tool. Use supervisor.sh to access this functionality. Task: {}, Path: {}", - task_id, file_path - ), - data: None, - } - } - MeshToolRequest::SpawnTask { name, .. } => { - MeshRequestResult { - success: false, - message: format!( - "spawn_task is a supervisor-only tool. Only the contract supervisor can spawn new tasks. Task name: {}", - name - ), - data: None, - } - } - MeshToolRequest::CreateCheckpoint { task_id, message } => { - MeshRequestResult { - success: false, - message: format!( - "create_checkpoint is a supervisor-only tool. Use supervisor.sh to access this functionality. Task: {}, Message: {}", - task_id, message - ), - data: None, - } - } - MeshToolRequest::ListTaskCheckpoints { task_id } => { - MeshRequestResult { - success: false, - message: format!( - "list_task_checkpoints is a supervisor-only tool. Use supervisor.sh to access this functionality. Task: {}", - task_id - ), - data: None, - } - } - MeshToolRequest::GetTaskTree { task_id } => { - MeshRequestResult { - success: false, - message: format!( - "get_task_tree is a supervisor-only tool. Use supervisor.sh to access this functionality. Task: {}", - task_id - ), - data: None, - } - } - } -} - -// ============================================================================= -// Chat History Endpoints -// ============================================================================= - -use crate::db::models::MeshChatHistoryResponse; - -/// Get chat history for the current conversation (requires authentication) -#[utoipa::path( - get, - path = "/api/v1/mesh/chat/history", - responses( - (status = 200, description = "Chat history", body = MeshChatHistoryResponse), - (status = 401, description = "Unauthorized"), - (status = 503, description = "Database not configured"), - (status = 500, description = "Internal server error") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Mesh" -)] -pub async fn get_chat_history( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - let conversation = match repository::get_or_create_active_conversation(pool, auth.owner_id).await { - Ok(c) => c, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": e.to_string() })), - ) - .into_response() - } - }; - - let messages = match repository::list_chat_messages(pool, conversation.id, None).await { - Ok(m) => m, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": e.to_string() })), - ) - .into_response() - } - }; - - ( - StatusCode::OK, - Json(MeshChatHistoryResponse { - conversation_id: conversation.id, - messages, - }), - ) - .into_response() -} - -/// Clear chat history (archives current conversation and starts new, requires authentication) -#[utoipa::path( - delete, - path = "/api/v1/mesh/chat/history", - responses( - (status = 200, description = "History cleared"), - (status = 401, description = "Unauthorized"), - (status = 503, description = "Database not configured"), - (status = 500, description = "Internal server error") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Mesh" -)] -pub async fn clear_chat_history( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - match repository::clear_conversation(pool, auth.owner_id).await { - Ok(new_conv) => ( - StatusCode::OK, - Json(json!({ "success": true, "conversationId": new_conv.id })), - ) - .into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": e.to_string() })), - ) - .into_response(), - } -} diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs index e5f0a81..19d2166 100644 --- a/makima/src/server/handlers/mesh_daemon.rs +++ b/makima/src/server/handlers/mesh_daemon.rs @@ -24,7 +24,6 @@ use uuid::Uuid; use crate::db::models::Task; use crate::db::repository; -use crate::llm::{check_deliverables_met, TaskInfo}; use crate::server::auth::{hash_api_key, API_KEY_HEADER}; use crate::server::messages::ApiError; use crate::server::state::{ @@ -609,71 +608,12 @@ struct DaemonAuthResult { owner_id: Uuid, } -/// Compute an action directive for the supervisor based on deliverable status. -/// Returns an [ACTION REQUIRED] message if all deliverables are met. -async fn compute_action_directive( - pool: &sqlx::PgPool, - contract_id: Uuid, - owner_id: Uuid, -) -> Option<String> { - // Get contract - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - _ => return None, - }; - - // Get tasks (non-supervisor only) - let tasks = match repository::list_tasks_by_contract(pool, contract_id, owner_id).await { - Ok(t) => t.into_iter().filter(|t| !t.is_supervisor).collect::<Vec<_>>(), - _ => return None, - }; - - // Get repositories - let repos = match repository::list_contract_repositories(pool, contract_id).await { - Ok(r) => r, - _ => return None, - }; - - // Get completed deliverables for the current phase - let completed_deliverables = contract.get_completed_deliverables(&contract.phase); - - let task_infos: Vec<TaskInfo> = tasks - .iter() - .map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }) - .collect(); - - let has_repository = !repos.is_empty(); - - // Check deliverables (unused, but kept for future reference) - let _check = check_deliverables_met( - &contract.phase, - &contract.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - ); - - // Generate directive based on deliverable status - if contract.phase == "execute" { - // Check if all tasks are done but PR deliverable is not marked complete - let all_tasks_done = !task_infos.is_empty() - && task_infos.iter().all(|t| t.status == "done"); - let pr_deliverable_complete = completed_deliverables.contains(&"pull-request".to_string()); - - if all_tasks_done && !pr_deliverable_complete { - let done_count = task_infos.len(); - return Some(format!( - "[INFO] All {} task(s) completed. System is auto-creating PR.", - done_count - )); - } - } - - None -} +// compute_action_directive removed alongside the LLM module — it used +// check_deliverables_met / TaskInfo from src/llm/phase_guidance.rs to +// nudge the supervisor with an "[INFO] all N tasks completed" message +// in the execute phase. Supervisors now receive `None` for the +// action_directive field; the auto-PR path below still fires when +// every non-supervisor task is done, so no behaviour is lost. /// Automatically create a PR when all non-supervisor tasks for a contract are done. /// Only applies to remote-repo contracts in the "execute" phase. @@ -1394,13 +1334,11 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re // Don't notify for supervisor tasks (they don't report to themselves) if !updated_task.is_supervisor { if let Ok(Some(supervisor)) = repository::get_contract_supervisor_task(&pool, contract_id).await { - // Compute action directive if task completed successfully - let action_directive = if updated_task.status == "done" { - compute_action_directive(&pool, contract_id, owner_id).await - } else { - None - }; - + // action_directive used to come from + // compute_action_directive (now removed alongside the + // LLM module). Passing None preserves the existing + // supervisor protocol; the auto-PR path below still + // fires when every task is done. state.notify_supervisor_of_task_completion( supervisor.id, supervisor.daemon_id, @@ -1409,7 +1347,7 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re &updated_task.status, updated_task.progress_summary.as_deref(), updated_task.error_message.as_deref(), - action_directive.as_deref(), + None, ).await; } } @@ -1812,8 +1750,14 @@ async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_re // The request_id is the file_id we want to update if success { if let (Some(pool), Some(content)) = (&state.db_pool, content) { - // Convert markdown to body elements - let body = crate::llm::markdown_to_body(&content); + // Markdown → body. The full markdown parser lived in the + // (deleted) LLM module; we now wrap the raw markdown in a + // single Markdown body element so File records still round-trip. + // Lossless for the daemon-fetch flow because the editor + // re-parses the markdown content on display. + let body = vec![crate::db::models::BodyElement::Markdown { + content: content.clone(), + }]; // Update file in database let update_req = crate::db::models::UpdateFileRequest { diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index a39a4c0..5737360 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -2,9 +2,12 @@ //! //! Phase 5 removed: contract_chat, contract_daemon, contract_discuss, //! contracts, transcript_analysis. Contracts subsystem is gone. +//! +//! LLM removal removed: chat, mesh_chat, templates. LLM module is gone; +//! the chat-based UIs (file chat, mesh chat, discuss-contract, +//! contract-type templates) were the only consumers. pub mod api_keys; -pub mod chat; pub mod daemon_download; pub mod directive_documents; pub mod directives; @@ -13,7 +16,6 @@ pub mod files; pub mod history; pub mod listen; pub mod mesh; -pub mod mesh_chat; pub mod orders; pub mod mesh_daemon; pub mod mesh_merge; @@ -21,7 +23,6 @@ pub mod mesh_supervisor; pub mod mesh_ws; pub mod repository_history; pub mod speak; -pub mod templates; pub mod voice; pub mod users; pub mod versions; diff --git a/makima/src/server/handlers/templates.rs b/makima/src/server/handlers/templates.rs deleted file mode 100644 index aa97876..0000000 --- a/makima/src/server/handlers/templates.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Contract types API handler. -//! Only returns built-in contract types (simple, specification, execute). - -use axum::{ - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::Serialize; -use utoipa::ToSchema; - -use crate::llm::templates; -use crate::llm::templates::ContractTypeTemplate; - -// ============================================================================= -// Contract Type Templates (Built-in Only) -// ============================================================================= - -/// Response for listing contract types -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ListContractTypesResponse { - pub contract_types: Vec<ContractTypeTemplate>, -} - -/// List all available contract type templates (built-in only) -#[utoipa::path( - get, - path = "/api/v1/contract-types", - responses( - (status = 200, description = "Contract types retrieved successfully", body = ListContractTypesResponse) - ), - tag = "templates" -)] -pub async fn list_contract_types() -> impl IntoResponse { - // Only return built-in types (simple, specification, execute) - let contract_types = templates::all_contract_types(); - ( - StatusCode::OK, - Json(ListContractTypesResponse { contract_types }), - ) - .into_response() -} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index a6c7787..bd48a8f 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, daemon_download, directive_documents, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, users, versions}; +use crate::server::handlers::{api_keys, daemon_download, directive_documents, directives, file_ws, files, history, listen, mesh, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -55,7 +55,6 @@ pub fn make_router(state: SharedState) -> Router { .put(files::update_file) .delete(files::delete_file), ) - .route("/files/{id}/chat", post(chat::chat_handler)) .route("/files/{id}/sync-from-repo", post(files::sync_file_from_repo)) // Version history endpoints .route("/files/{id}/versions", get(versions::list_versions)) @@ -88,12 +87,6 @@ pub fn make_router(state: SharedState) -> Router { .route("/mesh/tasks/{id}/check-target", post(mesh::check_target_exists)) .route("/mesh/tasks/{id}/reassign", post(mesh::reassign_task)) .route("/mesh/tasks/{id}/continue", post(mesh::continue_task)) - .route("/mesh/chat", post(mesh_chat::mesh_toplevel_chat_handler)) - .route( - "/mesh/chat/history", - get(mesh_chat::get_chat_history).delete(mesh_chat::clear_chat_history), - ) - .route("/mesh/tasks/{id}/chat", post(mesh_chat::mesh_chat_handler)) .route("/mesh/daemons", get(mesh::list_daemons)) .route("/mesh/daemons/directories", get(mesh::get_daemon_directories)) .route("/mesh/daemons/{id}", get(mesh::get_daemon)) @@ -279,8 +272,6 @@ pub fn make_router(state: SharedState) -> Router { .route("/orders/{id}/convert-to-step", post(orders::convert_to_step)) // Timeline endpoint (unified history for user) .route("/timeline", get(history::get_timeline)) - // Contract type templates (built-in only) - .route("/contract-types", get(templates::list_contract_types)) // Settings endpoints .route( "/settings/repository-history", diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 5bbd0fe..13ba787 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -6,7 +6,7 @@ use crate::db::models::{ AddLocalRepositoryRequest, AddRemoteRepositoryRequest, BranchInfo, BranchListResponse, BranchTaskRequest, BranchTaskResponse, ChangePhaseRequest, - Contract, ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent, + Contract, ContractEvent, ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, CleanupResponse, CreateContractRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest, @@ -31,7 +31,7 @@ use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, }; -use crate::server::handlers::{api_keys, directive_documents, directives, files, listen, mesh, mesh_chat, mesh_merge, orders, repository_history, users}; +use crate::server::handlers::{api_keys, directive_documents, directives, files, listen, mesh, mesh_merge, orders, repository_history, users}; use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage}; #[derive(OpenApi)] @@ -70,8 +70,6 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage mesh::check_target_exists, mesh::get_task_patch_data, mesh::branch_task, - mesh_chat::get_chat_history, - mesh_chat::clear_chat_history, // Merge endpoints mesh_merge::list_branches, mesh_merge::merge_start, |
