summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--makima/docs/PLAN-discuss-contract.md953
-rw-r--r--makima/frontend/src/components/listen/ContractPickerModal.tsx15
-rw-r--r--makima/frontend/src/components/listen/ControlPanel.tsx3
-rw-r--r--makima/frontend/src/components/listen/DiscussContractModal.tsx287
-rw-r--r--makima/frontend/src/lib/api.ts54
-rw-r--r--makima/frontend/src/routes/listen.tsx38
-rw-r--r--makima/src/llm/discuss_tools.rs210
-rw-r--r--makima/src/llm/mod.rs4
-rw-r--r--makima/src/server/handlers/contract_discuss.rs592
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/mod.rs3
-rw-r--r--makima/src/server/openapi.rs10
12 files changed, 2167 insertions, 3 deletions
diff --git a/makima/docs/PLAN-discuss-contract.md b/makima/docs/PLAN-discuss-contract.md
new file mode 100644
index 0000000..7543bdb
--- /dev/null
+++ b/makima/docs/PLAN-discuss-contract.md
@@ -0,0 +1,953 @@
+# Discuss Contract Feature - Implementation Plan
+
+## Overview
+
+Add a "Discuss Contract" feature to the listen page that enables users to have a natural conversation with Makima (in character) to flesh out and ultimately create a contract specification. This feature bridges the gap between raw transcript/requirements and a well-defined contract through interactive dialogue.
+
+**Goal:** Enable users to talk through their project idea with Makima, refine requirements conversationally, and then have the LLM create a properly-structured contract when the conversation reaches a natural conclusion.
+
+## Key Design Principles
+
+1. **Ephemeral conversation** - Does not require an existing contract (creates one at the end)
+2. **Character-aware** - Makima responds in character, aware of the makima.jp platform
+3. **Contextual** - Can incorporate transcript context from the current session
+4. **Audible** - Each response can be spoken using the TTS functionality
+5. **Natural conclusion** - The LLM decides when to create the contract via tool use
+6. **Reuse existing infrastructure** - Leverage chatWithContract API patterns and useSpeakWebSocket
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Listen Page │
+├─────────────────────────────────────────────────────────────────┤
+│ ControlPanel │
+│ ├── Contract Dropdown │
+│ │ ├── Ephemeral │
+│ │ ├── [existing contracts...] │
+│ │ └── ** "Discuss Contract" ** <-- NEW OPTION │
+│ └── ... │
+├─────────────────────────────────────────────────────────────────┤
+│ DiscussContractModal (NEW) │
+│ ├── Conversation history (chat bubbles) │
+│ ├── Text input │
+│ ├── Speak button per response │
+│ └── Contract created notification │
+└─────────────────────────────────────────────────────────────────┘
+ │
+ │ POST /api/v1/contracts/discuss
+ ▼
+┌─────────────────────────────────────────────────────────────────┐
+│ Backend: discuss_contract_handler │
+├─────────────────────────────────────────────────────────────────┤
+│ 1. Receive message + history + optional transcript context │
+│ 2. Build Makima-character system prompt │
+│ 3. Run agentic loop with discussion tools │
+│ 4. When ready, call create_contract tool │
+│ 5. Return response + tool calls + created contract info │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+## Implementation Tasks
+
+---
+
+### Task 1: Backend - New Discussion Endpoint
+
+**File:** `makima/src/server/handlers/contract_discuss.rs` (NEW)
+
+Create a new handler for ephemeral contract discussions that doesn't require an existing contract.
+
+#### 1.1 Request/Response Types
+
+```rust
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DiscussContractRequest {
+ /// The user's message
+ pub message: String,
+ /// Optional model selection (default: claude-sonnet)
+ #[serde(default)]
+ pub model: Option<String>,
+ /// Conversation history for context continuity
+ #[serde(default)]
+ pub history: Option<Vec<ChatMessage>>,
+ /// Optional transcript context from current session
+ #[serde(default)]
+ pub transcript_context: Option<String>,
+}
+
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DiscussContractResponse {
+ /// Makima's response message
+ pub response: String,
+ /// Tool calls that were executed (e.g., create_contract)
+ pub tool_calls: Vec<ToolCallInfo>,
+ /// If a contract was created, its details
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub created_contract: Option<CreatedContractInfo>,
+ /// Pending questions (if LLM needs clarification)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub pending_questions: Option<Vec<UserQuestion>>,
+}
+
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreatedContractInfo {
+ pub id: String,
+ pub name: String,
+ pub description: Option<String>,
+ pub contract_type: String,
+ pub initial_phase: String,
+}
+```
+
+#### 1.2 System Prompt - Makima Character
+
+```rust
+const DISCUSS_SYSTEM_PROMPT: &str = r#"
+You are Makima, an AI assistant on the makima.jp platform. You help users define and create contracts for their projects through natural conversation.
+
+## Your Personality
+- Professional yet personable
+- Focused on understanding the user's actual needs
+- Ask clarifying questions when requirements are vague
+- Guide the conversation toward actionable outcomes
+- Comfortable making recommendations based on experience
+
+## Your Goal
+Help the user flesh out their project idea into a well-defined contract. A contract on makima.jp includes:
+- A clear name and description
+- The right contract type (simple, specification, or execute)
+- Understanding of the scope and requirements
+
+## Contract Types
+- **simple**: Quick tasks with minimal planning (plan -> execute phases only)
+- **specification**: Full lifecycle projects (research -> specify -> plan -> execute -> review)
+- **execute**: Direct implementation when requirements are already clear (execute phase only)
+
+## Guidelines
+1. **Start by understanding**: Ask about what they want to build
+2. **Clarify scope**: Is this a quick fix, a new feature, or a full project?
+3. **Gather requirements**: What are the must-haves vs nice-to-haves?
+4. **Identify context**: Is there existing code? Which repository?
+5. **Recommend type**: Suggest the appropriate contract type
+6. **Confirm and create**: When the user is satisfied, create the contract
+
+## When to Create the Contract
+Create the contract when:
+- You have a clear understanding of what the user wants
+- The user has confirmed they're ready to proceed
+- You've gathered enough information for a meaningful contract
+
+Do NOT create the contract if:
+- The user is still exploring ideas
+- Key information is missing
+- The user hasn't indicated readiness
+
+{transcript_context}
+"#;
+```
+
+#### 1.3 Tool Definitions
+
+**File:** `makima/src/llm/discuss_tools.rs` (NEW)
+
+```rust
+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"]
+ }),
+ },
+ ]
+});
+```
+
+#### 1.4 Handler Implementation
+
+```rust
+pub async fn discuss_contract_handler(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Json(request): Json<DiscussContractRequest>,
+) -> impl IntoResponse {
+ // 1. Build system prompt with optional transcript context
+ let transcript_section = match &request.transcript_context {
+ Some(ctx) => format!(
+ "\n## Current Session Context\nThe user has been recording a session. Here's the transcript:\n\n{}\n",
+ ctx
+ ),
+ None => String::new(),
+ };
+
+ let system_prompt = DISCUSS_SYSTEM_PROMPT.replace("{transcript_context}", &transcript_section);
+
+ // 2. Initialize LLM client
+ let llm_client = match model {
+ LlmModel::ClaudeSonnet => ClaudeClient::from_env(ClaudeModel::Sonnet)?,
+ // ...
+ };
+
+ // 3. Run agentic loop with DISCUSS_TOOLS
+ run_discuss_agentic_loop(
+ pool,
+ &state,
+ &llm_client,
+ system_prompt,
+ &request,
+ auth.owner_id,
+ ).await
+}
+```
+
+#### 1.5 Register the Endpoint
+
+**File:** `makima/src/server/mod.rs`
+
+Add route:
+```rust
+.route("/api/v1/contracts/discuss", post(handlers::contract_discuss::discuss_contract_handler))
+```
+
+---
+
+### Task 2: Frontend - DiscussContractModal Component
+
+**File:** `makima/frontend/src/components/listen/DiscussContractModal.tsx` (NEW)
+
+Create a chat modal component for the discussion interface.
+
+#### 2.1 Types
+
+```typescript
+interface Message {
+ id: string;
+ role: "user" | "assistant";
+ content: string;
+ timestamp: Date;
+ toolCalls?: ToolCallInfo[];
+}
+
+interface DiscussContractModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ transcriptContext?: string; // Current session transcript
+ onContractCreated: (contract: CreatedContractInfo) => void;
+}
+
+interface DiscussContractState {
+ messages: Message[];
+ isLoading: boolean;
+ error: string | null;
+ createdContract: CreatedContractInfo | null;
+}
+```
+
+#### 2.2 Component Structure
+
+```tsx
+export function DiscussContractModal({
+ isOpen,
+ onClose,
+ transcriptContext,
+ onContractCreated,
+}: DiscussContractModalProps) {
+ const [messages, setMessages] = useState<Message[]>([]);
+ const [input, setInput] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [createdContract, setCreatedContract] = useState<CreatedContractInfo | null>(null);
+
+ const { speak, isSpeaking, cancel } = useSpeakWebSocket();
+ const messagesEndRef = 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) {
+ // Add initial assistant message
+ 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]);
+
+ const handleSend = 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);
+
+ try {
+ const history = 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) {
+ // Handle error
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleSpeak = (text: string) => {
+ if (isSpeaking) {
+ cancel();
+ } else {
+ speak(text);
+ }
+ };
+
+ return (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
+ <div className="panel w-full max-w-2xl h-[600px] flex flex-col">
+ {/* 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="...">
+ [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">Makima is thinking...</span>
+ </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={(e) => e.key === "Enter" && handleSend()}
+ 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"
+ />
+ <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"
+ >
+ Send
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
+```
+
+#### 2.3 ChatBubble Component
+
+```tsx
+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>
+ );
+}
+```
+
+---
+
+### Task 3: Frontend - API Integration
+
+**File:** `makima/frontend/src/lib/api.ts`
+
+Add the discuss contract API function.
+
+```typescript
+// =============================================================================
+// 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();
+}
+```
+
+---
+
+### Task 4: Frontend - Integration into Listen Page
+
+#### 4.1 Update ContractPickerModal
+
+**File:** `makima/frontend/src/components/listen/ContractPickerModal.tsx`
+
+Add "Discuss Contract" option to the picker.
+
+```tsx
+interface ContractPickerModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ contracts: ContractOption[];
+ selectedContractId: string | null;
+ onSelect: (contractId: string | null) => void;
+ onDiscussContract: () => void; // NEW
+ loading?: boolean;
+}
+
+export function ContractPickerModal({
+ // ... existing props
+ onDiscussContract,
+}: ContractPickerModalProps) {
+ return (
+ <div className="...">
+ {/* ... existing content ... */}
+
+ {/* Ephemeral option */}
+ <button onClick={() => handleSelect(null)} className="...">
+ <span className="uppercase tracking-wide">Ephemeral</span>
+ <span className="...">Transcript not saved</span>
+ </button>
+
+ {/* NEW: Discuss Contract option */}
+ <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>
+
+ {/* Existing contracts */}
+ {contracts.map((contract) => (
+ // ... existing contract buttons
+ ))}
+ </div>
+ );
+}
+```
+
+#### 4.2 Update ControlPanel
+
+**File:** `makima/frontend/src/components/listen/ControlPanel.tsx`
+
+Add prop for discuss contract callback.
+
+```tsx
+interface ControlPanelProps {
+ // ... existing props
+ onDiscussContract: () => void; // NEW
+}
+
+export function ControlPanel({
+ // ... existing props
+ onDiscussContract,
+}: ControlPanelProps) {
+ return (
+ <div className="...">
+ {/* ... existing content ... */}
+
+ <ContractPickerModal
+ isOpen={isModalOpen}
+ onClose={() => setIsModalOpen(false)}
+ contracts={contracts}
+ selectedContractId={selectedContractId}
+ onSelect={onContractChange}
+ onDiscussContract={onDiscussContract}
+ loading={contractsLoading}
+ />
+ </div>
+ );
+}
+```
+
+#### 4.3 Update Listen Route
+
+**File:** `makima/frontend/src/routes/listen.tsx`
+
+Integrate the DiscussContractModal.
+
+```tsx
+import { DiscussContractModal } from "../components/listen/DiscussContractModal";
+
+export default function ListenPage() {
+ // ... existing state ...
+
+ // NEW: Discuss contract modal state
+ const [isDiscussModalOpen, setIsDiscussModalOpen] = useState(false);
+
+ // 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);
+ }, []);
+
+ return (
+ <div className="...">
+ {/* ... existing content ... */}
+
+ <ControlPanel
+ // ... existing props ...
+ onDiscussContract={handleOpenDiscussModal}
+ />
+
+ {/* NEW: Discuss Contract Modal */}
+ <DiscussContractModal
+ isOpen={isDiscussModalOpen}
+ onClose={() => setIsDiscussModalOpen(false)}
+ transcriptContext={transcriptContext}
+ onContractCreated={handleContractCreated}
+ />
+
+ {/* ... existing TranscriptAnalysisPanel ... */}
+ </div>
+ );
+}
+```
+
+---
+
+### Task 5: Integration Testing
+
+#### 5.1 Backend Tests
+
+**File:** `makima/src/server/handlers/contract_discuss_test.rs`
+
+```rust
+#[tokio::test]
+async fn test_discuss_contract_basic_conversation() {
+ // Test basic message/response flow
+}
+
+#[tokio::test]
+async fn test_discuss_contract_creates_contract() {
+ // Test that create_contract tool works
+}
+
+#[tokio::test]
+async fn test_discuss_contract_with_transcript_context() {
+ // Test transcript context is included
+}
+```
+
+#### 5.2 Frontend Tests
+
+- Test modal opens/closes correctly
+- Test message sending and receiving
+- Test speak button functionality
+- Test contract creation flow
+
+---
+
+## UI/UX Flow
+
+### User Journey
+
+1. **User records a transcript** (optional)
+ - Captures meeting or brainstorming session
+
+2. **User clicks "Contract" dropdown**
+ - Sees: Ephemeral, existing contracts, "Discuss Contract" option
+
+3. **User selects "Discuss Contract"**
+ - Modal opens with greeting from Makima
+ - If transcript exists, Makima references it
+
+4. **Conversation flows naturally**
+ - User describes what they want to build
+ - Makima asks clarifying questions
+ - User can click [Speak] to hear Makima's responses
+
+5. **When ready, user confirms**
+ - "Yes, let's create the contract"
+ - Makima calls create_contract tool
+
+6. **Contract created**
+ - Success banner appears
+ - Modal closes, contract selected automatically
+ - User can continue with normal workflow
+
+### Example Conversation
+
+```
+Makima: Hello! I'm Makima. Tell me about what you'd like to build,
+ and I'll help you create a contract for it.
+
+User: I want to add dark mode to my React app
+
+Makima: Great! Dark mode is a common feature. Let me understand your
+ situation better:
+
+ 1. Is this for an existing React application, or are you
+ starting fresh?
+ 2. Do you have a specific design system or color palette in mind?
+ 3. Should users be able to toggle between light/dark, or should
+ it follow system preferences?
+
+User: Existing app. I want both - toggle and follow system.
+ No specific design yet.
+
+Makima: Perfect. For an existing app adding dark mode with toggle
+ AND system preference support, I'd recommend a "simple" contract
+ since the requirements are clear.
+
+ Here's what I'm thinking:
+ - **Name:** "Add Dark Mode Support"
+ - **Description:** Implement theme switching (light/dark mode)
+ with manual toggle and system preference detection
+ - **Type:** Simple (plan -> execute)
+
+ Does this look right? Should I create this contract?
+
+User: Yes, looks good!
+
+Makima: Done! I've created the contract "Add Dark Mode Support".
+ You can now add your repository and start planning the
+ implementation.
+
+ [Contract created: Add Dark Mode Support]
+```
+
+---
+
+## Technical Details
+
+### API Contract
+
+#### Request
+```
+POST /api/v1/contracts/discuss
+Content-Type: application/json
+Authorization: Bearer <token>
+
+{
+ "message": "I want to add dark mode to my app",
+ "model": "claude-sonnet",
+ "history": [
+ { "role": "assistant", "content": "Hello! I'm Makima..." },
+ { "role": "user", "content": "I want to add dark mode" }
+ ],
+ "transcriptContext": "[Speaker A]: Let's discuss the dark mode feature..."
+}
+```
+
+#### Response
+```json
+{
+ "response": "Great! Let me understand your requirements...",
+ "toolCalls": [],
+ "createdContract": null,
+ "pendingQuestions": null
+}
+```
+
+#### Response (with contract creation)
+```json
+{
+ "response": "Done! I've created the contract...",
+ "toolCalls": [
+ {
+ "name": "create_contract",
+ "result": {
+ "success": true,
+ "message": "Contract 'Add Dark Mode Support' created"
+ }
+ }
+ ],
+ "createdContract": {
+ "id": "uuid-here",
+ "name": "Add Dark Mode Support",
+ "description": "Implement theme switching...",
+ "contractType": "simple",
+ "initialPhase": "plan"
+ }
+}
+```
+
+### State Management
+
+The DiscussContractModal manages its own state:
+
+```typescript
+interface ModalState {
+ messages: Message[]; // Conversation history
+ isLoading: boolean; // API call in progress
+ error: string | null; // Error message
+ createdContract: CreatedContractInfo | null; // Created contract info
+}
+```
+
+The parent (ListenPage) only needs to know:
+- Is modal open?
+- When was a contract created?
+
+### Error Handling
+
+1. **API errors** - Display in modal, allow retry
+2. **Network errors** - Display error, suggest retry
+3. **LLM errors** - Display gracefully, offer to restart conversation
+
+---
+
+## Files to Create/Modify
+
+### New Files
+1. `makima/src/server/handlers/contract_discuss.rs` - Backend handler
+2. `makima/src/llm/discuss_tools.rs` - Tool definitions
+3. `makima/frontend/src/components/listen/DiscussContractModal.tsx` - Chat UI
+
+### Modified Files
+1. `makima/src/server/mod.rs` - Register new route
+2. `makima/src/server/openapi.rs` - Add OpenAPI docs
+3. `makima/src/llm/mod.rs` - Export discuss tools
+4. `makima/frontend/src/lib/api.ts` - Add API function
+5. `makima/frontend/src/components/listen/ContractPickerModal.tsx` - Add discuss option
+6. `makima/frontend/src/components/listen/ControlPanel.tsx` - Pass callback
+7. `makima/frontend/src/routes/listen.tsx` - Integrate modal
+
+---
+
+## Key Design Decisions
+
+1. **New endpoint vs. existing chatWithContract**
+ - **Decision:** New endpoint `/api/v1/contracts/discuss`
+ - **Rationale:** chatWithContract requires an existing contract; this feature creates one
+
+2. **System prompt character**
+ - **Decision:** Makima-character aware of makima.jp platform
+ - **Rationale:** Consistent brand experience, engaging conversation
+
+3. **Tool for contract creation**
+ - **Decision:** `create_contract` tool the LLM invokes
+ - **Rationale:** Natural conversation flow; LLM decides when ready
+
+4. **Transcript context**
+ - **Decision:** Pass as optional context in system prompt
+ - **Rationale:** Seamless integration with listen page recording
+
+5. **Speak functionality**
+ - **Decision:** Per-message speak button using useSpeakWebSocket
+ - **Rationale:** Reuses existing TTS infrastructure
+
+6. **Conversation history**
+ - **Decision:** Frontend manages history, sends with each request
+ - **Rationale:** Stateless backend, consistent with existing chat patterns
diff --git a/makima/frontend/src/components/listen/ContractPickerModal.tsx b/makima/frontend/src/components/listen/ContractPickerModal.tsx
index 961ccba..f3c72d0 100644
--- a/makima/frontend/src/components/listen/ContractPickerModal.tsx
+++ b/makima/frontend/src/components/listen/ContractPickerModal.tsx
@@ -7,6 +7,7 @@ interface ContractPickerModalProps {
contracts: ContractOption[];
selectedContractId: string | null;
onSelect: (contractId: string | null) => void;
+ onDiscussContract: () => void;
loading?: boolean;
}
@@ -16,6 +17,7 @@ export function ContractPickerModal({
contracts,
selectedContractId,
onSelect,
+ onDiscussContract,
loading,
}: ContractPickerModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
@@ -90,6 +92,19 @@ export function ContractPickerModal({
</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}
diff --git a/makima/frontend/src/components/listen/ControlPanel.tsx b/makima/frontend/src/components/listen/ControlPanel.tsx
index f482ec4..ab2bcee 100644
--- a/makima/frontend/src/components/listen/ControlPanel.tsx
+++ b/makima/frontend/src/components/listen/ControlPanel.tsx
@@ -22,6 +22,7 @@ interface ControlPanelProps {
contracts: ContractOption[];
selectedContractId: string | null;
onContractChange: (contractId: string | null) => void;
+ onDiscussContract: () => void;
contractsLoading?: boolean;
// Connection status for loading state
connectionStatus?: ConnectionStatus;
@@ -56,6 +57,7 @@ export function ControlPanel({
contracts,
selectedContractId,
onContractChange,
+ onDiscussContract,
contractsLoading,
connectionStatus,
}: ControlPanelProps) {
@@ -190,6 +192,7 @@ export function ControlPanel({
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
new file mode 100644
index 0000000..984f505
--- /dev/null
+++ b/makima/frontend/src/components/listen/DiscussContractModal.tsx
@@ -0,0 +1,287 @@
+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/lib/api.ts b/makima/frontend/src/lib/api.ts
index 56491fd..bdaedf9 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -2137,6 +2137,60 @@ export async function clearContractChatHistory(
}
// =============================================================================
+// 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();
+}
+
+// =============================================================================
// Template Types and API
// =============================================================================
diff --git a/makima/frontend/src/routes/listen.tsx b/makima/frontend/src/routes/listen.tsx
index 8af538e..a53cbd9 100644
--- a/makima/frontend/src/routes/listen.tsx
+++ b/makima/frontend/src/routes/listen.tsx
@@ -4,9 +4,10 @@ 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 } from "../lib/api";
+import { listContracts, type CreatedContractInfo } from "../lib/api";
import { useAuth } from "../contexts/AuthContext";
export default function ListenPage() {
@@ -27,6 +28,9 @@ export default function ListenPage() {
contractId: string;
} | null>(null);
+ // Discuss contract modal state
+ const [isDiscussModalOpen, setIsDiscussModalOpen] = useState(false);
+
// Fetch contracts on mount
useEffect(() => {
if (!isAuthenticated) {
@@ -175,6 +179,29 @@ export default function ListenPage() {
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 (
@@ -206,6 +233,7 @@ export default function ListenPage() {
contracts={contracts}
selectedContractId={selectedContractId}
onContractChange={setSelectedContractId}
+ onDiscussContract={handleOpenDiscussModal}
contractsLoading={contractsLoading}
connectionStatus={ws.status}
/>
@@ -236,6 +264,14 @@ export default function ListenPage() {
</div>
</div>
)}
+
+ {/* Discuss Contract Modal */}
+ <DiscussContractModal
+ isOpen={isDiscussModalOpen}
+ onClose={() => setIsDiscussModalOpen(false)}
+ transcriptContext={transcriptContext}
+ onContractCreated={handleContractCreated}
+ />
</div>
);
}
diff --git a/makima/src/llm/discuss_tools.rs b/makima/src/llm/discuss_tools.rs
new file mode 100644
index 0000000..7330db3
--- /dev/null
+++ b/makima/src/llm/discuss_tools.rs
@@ -0,0 +1,210 @@
+//! 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/mod.rs b/makima/src/llm/mod.rs
index a3a3daf..4c84ced 100644
--- a/makima/src/llm/mod.rs
+++ b/makima/src/llm/mod.rs
@@ -2,6 +2,7 @@
pub mod claude;
pub mod contract_tools;
+pub mod discuss_tools;
pub mod groq;
pub mod markdown;
pub mod mesh_tools;
@@ -16,6 +17,9 @@ 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::{
diff --git a/makima/src/server/handlers/contract_discuss.rs b/makima/src/server/handlers/contract_discuss.rs
new file mode 100644
index 0000000..1f98f53
--- /dev/null
+++ b/makima/src/server/handlers/contract_discuss.rs
@@ -0,0 +1,592 @@
+//! Discussion endpoint for LLM-powered contract creation.
+//!
+//! This handler provides an ephemeral conversation with Makima to help users
+//! define and create contracts through natural dialogue.
+
+use axum::{
+ extract::State,
+ http::StatusCode,
+ response::IntoResponse,
+ Json,
+};
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+use utoipa::ToSchema;
+use uuid::Uuid;
+
+use crate::db::{models::CreateContractRequest, repository};
+use crate::llm::{
+ claude::{self, ClaudeClient, ClaudeError, ClaudeModel},
+ groq::{GroqClient, GroqError, Message, ToolCallResponse},
+ discuss_tools::{parse_discuss_tool_call, DiscussToolRequest, DISCUSS_TOOLS},
+ LlmModel, ToolCall, ToolResult, UserQuestion,
+};
+use crate::server::auth::Authenticated;
+use crate::server::state::SharedState;
+
+/// Maximum number of tool-calling rounds to prevent infinite loops
+const MAX_TOOL_ROUNDS: usize = 10;
+
+/// System prompt for Makima character in contract discussions
+const DISCUSS_SYSTEM_PROMPT: &str = r#"
+You are Makima, an AI assistant on the makima.jp platform. You help users define and create contracts for their projects through natural conversation.
+
+## Your Personality
+- Professional yet personable
+- Focused on understanding the user's actual needs
+- Ask clarifying questions when requirements are vague
+- Guide the conversation toward actionable outcomes
+- Comfortable making recommendations based on experience
+
+## Your Goal
+Help the user flesh out their project idea into a well-defined contract. A contract on makima.jp includes:
+- A clear name and description
+- The right contract type (simple, specification, or execute)
+- Understanding of the scope and requirements
+
+## Contract Types
+- **simple**: Quick tasks with minimal planning (plan -> execute phases only)
+- **specification**: Full lifecycle projects (research -> specify -> plan -> execute -> review)
+- **execute**: Direct implementation when requirements are already clear (execute phase only)
+
+## Guidelines
+1. **Start by understanding**: Ask about what they want to build
+2. **Clarify scope**: Is this a quick fix, a new feature, or a full project?
+3. **Gather requirements**: What are the must-haves vs nice-to-haves?
+4. **Identify context**: Is there existing code? Which repository?
+5. **Recommend type**: Suggest the appropriate contract type
+6. **Confirm and create**: When the user is satisfied, create the contract
+
+## When to Create the Contract
+Create the contract when:
+- You have a clear understanding of what the user wants
+- The user has confirmed they're ready to proceed
+- You've gathered enough information for a meaningful contract
+
+Do NOT create the contract if:
+- The user is still exploring ideas
+- Key information is missing
+- The user hasn't indicated readiness
+
+{transcript_context}
+"#;
+
+/// Chat message in history
+#[derive(Debug, Clone, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChatMessage {
+ /// Role: "user" or "assistant"
+ pub role: String,
+ /// Message content
+ pub content: String,
+}
+
+/// Request to discuss a potential contract
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DiscussContractRequest {
+ /// The user's message
+ pub message: String,
+ /// Optional model selection (default: claude-sonnet)
+ #[serde(default)]
+ pub model: Option<String>,
+ /// Conversation history for context continuity
+ #[serde(default)]
+ pub history: Option<Vec<ChatMessage>>,
+ /// Optional transcript context from current session
+ #[serde(default)]
+ pub transcript_context: Option<String>,
+}
+
+/// Response from the discussion endpoint
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DiscussContractResponse {
+ /// Makima's response message
+ pub response: String,
+ /// Tool calls that were executed (e.g., create_contract)
+ pub tool_calls: Vec<ToolCallInfo>,
+ /// If a contract was created, its details
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub created_contract: Option<CreatedContractInfo>,
+ /// Pending questions (if LLM needs clarification)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub pending_questions: Option<Vec<UserQuestion>>,
+}
+
+/// Information about a tool call that was executed
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ToolCallInfo {
+ pub name: String,
+ pub result: ToolResult,
+}
+
+/// Information about a created contract
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreatedContractInfo {
+ pub id: String,
+ pub name: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub description: Option<String>,
+ pub contract_type: String,
+ pub initial_phase: String,
+}
+
+/// 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,
+}
+
+/// Discuss a potential contract with Makima
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/discuss",
+ request_body = DiscussContractRequest,
+ responses(
+ (status = 200, description = "Discussion completed successfully", body = DiscussContractResponse),
+ (status = 401, description = "Unauthorized"),
+ (status = 500, description = "Internal server error")
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Contracts"
+)]
+pub async fn discuss_contract_handler(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Json(request): Json<DiscussContractRequest>,
+) -> 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!("Contract discussion 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 system prompt with optional transcript context
+ let transcript_section = match &request.transcript_context {
+ Some(ctx) => format!(
+ "\n## Current Session Context\nThe user has been recording a session. Here's the transcript:\n\n{}\n",
+ ctx
+ ),
+ None => String::new(),
+ };
+
+ let system_prompt = DISCUSS_SYSTEM_PROMPT.replace("{transcript_context}", &transcript_section);
+
+ // Run the discussion agentic loop
+ run_discuss_agentic_loop(
+ pool,
+ &llm_client,
+ system_prompt,
+ &request,
+ auth.owner_id,
+ )
+ .await
+}
+
+/// Run the agentic loop for contract discussion
+async fn run_discuss_agentic_loop(
+ pool: &sqlx::PgPool,
+ llm_client: &LlmClient,
+ system_prompt: String,
+ request: &DiscussContractRequest,
+ owner_id: Uuid,
+) -> axum::response::Response {
+ // Build initial messages
+ 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
+ if let Some(history) = &request.history {
+ for msg in history {
+ messages.push(Message {
+ role: msg.role.clone(),
+ content: Some(msg.content.clone()),
+ tool_calls: None,
+ tool_call_id: None,
+ });
+ }
+ }
+
+ // 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<ToolCallInfo> = Vec::new();
+ let mut final_response: Option<String> = None;
+ let mut created_contract: Option<CreatedContractInfo> = None;
+ 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(),
+ "Contract discussion loop iteration"
+ );
+
+ // Call the appropriate LLM API
+ let result = match llm_client {
+ LlmClient::Groq(groq) => {
+ match groq.chat_with_tools(messages.clone(), &DISCUSS_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, &DISCUSS_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 discussion tool call");
+
+ // Parse the tool call
+ let mut execution_result = parse_discuss_tool_call(tool_call);
+
+ // Handle async discussion tool requests
+ if let Some(discuss_request) = execution_result.request.take() {
+ let async_result =
+ handle_discuss_request(pool, discuss_request, owner_id).await;
+ execution_result.success = async_result.success;
+ execution_result.message = async_result.message;
+ execution_result.data = async_result.data;
+
+ // Check if a contract was created
+ if let Some(ref data) = execution_result.data {
+ if let Some(contract_info) = data.get("createdContract") {
+ created_contract = Some(CreatedContractInfo {
+ id: contract_info["id"].as_str().unwrap_or("").to_string(),
+ name: contract_info["name"].as_str().unwrap_or("").to_string(),
+ description: contract_info["description"].as_str().map(|s| s.to_string()),
+ contract_type: contract_info["contractType"].as_str().unwrap_or("").to_string(),
+ initial_phase: contract_info["initialPhase"].as_str().unwrap_or("").to_string(),
+ });
+ }
+ }
+ }
+
+ // Check for pending user questions
+ if let Some(questions) = execution_result.pending_questions {
+ tracing::info!(
+ question_count = questions.len(),
+ "Discussion LLM requesting user input"
+ );
+ pending_questions = Some(questions);
+ all_tool_call_infos.push(ToolCallInfo {
+ 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(ToolCallInfo {
+ 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 {
+ "Done!".to_string()
+ }
+ });
+
+ (
+ StatusCode::OK,
+ Json(DiscussContractResponse {
+ response: response_text,
+ tool_calls: all_tool_call_infos,
+ created_contract,
+ pending_questions,
+ }),
+ )
+ .into_response()
+}
+
+/// Result from handling an async discussion tool request
+struct DiscussRequestResult {
+ success: bool,
+ message: String,
+ data: Option<serde_json::Value>,
+}
+
+/// Handle async discussion tool requests that require database access
+async fn handle_discuss_request(
+ pool: &sqlx::PgPool,
+ request: DiscussToolRequest,
+ owner_id: Uuid,
+) -> DiscussRequestResult {
+ match request {
+ DiscussToolRequest::CreateContract {
+ name,
+ description,
+ contract_type,
+ repository_url,
+ local_only,
+ } => {
+ // Create the contract request
+ let create_req = CreateContractRequest {
+ name: name.clone(),
+ description: Some(description.clone()),
+ contract_type: Some(contract_type.clone()),
+ template_id: None,
+ initial_phase: None,
+ autonomous_loop: None,
+ phase_guard: None,
+ local_only: Some(local_only),
+ auto_merge_local: None,
+ };
+
+ match repository::create_contract_for_owner(pool, owner_id, create_req).await {
+ Ok(contract) => {
+ // If repository URL was provided, try to add it
+ if let Some(repo_url) = repository_url {
+ // Try to add as remote repository
+ let add_result = repository::add_remote_repository(
+ pool,
+ contract.id,
+ &format!("{} Repository", name),
+ &repo_url,
+ true, // is_primary
+ )
+ .await;
+
+ if let Err(e) = add_result {
+ tracing::warn!(
+ "Failed to add repository to contract {}: {}",
+ contract.id,
+ e
+ );
+ }
+ }
+
+ DiscussRequestResult {
+ success: true,
+ message: format!("Contract '{}' created successfully!", contract.name),
+ data: Some(json!({
+ "createdContract": {
+ "id": contract.id.to_string(),
+ "name": contract.name,
+ "description": contract.description,
+ "contractType": contract.contract_type,
+ "initialPhase": contract.phase,
+ }
+ })),
+ }
+ }
+ Err(e) => {
+ tracing::error!("Failed to create contract: {}", e);
+ DiscussRequestResult {
+ success: false,
+ message: format!("Failed to create contract: {}", e),
+ data: None,
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index 3e01a3e..5e172bc 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -5,6 +5,7 @@ pub mod chains;
pub mod chat;
pub mod contract_chat;
pub mod contract_daemon;
+pub mod contract_discuss;
pub mod contracts;
pub mod file_ws;
pub mod files;
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index d110c18..553797f 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, chains, chat, contract_chat, contract_daemon, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions};
+use crate::server::handlers::{api_keys, chains, chat, contract_chat, contract_daemon, contract_discuss, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions};
use crate::server::openapi::ApiDoc;
use crate::server::state::SharedState;
@@ -151,6 +151,7 @@ pub fn make_router(state: SharedState) -> Router {
.route("/users/me/password", axum::routing::put(users::change_password_handler))
.route("/users/me/email", axum::routing::put(users::change_email_handler))
// Contract endpoints
+ .route("/contracts/discuss", post(contract_discuss::discuss_contract_handler))
.route(
"/contracts",
get(contracts::list_contracts).post(contracts::create_contract),
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index f8c5474..a70342b 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -20,7 +20,7 @@ use crate::server::auth::{
ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse,
RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse,
};
-use crate::server::handlers::{api_keys, contract_chat, contracts, files, listen, mesh, mesh_chat, mesh_merge, repository_history, users};
+use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, files, listen, mesh, mesh_chat, mesh_merge, repository_history, users};
use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage};
#[derive(OpenApi)]
@@ -97,6 +97,8 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
contract_chat::contract_chat_handler,
contract_chat::get_contract_chat_history,
contract_chat::clear_contract_chat_history,
+ // Contract discuss endpoint
+ contract_discuss::discuss_contract_handler,
// Repository history/settings endpoints
repository_history::list_repository_history,
repository_history::get_repository_suggestions,
@@ -137,6 +139,12 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
// Contract chat schemas
ContractChatMessageRecord,
ContractChatHistoryResponse,
+ // Contract discuss schemas
+ contract_discuss::ChatMessage,
+ contract_discuss::DiscussContractRequest,
+ contract_discuss::DiscussContractResponse,
+ contract_discuss::ToolCallInfo,
+ contract_discuss::CreatedContractInfo,
// Merge schemas
BranchInfo,
BranchListResponse,