This is page 2 of 2. Use http://codebase.md/jhawkins11/task-manager-mcp?lines=false&page={x} to view the full context.
# Directory Structure
```
├── .gitignore
├── frontend
│ ├── .gitignore
│ ├── .npmrc
│ ├── components.json
│ ├── package-lock.json
│ ├── package.json
│ ├── postcss.config.cjs
│ ├── README.md
│ ├── src
│ │ ├── app.d.ts
│ │ ├── app.html
│ │ ├── app.pcss
│ │ ├── lib
│ │ │ ├── components
│ │ │ │ ├── ImportTasksModal.svelte
│ │ │ │ ├── QuestionModal.svelte
│ │ │ │ ├── TaskFormModal.svelte
│ │ │ │ └── ui
│ │ │ │ ├── badge
│ │ │ │ │ ├── badge.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── button
│ │ │ │ │ ├── button.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── card
│ │ │ │ │ ├── card-content.svelte
│ │ │ │ │ ├── card-description.svelte
│ │ │ │ │ ├── card-footer.svelte
│ │ │ │ │ ├── card-header.svelte
│ │ │ │ │ ├── card-title.svelte
│ │ │ │ │ ├── card.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── checkbox
│ │ │ │ │ ├── checkbox.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── dialog
│ │ │ │ │ ├── dialog-content.svelte
│ │ │ │ │ ├── dialog-description.svelte
│ │ │ │ │ ├── dialog-footer.svelte
│ │ │ │ │ ├── dialog-header.svelte
│ │ │ │ │ ├── dialog-overlay.svelte
│ │ │ │ │ ├── dialog-portal.svelte
│ │ │ │ │ ├── dialog-title.svelte
│ │ │ │ │ └── index.ts
│ │ │ │ ├── input
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── input.svelte
│ │ │ │ ├── label
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── label.svelte
│ │ │ │ ├── progress
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── progress.svelte
│ │ │ │ ├── select
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── select-content.svelte
│ │ │ │ │ ├── select-group-heading.svelte
│ │ │ │ │ ├── select-item.svelte
│ │ │ │ │ ├── select-scroll-down-button.svelte
│ │ │ │ │ ├── select-scroll-up-button.svelte
│ │ │ │ │ ├── select-separator.svelte
│ │ │ │ │ └── select-trigger.svelte
│ │ │ │ ├── separator
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── separator.svelte
│ │ │ │ └── textarea
│ │ │ │ ├── index.ts
│ │ │ │ └── textarea.svelte
│ │ │ ├── index.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ └── routes
│ │ ├── +layout.server.ts
│ │ ├── +layout.svelte
│ │ └── +page.svelte
│ ├── static
│ │ └── favicon.png
│ ├── svelte.config.js
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ └── vite.config.ts
├── img
│ └── ui.png
├── jest.config.js
├── package-lock.json
├── package.json
├── README.md
├── src
│ ├── config
│ │ ├── index.ts
│ │ ├── migrations.sql
│ │ └── schema.sql
│ ├── index.ts
│ ├── lib
│ │ ├── dbUtils.ts
│ │ ├── llmUtils.ts
│ │ ├── logger.ts
│ │ ├── repomixUtils.ts
│ │ ├── utils.ts
│ │ └── winstonLogger.ts
│ ├── models
│ │ └── types.ts
│ ├── server.ts
│ ├── services
│ │ ├── aiService.ts
│ │ ├── databaseService.ts
│ │ ├── planningStateService.ts
│ │ └── webSocketService.ts
│ └── tools
│ ├── adjustPlan.ts
│ ├── markTaskComplete.ts
│ ├── planFeature.ts
│ └── reviewChanges.ts
├── tests
│ ├── json-parser.test.ts
│ ├── llmUtils.unit.test.ts
│ ├── reviewChanges.integration.test.ts
│ └── setupEnv.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/src/services/webSocketService.ts:
--------------------------------------------------------------------------------
```typescript
import { WebSocket, WebSocketServer } from 'ws'
import { UI_PORT } from '../config'
import { logToFile } from '../lib/logger'
import {
WebSocketMessage,
WebSocketMessageType,
ClientRegistrationPayload,
ErrorPayload,
ShowQuestionPayload,
QuestionResponsePayload,
PlanFeatureResponseSchema,
IntermediatePlanningState,
} from '../models/types'
import planningStateService from '../services/planningStateService'
import { aiService } from '../services/aiService'
import { OPENROUTER_MODEL, GEMINI_MODEL } from '../config'
import { addHistoryEntry } from '../lib/dbUtils'
import crypto from 'crypto'
import {
processAndBreakdownTasks,
ensureEffortRatings,
processAndFinalizePlan,
} from '../lib/llmUtils'
import OpenAI from 'openai'
import { GenerativeModel } from '@google/generative-ai'
import { z } from 'zod'
import { databaseService } from '../services/databaseService'
interface WebSocketConnection {
socket: WebSocket
featureId?: string
clientId?: string
lastActivity: Date
}
class WebSocketService {
private wss: WebSocketServer | null = null
private connections: Map<WebSocket, WebSocketConnection> = new Map()
private static instance: WebSocketService
private isInitialized = false
private constructor() {}
/**
* Returns the singleton instance of WebSocketService
*/
public static getInstance(): WebSocketService {
if (!WebSocketService.instance) {
WebSocketService.instance = new WebSocketService()
}
return WebSocketService.instance
}
/**
* Initializes the WebSocket server using an existing HTTP server
*
* @param httpServer The Node.js HTTP server instance from Express
*/
public async initialize(httpServer: import('http').Server): Promise<void> {
if (this.isInitialized) {
await logToFile(
'[WebSocketService] WebSocket server already initialized.'
)
return
}
try {
// Attach WebSocket server to the existing HTTP server
this.wss = new WebSocketServer({ server: httpServer })
// Use UI_PORT for logging consistency if needed
await logToFile(
`[WebSocketService] WebSocket server attached to HTTP server on port ${UI_PORT}`
)
this.wss.on('connection', this.handleConnection.bind(this))
this.wss.on('error', this.handleServerError.bind(this))
// Set up connection cleanup interval (runs every minute)
setInterval(this.cleanupInactiveConnections.bind(this), 60000)
this.isInitialized = true
} catch (error) {
await logToFile(
`[WebSocketService] Failed to initialize WebSocket server: ${error}`
)
throw error
}
}
/**
* Handles new WebSocket connections
*/
private handleConnection(socket: WebSocket, _request: any): void {
// Create a new connection entry
const connection: WebSocketConnection = {
socket,
lastActivity: new Date(),
}
this.connections.set(socket, connection)
logToFile(
`[WebSocketService] New client connected. Total connections: ${this.connections.size}`
)
// Send a connection established message
this.sendToSocket(socket, {
type: 'connection_established',
})
// Set up event listeners for the socket
socket.on('message', (data: Buffer) => this.handleMessage(socket, data))
socket.on('close', () => this.handleDisconnect(socket))
socket.on('error', (error) => this.handleSocketError(socket, error))
}
/**
* Handles incoming WebSocket messages
*/
private handleMessage(socket: WebSocket, data: Buffer): void {
try {
// Update last activity timestamp
const connection = this.connections.get(socket)
if (connection) {
connection.lastActivity = new Date()
}
// Parse the message
const message = JSON.parse(data.toString()) as WebSocketMessage
// Handle client registration
if (message.type === 'client_registration' && message.payload) {
this.handleClientRegistration(
socket,
message.payload as ClientRegistrationPayload
)
return
}
// Handle question response
if (message.type === 'question_response' && message.payload) {
this.handleQuestionResponse(
message.featureId || '',
message.payload as QuestionResponsePayload
)
return
}
// Log the message type
logToFile(`[WebSocketService] Received message of type: ${message.type}`)
// Additional message handling logic can be added here
} catch (error) {
logToFile(`[WebSocketService] Error handling message: ${error}`)
this.sendToSocket(socket, {
type: 'error',
payload: {
code: 'MESSAGE_PARSING_ERROR',
message: 'Failed to parse incoming message',
} as ErrorPayload,
})
}
}
/**
* Handles client registration messages
*/
private handleClientRegistration(
socket: WebSocket,
payload: ClientRegistrationPayload
): void {
const connection = this.connections.get(socket)
if (connection) {
connection.featureId = payload.featureId
connection.clientId = payload.clientId || `client-${Date.now()}`
logToFile(
`[WebSocketService] Client registered: ${connection.clientId} for feature: ${connection.featureId}`
)
// Confirm registration to the client
this.sendToSocket(socket, {
type: 'client_registration',
featureId: connection.featureId,
payload: {
featureId: connection.featureId,
clientId: connection.clientId,
},
})
}
}
/**
* Handles socket disconnections
*/
private handleDisconnect(socket: WebSocket): void {
const connection = this.connections.get(socket)
if (connection) {
logToFile(
`[WebSocketService] Client disconnected: ${
connection.clientId || 'unknown'
}`
)
this.connections.delete(socket)
}
}
/**
* Handles socket errors
*/
private handleSocketError(socket: WebSocket, error: Error): void {
const connection = this.connections.get(socket)
logToFile(
`[WebSocketService] Socket error for client ${
connection?.clientId || 'unknown'
}: ${error.message}`
)
// Try to send an error message to the client
this.sendToSocket(socket, {
type: 'error',
payload: {
code: 'SOCKET_ERROR',
message: 'Socket error occurred',
} as ErrorPayload,
})
// Close the connection after an error
try {
socket.terminate()
} catch (closeError) {
logToFile(`[WebSocketService] Error closing socket: ${closeError}`)
}
// Remove the connection from our map
this.connections.delete(socket)
}
/**
* Handles server errors
*/
private async handleServerError(error: Error): Promise<void> {
await logToFile(
`[WebSocketService] WebSocket server error: ${error.message}`
)
}
/**
* Cleans up inactive connections
*/
private cleanupInactiveConnections(): void {
const now = new Date()
const inactivityThreshold = 30 * 60 * 1000 // 30 minutes
for (const [socket, connection] of this.connections.entries()) {
const timeSinceLastActivity =
now.getTime() - connection.lastActivity.getTime()
if (timeSinceLastActivity > inactivityThreshold) {
logToFile(
`[WebSocketService] Closing inactive connection: ${
connection.clientId || 'unknown'
}`
)
try {
socket.terminate()
} catch (error) {
logToFile(
`[WebSocketService] Error terminating inactive socket: ${error}`
)
}
this.connections.delete(socket)
}
}
}
/**
* Sends a message to all connected clients for a specific feature
*/
public broadcast(message: WebSocketMessage): void {
if (!message.featureId) {
logToFile('[WebSocketService] Cannot broadcast without featureId')
return
}
let recipientCount = 0
for (const [socket, connection] of this.connections.entries()) {
// Only send to clients registered for this feature
if (connection.featureId === message.featureId) {
this.sendToSocket(socket, message)
recipientCount++
}
}
logToFile(
`[WebSocketService] Broadcast message of type '${message.type}' to ${recipientCount} clients for feature: ${message.featureId}`
)
}
/**
* Sends a message to a specific socket
*/
private sendToSocket(socket: WebSocket, message: WebSocketMessage): void {
if (socket.readyState === WebSocket.OPEN) {
try {
socket.send(JSON.stringify(message))
} catch (error) {
logToFile(
`[WebSocketService] Error sending message to socket: ${error}`
)
}
}
}
/**
* Gracefully shutdowns the WebSocket server
*/
public async shutdown(): Promise<void> {
if (!this.wss) {
return
}
await logToFile('[WebSocketService] Shutting down WebSocket server...')
// Close all connections
for (const [socket] of this.connections.entries()) {
try {
socket.terminate()
} catch (error) {
await logToFile(
`[WebSocketService] Error terminating socket during shutdown: ${error}`
)
}
}
this.connections.clear()
// Close the server
this.wss.close((error) => {
if (error) {
logToFile(`[WebSocketService] Error closing WebSocket server: ${error}`)
} else {
logToFile('[WebSocketService] WebSocket server closed successfully')
}
})
this.wss = null
this.isInitialized = false
}
/**
* Broadcasts a task update notification for a feature
*/
public notifyTasksUpdated(featureId: string, tasks: any): void {
// Log tasks to help debug
console.log(
'WebSocketService.notifyTasksUpdated - Task fromReview values:',
tasks.map((t: any) => ({ id: t.id, fromReview: !!t.fromReview }))
)
// Make sure fromReview is properly formatted for all tasks
const formattedTasks = tasks.map((task: any) => ({
...task,
fromReview: task.fromReview === 1 ? true : !!task.fromReview,
}))
this.broadcast({
type: 'tasks_updated',
featureId,
payload: {
tasks: formattedTasks,
updatedAt: new Date().toISOString(),
},
})
}
/**
* Broadcasts a task status change notification
*/
public notifyTaskStatusChanged(
featureId: string,
taskId: string,
status: 'pending' | 'completed' | 'decomposed'
): void {
this.broadcast({
type: 'status_changed',
featureId,
payload: {
taskId,
status,
updatedAt: new Date().toISOString(),
},
})
}
/**
* Broadcasts a notification when a task is created
*/
public notifyTaskCreated(featureId: string, task: any): void {
// Make sure fromReview is properly formatted
const formattedTask = {
...task,
fromReview: task.fromReview === 1 ? true : !!task.fromReview,
}
this.broadcast({
type: 'task_created',
featureId,
payload: {
task: formattedTask,
featureId,
createdAt: new Date().toISOString(),
},
})
logToFile(
`[WebSocketService] Broadcasted task_created for task ID: ${task.id}`
)
}
/**
* Broadcasts a notification when a task is updated
*/
public notifyTaskUpdated(featureId: string, task: any): void {
// Make sure fromReview is properly formatted
const formattedTask = {
...task,
fromReview: task.fromReview === 1 ? true : !!task.fromReview,
}
this.broadcast({
type: 'task_updated',
featureId,
payload: {
task: formattedTask,
featureId,
updatedAt: new Date().toISOString(),
},
})
logToFile(
`[WebSocketService] Broadcasted task_updated for task ID: ${task.id}`
)
}
/**
* Broadcasts a notification when a task is deleted
*/
public notifyTaskDeleted(featureId: string, taskId: string): void {
this.broadcast({
type: 'task_deleted',
featureId,
payload: {
taskId,
featureId,
deletedAt: new Date().toISOString(),
},
})
logToFile(
`[WebSocketService] Broadcasted task_deleted for task ID: ${taskId}`
)
}
/**
* Sends a question to UI clients
*/
public sendQuestion(
featureId: string,
questionId: string,
question: string,
options?: string[],
allowsText?: boolean
): void {
try {
if (!featureId || !questionId || !question) {
logToFile(
'[WebSocketService] Cannot send question: Missing required parameters'
)
return
}
// Check if any clients are connected for this feature
let featureClients = 0
for (const connection of this.connections.values()) {
if (connection.featureId === featureId) {
featureClients++
}
}
// Log if no clients are available
if (featureClients === 0) {
logToFile(
`[WebSocketService] Warning: Sending question ${questionId} to feature ${featureId} with no connected clients`
)
}
this.broadcast({
type: 'show_question',
featureId,
payload: {
questionId,
question,
options,
allowsText,
} as ShowQuestionPayload,
})
logToFile(
`[WebSocketService] Sent question to ${featureClients} clients for feature ${featureId}: ${question}`
)
} catch (error: any) {
logToFile(`[WebSocketService] Error sending question: ${error.message}`)
}
}
/**
* Requests a screenshot from UI clients
*/
public requestScreenshot(
featureId: string,
requestId: string,
target?: string
): void {
this.broadcast({
type: 'request_screenshot',
featureId,
payload: {
requestId,
target,
},
})
}
/**
* Handles user responses to questions
*/
private async handleQuestionResponse(
featureId: string,
payload: QuestionResponsePayload
): Promise<void> {
try {
if (!featureId) {
logToFile(
'[WebSocketService] Cannot handle question response: Missing featureId'
)
return
}
const { questionId, response } = payload
if (!questionId) {
logToFile(
'[WebSocketService] Cannot handle question response: Missing questionId'
)
this.broadcast({
type: 'error',
featureId,
payload: {
code: 'INVALID_RESPONSE',
message: 'Invalid response format: missing questionId',
} as ErrorPayload,
})
return
}
logToFile(
`[WebSocketService] Received response to question ${questionId}: ${response}`
)
// Get the stored planning state
const state = await planningStateService.getStateByQuestionId(questionId)
if (!state) {
logToFile(
`[WebSocketService] No planning state found for question ${questionId}`
)
this.broadcast({
type: 'error',
featureId,
payload: {
code: 'QUESTION_EXPIRED',
message: 'The question session has expired or is invalid.',
} as ErrorPayload,
})
return
}
// Verify feature ID matches
if (state.featureId !== featureId) {
logToFile(
`[WebSocketService] Feature ID mismatch: question belongs to ${state.featureId}, but response came from ${featureId}`
)
this.broadcast({
type: 'error',
featureId,
payload: {
code: 'FEATURE_MISMATCH',
message:
'Response came from a different feature than the question.',
} as ErrorPayload,
})
return
}
// Add the response to history
await addHistoryEntry(featureId, 'user', {
questionId,
question: state.partialResponse,
response,
})
// Notify UI that response is being processed
this.broadcast({
type: 'status_changed',
featureId,
payload: {
status: 'processing_response',
questionId,
},
})
// Resume planning/adjustment with the user's response
try {
const planningModel = aiService.getPlanningModel()
if (!planningModel) {
throw new Error('Planning model not available')
}
logToFile(
`[WebSocketService] Resuming ${state.planningType} with user response for feature ${featureId}`
)
// Fetch feature information from database
await databaseService.connect()
const feature = await databaseService.getFeatureById(featureId)
await databaseService.close()
if (!feature) {
throw new Error(`Feature with ID ${featureId} not found`)
}
// Get previous history entries for context
await databaseService.connect()
const history = await databaseService.getHistoryByFeatureId(
featureId,
10
)
await databaseService.close()
// Extract original feature description
const originalDescription =
feature.description || 'Unknown feature description'
// Create a comprehensive follow-up prompt with complete context
let followUpPrompt = `You previously received this feature request: "${originalDescription}"
When planning this feature implementation, you asked for clarification with this question:
${state.partialResponse}
The user has now provided this answer to your question: "${response}"
`
// Call the LLM with the comprehensive follow-up prompt
if (planningModel instanceof OpenAI) {
await this.processOpenRouterResumeResponse(
planningModel,
followUpPrompt,
state,
featureId,
questionId
)
} else {
await this.processGeminiResumeResponse(
planningModel,
followUpPrompt,
state,
featureId,
questionId
)
}
} catch (error: any) {
logToFile(
`[WebSocketService] Error resuming planning: ${error.message}`
)
// Notify clients of the error
this.broadcast({
type: 'error',
featureId,
payload: {
code: 'RESUME_PLANNING_FAILED',
message: `Failed to process your response: ${error.message}`,
} as ErrorPayload,
})
// Add error history entry
await addHistoryEntry(featureId, 'tool_response', {
tool:
state.planningType === 'feature_planning'
? 'plan_feature'
: 'adjust_plan',
status: 'failed_after_clarification',
error: error.message,
})
}
} catch (error: any) {
logToFile(
`[WebSocketService] Unhandled error in question response handler: ${error.message}`
)
if (featureId) {
this.broadcast({
type: 'error',
featureId,
payload: {
code: 'INTERNAL_ERROR',
message:
'An internal error occurred while processing your response.',
} as ErrorPayload,
})
}
}
}
/**
* Process OpenRouter response for resuming planning after clarification
*/
private async processOpenRouterResumeResponse(
model: OpenAI,
prompt: string,
state: IntermediatePlanningState,
featureId: string,
questionId: string
): Promise<void> {
try {
logToFile(
`[WebSocketService] Calling OpenRouter with follow-up prompt for feature ${featureId}`
)
const result = await aiService.callOpenRouterWithSchema(
OPENROUTER_MODEL,
[{ role: 'user', content: prompt }],
PlanFeatureResponseSchema,
{ temperature: 0.3, max_tokens: 4096 }
)
if (result.success) {
await this.processPlanningSuccess(
result.data,
model,
state,
featureId,
questionId
)
} else {
throw new Error(
`OpenRouter failed to generate a valid response: ${result.error}`
)
}
} catch (error: any) {
logToFile(
`[WebSocketService] Error in OpenRouter response processing: ${error.message}`
)
throw error // Re-throw to be handled by the caller
}
}
/**
* Process Gemini response for resuming planning after clarification
*/
private async processGeminiResumeResponse(
model: GenerativeModel,
prompt: string,
state: IntermediatePlanningState,
featureId: string,
questionId: string
): Promise<void> {
try {
logToFile(
`[WebSocketService] Calling Gemini with follow-up prompt for feature ${featureId}`
)
const result = await aiService.callGeminiWithSchema(
GEMINI_MODEL,
prompt,
PlanFeatureResponseSchema,
{ temperature: 0.3 }
)
if (result.success) {
await this.processPlanningSuccess(
result.data,
model,
state,
featureId,
questionId
)
} else {
throw new Error(
`Gemini failed to generate a valid response: ${result.error}`
)
}
} catch (error: any) {
logToFile(
`[WebSocketService] Error in Gemini response processing: ${error.message}`
)
throw error // Re-throw to be handled by the caller
}
}
/**
* Process successful planning result after clarification
*/
private async processPlanningSuccess(
data: z.infer<typeof PlanFeatureResponseSchema>,
model: GenerativeModel | OpenAI,
state: IntermediatePlanningState,
featureId: string,
questionId: string
): Promise<void> {
try {
// Check if tasks exist before processing
if (!data.tasks) {
logToFile(
`[WebSocketService] Error: processPlanningSuccess called but response contained clarificationNeeded instead of tasks for feature ${featureId}`
)
// Optionally, you could try to handle the clarification again here, but throwing seems safer
throw new Error(
'processPlanningSuccess received clarification request, expected tasks.'
)
}
// Process the tasks using schema data
const rawPlanSteps = data.tasks.map(
(task: { effort: string; description: string }) =>
`[${task.effort}] ${task.description}`
)
// Log the result before detailed processing
await addHistoryEntry(featureId, 'model', {
step: 'resumed_planning_response',
response: JSON.stringify(data),
})
logToFile(
`[WebSocketService] Got ${rawPlanSteps.length} raw tasks after clarification for feature ${featureId}`
)
// Call the centralized processing function
const finalTasks = await processAndFinalizePlan(
rawPlanSteps,
model,
featureId
)
logToFile(
`[WebSocketService] Processed ${finalTasks.length} final tasks after clarification for feature ${featureId}`
)
// Clean up the temporary state
await planningStateService.clearState(questionId)
// Add success history entry (notification/saving is now handled within processAndFinalizePlan)
await addHistoryEntry(featureId, 'tool_response', {
tool:
state.planningType === 'feature_planning'
? 'plan_feature'
: 'adjust_plan',
status: 'completed_after_clarification',
taskCount: finalTasks.length,
})
logToFile(
`[WebSocketService] Successfully completed ${state.planningType} after clarification for feature ${featureId}`
)
} catch (error: any) {
logToFile(
`[WebSocketService] Error processing successful planning result: ${error.message}`
)
throw error // Re-throw to be handled by the caller
}
}
}
export default WebSocketService.getInstance()
```
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
```typescript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
import * as fsSync from 'fs'
import * as fs from 'fs/promises'
import { logToFile } from './lib/logger'
import logger from './lib/winstonLogger'
import { handleMarkTaskComplete } from './tools/markTaskComplete'
import { handlePlanFeature } from './tools/planFeature'
import { handleReviewChanges } from './tools/reviewChanges'
import { AdjustPlanInputSchema, AdjustPlanInput } from './models/types'
import { adjustPlanHandler } from './tools/adjustPlan'
import webSocketService from './services/webSocketService'
import planningStateService from './services/planningStateService'
// Re-add static imports
import express, { Request, Response, NextFunction } from 'express'
import path from 'path'
import crypto from 'crypto'
import { FEATURE_TASKS_DIR, UI_PORT } from './config'
import { Task } from './models/types'
import { detectClarificationRequest } from './lib/llmUtils'
import { databaseService } from './services/databaseService'
import { addHistoryEntry } from './lib/dbUtils'
// Immediately log that we're starting up
logger.info('Starting task manager server...')
// --- MCP Server Setup ---
logger.info('Setting up MCP Server instance...')
const server = new McpServer({
name: 'task-manager-mcp',
version: '0.6.3',
description:
'MCP Server using Google AI SDK and repomix for planning and review.',
capabilities: {
tools: { listChanged: false },
},
})
logger.info('MCP Server instance created.')
// --- Tool Definitions ---
logger.info('Defining tools...')
// New 'get_next_task' tool
server.tool(
'get_next_task',
{
featureId: z
.string()
.uuid({ message: 'Valid feature ID (UUID) is required.' }),
},
async (args, _extra) => {
try {
const { featureId } = args
await logToFile(
`[TaskServer] Handling get_next_task request for feature: ${featureId}`
)
// 1. Read tasks for the given feature ID
await databaseService.connect()
const tasks = await databaseService.getTasksByFeatureId(featureId)
await databaseService.close()
if (tasks.length === 0) {
const message = `No tasks found for feature ID ${featureId}. The feature may not exist or has not been planned yet.`
await logToFile(`[TaskServer] ${message}`)
return {
content: [{ type: 'text', text: message }],
isError: false,
}
}
// 2. Find the first pending task in the list
const nextTask = tasks.find((task) => task.status === 'pending')
if (!nextTask) {
const message = `All tasks have been completed for feature ${featureId}.`
await logToFile(`[TaskServer] ${message}`)
return {
content: [{ type: 'text', text: message }],
isError: false,
}
}
// 3. Format response with task details
// Include effort in the message if available
const effortInfo = nextTask.effort ? ` (Effort: ${nextTask.effort})` : ''
// Include parent info if this is a subtask
let parentInfo = ''
if (nextTask.parent_task_id) {
// Find the parent task
const parentTask = tasks.find((t) => t.id === nextTask.parent_task_id)
if (parentTask) {
const parentDesc =
parentTask.description && parentTask.description.length > 30
? parentTask.description.substring(0, 30) + '...'
: parentTask.description || '' // Use empty string if description is undefined
parentInfo = ` (Subtask of: "${parentDesc}")`
} else {
parentInfo = ` (Subtask of parent ID: ${nextTask.parent_task_id})` // Fallback if parent not found
}
}
// Embed ID, description, effort, and parent info in the text message
const message = `Next pending task (ID: ${
nextTask.id
})${effortInfo}${parentInfo}: ${
nextTask.title !== nextTask.description
? `${nextTask.title}: ${nextTask.description}`
: nextTask.description
}`
await logToFile(`[TaskServer] Found next task: ${nextTask.id}`)
return {
content: [{ type: 'text', text: message }],
isError: false,
}
} catch (error: any) {
const errorMsg = `Error processing get_next_task request: ${
error instanceof Error ? error.message : String(error)
}`
logger.error(errorMsg)
await logToFile(`[TaskServer] ${errorMsg}`)
return {
content: [{ type: 'text', text: errorMsg }],
isError: true,
}
}
}
)
// 1. Tool: mark_task_complete
server.tool(
'mark_task_complete',
{
task_id: z.string().uuid({ message: 'Valid task ID (UUID) is required.' }),
feature_id: z
.string()
.uuid({ message: 'Valid feature ID (UUID) is required.' }),
},
async (args, _extra) => {
const result = await handleMarkTaskComplete(args)
// Transform the content to match SDK expected format
return {
content: result.content.map((item) => ({
type: item.type as 'text',
text: item.text,
})),
isError: result.isError,
}
}
)
// 2. Tool: plan_feature
server.tool(
'plan_feature',
{
feature_description: z.string().min(10, {
message: 'Feature description must be at least 10 characters.',
}),
project_path: z
.string()
.describe(
'The absolute path to the project directory to scan with repomix. '
),
},
async (args, _extra) => {
const result = await handlePlanFeature(args)
// Transform the content to match SDK expected format
// Since handlePlanFeature now always returns Array<{type: 'text', text: string}>
// we can use a simple map.
return {
content: result.content.map((item) => ({
type: 'text',
text: item.text,
})),
isError: result.isError,
}
}
)
// 3. Tool: review_changes
server.tool(
'review_changes',
{
project_path: z
.string()
.optional()
.describe(
'The absolute path to the project directory where git commands should run. Defaults to the workspace root if not provided.'
),
featureId: z
.string()
.uuid({ message: 'Valid feature ID (UUID) is required.' }),
},
async (args, _extra) => {
// Pass the project_path argument to the handler
const result = await handleReviewChanges({
featureId: args.featureId,
project_path: args.project_path,
})
// Transform the content to match SDK expected format
return {
content: result.content.map((item) => ({
type: item.type as 'text',
text: item.text,
})),
isError: result.isError,
}
}
)
// 4. Tool: adjust_plan
server.tool(
'adjust_plan',
{
featureId: z
.string()
.uuid({ message: 'Valid feature ID (UUID) is required.' }),
adjustment_request: z
.string()
.min(1, { message: 'Adjustment request cannot be empty.' }),
},
async (args: AdjustPlanInput, _extra) => {
const result = await adjustPlanHandler(args)
return {
content: [{ type: 'text', text: result.message }],
isError: result.status === 'error',
}
}
)
logger.info('Tools defined.')
// --- Error Handlers ---
// Add top-level error handler for synchronous errors during load
process.on('uncaughtException', (error) => {
// Cannot reliably use async logToFile here
logger.error('Uncaught Exception:', error)
// Use synchronous append for critical errors before exit
try {
const logDir = process.env.LOG_DIR || './logs'
const logFile = process.env.LOG_FILE || `${logDir}/debug.log`
fsSync.mkdirSync(logDir, { recursive: true })
fsSync.appendFileSync(
logFile,
`${new Date().toISOString()} - [TaskServer] FATAL: Uncaught Exception: ${
error?.message || error
}\n${error?.stack || ''}\n`
)
} catch (logErr) {
logger.error('Error writing uncaughtException to sync log:', logErr)
}
process.exit(1)
})
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise, 'reason:', reason)
try {
const logDir = process.env.LOG_DIR || './logs'
const logFile = process.env.LOG_FILE || `${logDir}/debug.log`
fsSync.mkdirSync(logDir, { recursive: true })
fsSync.appendFileSync(
logFile,
`${new Date().toISOString()} - [TaskServer] FATAL: Unhandled Rejection: ${reason}\n`
)
} catch (logErr) {
logger.error('Error writing unhandledRejection to sync log:', logErr)
}
process.exit(1)
})
// Helper function to list all features
async function listFeatures() {
try {
// Ensure features directory exists
await fs.mkdir(FEATURE_TASKS_DIR, { recursive: true })
// Read all files in the features directory
const files = await fs.readdir(FEATURE_TASKS_DIR)
// Filter for task files (ending with _mcp_tasks.json)
const taskFiles = files.filter((file) => file.endsWith('_mcp_tasks.json'))
// Extract feature IDs from filenames
const featureIds = taskFiles.map((file) =>
file.replace('_mcp_tasks.json', '')
)
return featureIds
} catch (error) {
logger.error('Error listing features:', error)
return []
}
}
// Helper function to format a task for the frontend
function formatTaskForFrontend(task: any, featureId: string) {
return {
id: task.id,
title: task.title || task.description,
description: task.description,
status: task.status,
completed: task.status === 'completed' || Boolean(task.completed),
effort: task.effort,
feature_id: featureId,
// Convert from snake_case to camelCase for frontend compatibility
parentTaskId: task.parent_task_id,
createdAt:
typeof task.created_at === 'number'
? new Date(task.created_at * 1000).toISOString()
: task.createdAt,
updatedAt:
typeof task.updated_at === 'number'
? new Date(task.updated_at * 1000).toISOString()
: task.updatedAt,
fromReview: Boolean(task.fromReview || task.from_review === 1),
}
}
// --- Server Start ---
async function main() {
logger.info('Entering main function...')
// Initialize database *before* starting other services
try {
logger.info('Initializing database...')
await databaseService.initializeDatabase()
logger.info('Database initialized successfully.')
} catch (dbError) {
logger.error(
'FATAL: Failed to initialize database. Server cannot start.',
dbError
)
process.exit(1) // Exit if database fails to initialize
}
await logToFile('[TaskServer] LOG: main() started.')
logger.info('Main function started')
try {
// --- Express Server Setup --- Moved inside main, after MCP connect
const app = express()
const PORT = process.env.PORT || UI_PORT || 4999
// HTTP request logging middleware
app.use((req: Request, res: Response, next: NextFunction) => {
const start = new Date().getTime()
res.on('finish', () => {
const duration = new Date().getTime() - start
logger.info({
method: req.method,
url: req.url,
status: res.statusCode,
duration: `${duration}ms`,
})
})
next()
})
// --- MCP Server Connection --- Moved after Express init
await logToFile('[TaskServer] LOG: Creating transport...')
const transport = new StdioServerTransport()
await logToFile('[TaskServer] LOG: Transport created.')
await logToFile('[TaskServer] LOG: Connecting server to transport...')
await server.connect(transport)
await logToFile(
'[TaskServer] LOG: MCP Task Manager Server connected and running on stdio...'
)
// Setup API endpoints
// Get list of features
app.get('/api/features', (req: Request, res: Response) => {
;(async () => {
try {
const featureIds = await listFeatures()
res.json(featureIds)
} catch (error: any) {
logger.error(`Failed to fetch features: ${error?.message || error}`)
await logToFile(
`[TaskServer] ERROR fetching features: ${error?.message || error}`
)
res.status(500).json({ error: 'Failed to fetch features' })
}
})()
})
// Get tasks for a specific feature
app.get('/api/tasks/:featureId', (req: Request, res: Response) => {
;(async () => {
const { featureId } = req.params
try {
await databaseService.connect()
const tasks = await databaseService.getTasksByFeatureId(featureId)
await databaseService.close()
// Use the helper function to format tasks
const formattedTasks = tasks.map((task) =>
formatTaskForFrontend(task, featureId)
)
res.json(formattedTasks)
} catch (error: any) {
logger.error(
`Failed to fetch tasks for feature ${featureId}: ${
error?.message || error
}`
)
await logToFile(
`[TaskServer] ERROR fetching tasks for feature ${featureId}: ${
error?.message || error
}`
)
res.status(500).json({ error: 'Failed to fetch tasks' })
}
})()
})
// Parse JSON bodies for API requests
app.use(express.json())
// POST: Create a new task
app.post('/api/tasks', (req: Request, res: Response) => {
;(async () => {
try {
const { featureId, description, title, effort } = req.body
// Use title if description is missing
const taskDescription = description || title
if (!featureId || !taskDescription) {
return res.status(400).json({
error:
'Missing required fields: featureId and title are required',
})
}
// Read existing tasks
await databaseService.connect()
const tasks = await databaseService.getTasksByFeatureId(featureId)
// Create a new task with a UUID
const now = Math.floor(Date.now() / 1000)
const newTask = {
id: crypto.randomUUID(),
description: taskDescription,
title: title || taskDescription, // Use title or derived description
status: 'pending' as const,
completed: false,
effort: effort as 'low' | 'medium' | 'high' | undefined,
feature_id: featureId,
created_at: now,
updated_at: now,
}
// Convert to DB format and add to database
await databaseService.addTask(newTask)
// Add the new task to the list for WS notifications
tasks.push(newTask)
// Notify clients via WebSocket - both general task update and specific creation event
webSocketService.broadcast({
type: 'tasks_updated',
featureId,
payload: {
tasks: tasks.map((task) =>
formatTaskForFrontend(task, featureId)
),
updatedAt: new Date().toISOString(),
},
})
// Also send a specific task created notification
webSocketService.notifyTaskCreated(
featureId,
formatTaskForFrontend(newTask, featureId)
)
await databaseService.close()
res.status(201).json(formatTaskForFrontend(newTask, featureId))
} catch (error: any) {
logger.error(`Failed to create task: ${error?.message || error}`)
await logToFile(
`[TaskServer] ERROR creating task: ${error?.message || error}`
)
res.status(500).json({ error: 'Failed to create task' })
}
})()
})
// PUT: Update an existing task
app.put('/api/tasks/:taskId', (req: Request, res: Response) => {
;(async () => {
try {
const { taskId } = req.params
const { featureId, description, title, status, completed, effort } =
req.body
if (!featureId) {
return res
.status(400)
.json({ error: 'Missing required field: featureId' })
}
// Read existing tasks
await databaseService.connect()
// Check if task exists
const task = await databaseService.getTaskById(taskId)
if (!task) {
await databaseService.close()
return res.status(404).json({ error: 'Task not found' })
}
// First determine what kind of update we need (status or details)
if (status || completed !== undefined) {
// Status update
const newStatus = status || task.status
const isCompleted =
completed !== undefined ? completed : task.completed
await databaseService.updateTaskStatus(
taskId,
newStatus,
isCompleted
)
}
// If we have other fields to update, do that as well
if (title || description || effort) {
await databaseService.updateTaskDetails(taskId, {
title: title,
description: description,
effort: effort as 'low' | 'medium' | 'high' | undefined,
})
}
// Get updated tasks for WebSocket notification
const tasks = await databaseService.getTasksByFeatureId(featureId)
const updatedTask = await databaseService.getTaskById(taskId)
// Notify clients via WebSocket - both general task update and specific update event
webSocketService.broadcast({
type: 'tasks_updated',
featureId,
payload: {
tasks: tasks.map((task) =>
formatTaskForFrontend(task, featureId)
),
updatedAt: new Date().toISOString(),
},
})
// Send a specific task updated notification
webSocketService.notifyTaskUpdated(
featureId,
formatTaskForFrontend(updatedTask!, featureId)
)
// Also send a status change notification if the status was updated
if (status) {
webSocketService.notifyTaskStatusChanged(featureId, taskId, status)
}
await databaseService.close()
res.json(formatTaskForFrontend(updatedTask!, featureId))
} catch (error: any) {
logger.error(`Failed to update task: ${error?.message || error}`)
await logToFile(
`[TaskServer] ERROR updating task: ${error?.message || error}`
)
res.status(500).json({ error: 'Failed to update task' })
}
})()
})
// DELETE: Remove a task
app.delete('/api/tasks/:taskId', (req: Request, res: Response) => {
;(async () => {
try {
const { taskId } = req.params
const { featureId } = req.query
if (!featureId) {
return res
.status(400)
.json({ error: 'Missing required query parameter: featureId' })
}
// Connect to database
await databaseService.connect()
// Get the task before deletion for the response
const task = await databaseService.getTaskById(taskId)
if (!task) {
await databaseService.close()
return res.status(404).json({ error: 'Task not found' })
}
// Delete the task
const deleted = await databaseService.deleteTask(taskId)
if (!deleted) {
await databaseService.close()
return res.status(404).json({ error: 'Failed to delete task' })
}
// Get updated tasks for WebSocket notification
const remainingTasks = await databaseService.getTasksByFeatureId(
featureId as string
)
// Notify clients via WebSocket - both general task update and specific deletion event
webSocketService.broadcast({
type: 'tasks_updated',
featureId: featureId as string,
payload: {
tasks: remainingTasks.map((task) =>
formatTaskForFrontend(task, featureId as string)
),
updatedAt: new Date().toISOString(),
},
})
// Send a specific task deleted notification
webSocketService.notifyTaskDeleted(featureId as string, taskId)
await databaseService.close()
res.json({
message: 'Task deleted successfully',
task: formatTaskForFrontend(task, featureId as string),
})
} catch (error: any) {
logger.error(`Failed to delete task: ${error?.message || error}`)
await logToFile(
`[TaskServer] ERROR deleting task: ${error?.message || error}`
)
res.status(500).json({ error: 'Failed to delete task' })
}
})()
})
// Get pending question for a specific feature
app.get(
'/api/features/:featureId/pending-question',
(req: Request, res: Response) => {
;(async () => {
const { featureId } = req.params
try {
const state = await planningStateService.getStateByFeatureId(
featureId
)
if (state && state.partialResponse) {
// Attempt to parse the stored partialResponse as JSON
let parsedData: any
try {
parsedData = JSON.parse(state.partialResponse)
} catch (parseError) {
logToFile(
`[TaskServer] Error parsing partialResponse JSON for feature ${featureId}: ${parseError}. Content: ${state.partialResponse}`
)
res.json(null) // Cannot parse the stored state
return
}
// Check if the parsed data contains the clarificationNeeded structure
if (parsedData && parsedData.clarificationNeeded) {
const clarification = parsedData.clarificationNeeded
logToFile(
`[TaskServer] Found pending question ${state.questionId} for feature ${featureId}`
)
res.json({
questionId: state.questionId, // Use the ID from the stored state
question: clarification.question,
options: clarification.options,
allowsText: clarification.allowsText,
})
} else {
logToFile(
`[TaskServer] State found for feature ${featureId}, but partialResponse JSON did not contain 'clarificationNeeded'. Content: ${state.partialResponse}`
)
res.json(null) // Parsed data structure unexpected
}
} else {
logToFile(
`[TaskServer] No pending question found for feature ${featureId}`
)
res.json(null) // No pending question found
}
} catch (error: any) {
logToFile(
`[TaskServer] ERROR fetching pending question for feature ${featureId}: ${
error?.message || error
}`
)
res.status(500).json({ error: 'Failed to fetch pending question' })
}
})()
}
)
// Default endpoint to get tasks from most recent feature
app.get('/api/tasks', (req: Request, res: Response) => {
;(async () => {
try {
const featureIds = await listFeatures()
if (featureIds.length === 0) {
// Return empty array if no features exist
return res.json([])
}
// Sort feature IDs by creation time (using file stats)
const featuresWithStats = await Promise.all(
featureIds.map(async (featureId) => {
const filePath = path.join(
FEATURE_TASKS_DIR,
`${featureId}_mcp_tasks.json`
)
const stats = await fs.stat(filePath)
return { featureId, mtime: stats.mtime }
})
)
// Sort by most recent modification time
featuresWithStats.sort(
(a, b) => b.mtime.getTime() - a.mtime.getTime()
)
// Get tasks for the most recent feature
const mostRecentFeatureId = featuresWithStats[0].featureId
await databaseService.connect()
const tasks = await databaseService.getTasksByFeatureId(
mostRecentFeatureId
)
await databaseService.close()
// Use the helper function to format tasks
const formattedTasks = tasks.map((task) =>
formatTaskForFrontend(task, mostRecentFeatureId)
)
res.json(formattedTasks)
} catch (error: any) {
await logToFile(
`[TaskServer] ERROR fetching default tasks: ${
error?.message || error
}`
)
res.status(500).json({ error: 'Failed to fetch tasks' })
}
})()
})
// Serve static frontend files
const staticFrontendPath = path.join(__dirname, 'frontend-ui')
app.use(express.static(staticFrontendPath))
// Catch-all route to serve the SPA for any unmatched routes
app.get('*', (req: Request, res: Response) => {
res.sendFile(path.join(staticFrontendPath, 'index.html'))
})
// Start the Express server and capture the HTTP server instance
const httpServer = app.listen(PORT, () => {
const url = `http://localhost:${PORT}`
logger.info(`Frontend server running at ${url}`)
})
// Initialize WebSocket service with the HTTP server instance
try {
await webSocketService.initialize(httpServer)
await logToFile(
'[TaskServer] LOG: WebSocket server attached to HTTP server.'
)
} catch (wsError) {
await logToFile(
`[TaskServer] WARN: Failed to initialize WebSocket server: ${wsError}`
)
logger.error('WebSocket server initialization failed:', wsError)
// Decide if this is fatal or can continue
}
// Handle process termination gracefully
process.on('SIGINT', async () => {
await logToFile(
'[TaskServer] LOG: Received SIGINT. Shutting down gracefully...'
)
// Shutdown WebSocket server
try {
await webSocketService.shutdown()
await logToFile(
'[TaskServer] LOG: WebSocket server shut down successfully.'
)
} catch (error) {
await logToFile(
`[TaskServer] ERROR: Error shutting down WebSocket server: ${error}`
)
}
process.exit(0)
})
process.on('SIGTERM', async () => {
await logToFile(
'[TaskServer] LOG: Received SIGTERM. Shutting down gracefully...'
)
// Shutdown WebSocket server
try {
await webSocketService.shutdown()
await logToFile(
'[TaskServer] LOG: WebSocket server shut down successfully.'
)
} catch (error) {
await logToFile(
`[TaskServer] ERROR: Error shutting down WebSocket server: ${error}`
)
}
process.exit(0)
})
} catch (connectError) {
logger.error('CRITICAL ERROR during server.connect():', connectError)
process.exit(1)
}
}
logger.info('Script execution reaching end of top-level code.')
main().catch((error) => {
logger.error('CRITICAL ERROR executing main():', error)
try {
const logDir = process.env.LOG_DIR || './logs'
const logFile = process.env.LOG_FILE || `${logDir}/debug.log`
fsSync.mkdirSync(logDir, { recursive: true })
fsSync.appendFileSync(
logFile,
`${new Date().toISOString()} - [TaskServer] CRITICAL ERROR executing main(): ${
error?.message || error
}\n${error?.stack || ''}\n`
)
} catch (logErr) {
logger.error('Error writing main() catch to sync log:', logErr)
}
process.exit(1) // Exit if main promise rejects
})
```
--------------------------------------------------------------------------------
/src/tools/planFeature.ts:
--------------------------------------------------------------------------------
```typescript
import path from 'path'
import crypto from 'crypto'
import {
Task,
PlanFeatureResponseSchema,
PlanningOutputSchema,
PlanningTaskSchema,
} from '../models/types'
import { promisify } from 'util'
import { aiService } from '../services/aiService'
import {
parseGeminiPlanResponse,
extractParentTaskId,
extractEffort,
ensureEffortRatings,
processAndBreakdownTasks,
detectClarificationRequest,
processAndFinalizePlan,
} from '../lib/llmUtils'
import { logToFile } from '../lib/logger'
import fs from 'fs/promises'
import { exec } from 'child_process'
import * as fsSync from 'fs'
import { encoding_for_model } from 'tiktoken'
import webSocketService from '../services/webSocketService'
import { z } from 'zod'
import { GEMINI_MODEL, OPENROUTER_MODEL, WS_PORT, UI_PORT } from '../config'
import { dynamicImportDefault } from '../lib/utils'
import planningStateService from '../services/planningStateService'
import { databaseService } from '../services/databaseService'
import { addHistoryEntry } from '../lib/dbUtils'
import { getCodebaseContext } from '../lib/repomixUtils'
// Promisify child_process.exec for easier async/await usage
const execPromise = promisify(exec)
interface PlanFeatureParams {
feature_description: string
project_path: string
}
// Revert interface to only expect text content for SDK compatibility
interface PlanFeatureResult {
content: Array<{ type: 'text'; text: string }>
isError?: boolean
}
// Define a standard structure for the serialized response
interface PlanFeatureStandardResponse {
status: 'completed' | 'awaiting_clarification' | 'error'
message: string
featureId: string
data?: any // For clarification details or potentially first task info
uiUrl?: string // Include UI URL for convenience
firstTask?: {
id: string
description: string
effort: string
}
}
/**
* Handles the plan_feature tool request
*/
export async function handlePlanFeature(
params: PlanFeatureParams
): Promise<PlanFeatureResult> {
const { feature_description, project_path } = params
// Generate a unique feature ID *first*
const featureId = crypto.randomUUID()
// Define UI URL early
const uiUrl = `http://localhost:${UI_PORT || 4999}?featureId=${featureId}`
let browserOpened = false // Flag to track if browser was opened for clarification
await logToFile(
`[TaskServer] Handling plan_feature request: "${feature_description}" (Path: ${
project_path || 'CWD'
}), Feature ID: ${featureId}`
)
// Define message and isError outside the try block to ensure they are always available
let message: string
let isError = false
let task_count: number | undefined = undefined // Keep track of task count
try {
// Record tool call in history *early*
await addHistoryEntry(featureId, 'tool_call', {
tool: 'plan_feature',
params: { feature_description, project_path },
})
// Create the feature record with project_path
try {
await databaseService.createFeature(
featureId,
feature_description,
project_path
)
await logToFile(
`[TaskServer] Created feature record with ID: ${featureId}, Project Path: ${project_path}`
)
} catch (featureError) {
await logToFile(
`[TaskServer] Error creating feature record: ${featureError}`
)
// Continue even if feature creation fails - we can recover later
}
const planningModel = aiService.getPlanningModel()
if (!planningModel) {
await logToFile(
'[TaskServer] Planning model not initialized (check API key).'
)
message = 'Error: Planning model not initialized. Check API Key.'
isError = true
// Record error in history, handling potential logging errors
try {
await addHistoryEntry(featureId, 'tool_response', {
tool: 'plan_feature',
isError: true,
message,
})
} catch (historyError) {
console.error(
`[TaskServer] Failed to add error history entry during model init failure: ${historyError}`
)
}
// Return the structured error object *serialized*
const errorResponse: PlanFeatureStandardResponse = {
status: 'error',
message: message,
featureId: featureId, // Include featureId even in early errors
}
return {
content: [{ type: 'text', text: JSON.stringify(errorResponse) }],
isError,
}
}
// --- Get Codebase Context using Utility Function ---
const targetDir = project_path || '.' // Keep targetDir logic
const { context: codebaseContext, error: contextError } =
await getCodebaseContext(
targetDir,
featureId // Use featureId as log context
)
// Handle potential errors from getCodebaseContext
if (contextError) {
message = contextError // Use the user-friendly error from the utility
isError = true
// Record error in history, handling potential logging errors
try {
await addHistoryEntry(featureId, 'tool_response', {
tool: 'plan_feature',
isError: true,
message,
step: 'repomix_context_gathering',
})
} catch (historyError) {
console.error(
`[TaskServer] Failed to add error history entry during context gathering failure: ${historyError}`
)
}
return { content: [{ type: 'text', text: message }], isError }
}
// Optional: Add token counting / compression logic here if needed,
// operating on the returned `codebaseContext`.
// This part is kept separate from the core getCodebaseContext utility for now.
// ... (Token counting and compression logic could go here)
// --- LLM Planning & Task Generation ---
let planSteps: string[] = []
try {
await logToFile('[TaskServer] Calling LLM API for planning...')
const contextPromptPart = codebaseContext
? `Based on the following codebase context:\n\`\`\`\n${codebaseContext}\n\`\`\`\n\n`
: 'Based on the provided feature description (no codebase context available):\n\n'
// Define the structured planning prompt incorporating the new schema
const structuredPlanningPrompt = `${contextPromptPart}Generate a detailed, step-by-step coding implementation plan for the feature: \"${feature_description}\".
The plan should ONLY include actionable tasks a developer needs to perform within the code. Exclude steps related to project management, deployment, manual testing, documentation updates, or obtaining approvals.
For each coding task, include an effort rating (low, medium, or high) based on implementation work involved. High effort tasks often require breakdown.
Use these effort definitions:
- Low: Simple, quick changes in one or few files, minimal logic changes.
- Medium: Requires moderate development time, involves changes across several files/components, includes writing new functions/classes.
- High: Significant development time, complex architectural changes, intricate algorithms, deep refactoring.
**RESPONSE FORMAT:**
You MUST respond with a single JSON object.
**Case 1: Clarification Needed**
If you require clarification before creating the plan, respond with this JSON structure:
\`\`\`json
{\n "clarificationNeeded": {\n "question": "Your specific question here. Be precise.",
\n "options": ["Option A", "Option B"] // Optional: Provide options if helpful
\n "allowsText": true // Optional: Set to false if only options are valid
\n }\n }\`\`\`
**Case 2: No Clarification Needed**
If you DO NOT need clarification, respond with this JSON structure, containing a non-empty array of tasks:
\`\`\`json
{\n "tasks": [\n { "description": "Description of the first task", "effort": "low" | "medium" | "high" },\n { "description": "Description of the second task", "effort": "low" | "medium" | "high" },\n ...\n ]\n }\`\`\`
**IMPORTANT:** Respond *only* with the valid JSON object conforming to one of the two cases described above. Do not include any introductory text, concluding remarks, or markdown formatting outside the JSON structure.`
// Log truncated structured prompt for history
await addHistoryEntry(featureId, 'model', {
step: 'structured_planning_prompt',
prompt: `Generate structured plan or clarification for: "${feature_description}" ${
codebaseContext ? '(with context)' : '(no context)'
}...`,
})
if ('chat' in planningModel) {
// It's OpenRouter - Use structured output
const structuredResult = await aiService.callOpenRouterWithSchema(
OPENROUTER_MODEL,
[{ role: 'user', content: structuredPlanningPrompt }],
PlanFeatureResponseSchema,
{ temperature: 0.7 }
)
logToFile(
`[TaskServer] Structured result (OpenRouter): ${JSON.stringify(
structuredResult
)}`
)
if (structuredResult.success) {
// Check if clarification is needed
if (structuredResult.data.clarificationNeeded) {
logToFile(
'[TaskServer] Clarification needed based on structured response.'
)
const clarification = structuredResult.data.clarificationNeeded
// Open the browser *now* to show the question
try {
logToFile(`[TaskServer] Launching UI for clarification: ${uiUrl}`)
const open = await dynamicImportDefault('open')
await open(uiUrl)
browserOpened = true // Mark browser as opened
logToFile(
'[TaskServer] Browser launch for clarification initiated.'
)
} catch (openError: any) {
logToFile(
`[TaskServer] Error launching browser for clarification: ${openError.message}`
)
// Continue even if browser launch fails, WS should still work if UI is open
}
// Store the intermediate state
const questionId =
await planningStateService.storeIntermediateState(
featureId,
structuredPlanningPrompt,
JSON.stringify(structuredResult.data),
'feature_planning'
)
// Send WebSocket message
webSocketService.broadcast({
type: 'show_question',
featureId,
payload: {
questionId,
question: clarification.question,
options: clarification.options,
allowsText: clarification.allowsText,
},
})
// Record in history
await addHistoryEntry(featureId, 'tool_response', {
tool: 'plan_feature',
status: 'awaiting_clarification',
questionId,
})
// Return structured clarification info *serialized as text*
const clarificationData = {
questionId: questionId,
question: clarification.question,
options: clarification.options,
allowsText: clarification.allowsText,
}
const clarificationResponse: PlanFeatureStandardResponse = {
status: 'awaiting_clarification',
message: `Planning paused for feature ${featureId}. User clarification needed via UI (${uiUrl}). Once submitted, call 'get_next_task' with featureId '${featureId}' to retrieve the first task.`,
featureId: featureId,
data: clarificationData,
uiUrl: uiUrl,
}
return {
// Serialize the standard response structure
content: [
{ type: 'text', text: JSON.stringify(clarificationResponse) },
],
isError: false, // Not an error, just waiting
}
} else if (structuredResult.data.tasks) {
logToFile('[TaskServer] Tasks received in structured response.')
// Convert the structured response to the expected format for processing
planSteps = structuredResult.data.tasks.map(
(task) => `[${task.effort}] ${task.description}`
)
await addHistoryEntry(featureId, 'model', {
step: 'structured_planning_response',
response: JSON.stringify(structuredResult.data),
})
} else {
// Schema validation should prevent this, but handle defensively
throw new Error(
'Structured response valid but contained neither tasks nor clarification.'
)
}
} else {
// Fallback to unstructured response if structured fails
console.warn(
`[TaskServer] Structured planning failed: ${structuredResult.error}. Falling back to unstructured format.`
)
// Use traditional prompt and formatting
const unstructuredFallbackPrompt = `${contextPromptPart}Generate a detailed, step-by-step coding implementation plan for the feature: "${feature_description}".
The plan should ONLY include actionable tasks a developer needs to perform within the code. Exclude steps related to project management, deployment, manual testing, documentation updates, or obtaining approvals.
For each coding task, include an effort rating (low, medium, or high) based on implementation work involved. High effort tasks often require breakdown.
Use these effort definitions:
- Low: Simple, quick changes in one or few files, minimal logic changes.
- Medium: Requires moderate development time, involves changes across several files/components, includes writing new functions/classes.
- High: Significant development time, complex architectural changes, intricate algorithms, deep refactoring.
IF YOU NEED CLARIFICATION BEFORE YOU CAN PROPERLY CREATE THE PLAN:
Instead of returning a task list, use the following format to ask for clarification:
[CLARIFICATION_NEEDED]
Your specific question here. Be precise about what information you need to proceed.
Options: [Option A, Option B, Option C] (include this line only if providing multiple-choice options)
MULTIPLE_CHOICE_ONLY (include this if only the listed options are valid, omit if free text is also acceptable)
[END_CLARIFICATION]
For example:
[CLARIFICATION_NEEDED]
Should the feature implementation use SQL or NoSQL database for data storage?
Options: [SQL, NoSQL]
[END_CLARIFICATION]
IF NO CLARIFICATION IS NEEDED:
**IMPORTANT: Do NOT include any introductory sentences, summaries, concluding remarks, or any text other than the formatted task list itself. The response must start *directly* with the first task (e.g., "[low] Add console log...").**
Now, generate the list of coding tasks, ensuring each task strictly follows this format: [effort] Task Description`
let result = await planningModel.chat.completions.create({
model: OPENROUTER_MODEL,
messages: [{ role: 'user', content: unstructuredFallbackPrompt }],
temperature: 0.7,
})
// We need to get the text response here for the old parsing logic to work
let responseText = aiService.extractTextFromResponse(result)
if (responseText === null) {
message =
'Error: Failed to get planning response from LLM or response was blocked. AI Agent: Do not try to call again. Simply alert the user.'
isError = true
// Record error in history, handling potential logging errors
try {
await addHistoryEntry(featureId, 'tool_response', {
tool: 'plan_feature',
isError: true,
message,
step: 'llm_response_processing',
})
} catch (historyError) {
console.error(
`[TaskServer] Failed to add error history entry during LLM processing failure: ${historyError}`
)
}
return { content: [{ type: 'text', text: message }], isError }
}
// If no plan steps were extracted from structured response, try text parsing from fallback
if (!planSteps.length && responseText) {
logToFile(
'[TaskServer] Attempting text parsing on unstructured fallback response.'
)
// IMPORTANT: Ensure parseGeminiPlanResponse ONLY extracts tasks and doesn't get confused by potential JSON remnants
planSteps = parseGeminiPlanResponse(responseText)
if (planSteps.length > 0) {
logToFile(
`[TaskServer] Extracted ${planSteps.length} tasks via text parsing.`
)
} else {
// If still no tasks, log error and return *serialized*
message =
'Error: The planning model did not return a recognizable list of tasks.'
isError = true
// Record error in history, handling potential logging errors
try {
await addHistoryEntry(featureId, 'tool_response', {
tool: 'plan_feature',
isError: true,
message,
step: 'response_parsing',
})
} catch (historyError) {
console.error(
`[TaskServer] Failed to add error history entry during response parsing failure: ${historyError}`
)
}
const errorResponse: PlanFeatureStandardResponse = {
status: 'error',
message: message,
featureId: featureId,
}
return {
content: [
{ type: 'text', text: JSON.stringify(errorResponse) },
],
isError: true,
}
}
}
}
} else {
// It's Gemini - Use structured output
const structuredResult = await aiService.callGeminiWithSchema(
GEMINI_MODEL,
structuredPlanningPrompt,
PlanFeatureResponseSchema,
{ temperature: 0.7 }
)
logToFile(
`[TaskServer] Structured result (Gemini): ${JSON.stringify(
structuredResult
)}`
)
if (structuredResult.success) {
// Check if clarification is needed
if (structuredResult.data.clarificationNeeded) {
logToFile(
'[TaskServer] Clarification needed based on structured response.'
)
const clarification = structuredResult.data.clarificationNeeded
// Open the browser *now* to show the question
try {
logToFile(`[TaskServer] Launching UI for clarification: ${uiUrl}`)
const open = await dynamicImportDefault('open')
await open(uiUrl)
browserOpened = true // Mark browser as opened
logToFile(
'[TaskServer] Browser launch for clarification initiated.'
)
} catch (openError: any) {
logToFile(
`[TaskServer] Error launching browser for clarification: ${openError.message}`
)
// Continue even if browser launch fails, WS should still work if UI is open
}
// Store the intermediate state
const questionId =
await planningStateService.storeIntermediateState(
featureId,
structuredPlanningPrompt,
JSON.stringify(structuredResult.data),
'feature_planning'
)
// Send WebSocket message
webSocketService.broadcast({
type: 'show_question',
featureId,
payload: {
questionId,
question: clarification.question,
options: clarification.options,
allowsText: clarification.allowsText,
},
})
// Record in history
await addHistoryEntry(featureId, 'tool_response', {
tool: 'plan_feature',
status: 'awaiting_clarification',
questionId,
})
// Return structured clarification info *serialized as text*
const clarificationData = {
questionId: questionId,
question: clarification.question,
options: clarification.options,
allowsText: clarification.allowsText,
}
const clarificationResponse: PlanFeatureStandardResponse = {
status: 'awaiting_clarification',
message: `Planning paused for feature ${featureId}. User clarification needed via UI (${uiUrl}). Once submitted, call 'get_next_task' with featureId '${featureId}' to retrieve the first task.`,
featureId: featureId,
data: clarificationData,
uiUrl: uiUrl,
}
return {
// Serialize the standard response structure
content: [
{ type: 'text', text: JSON.stringify(clarificationResponse) },
],
isError: false, // Not an error, just waiting
}
} else if (structuredResult.data.tasks) {
logToFile('[TaskServer] Tasks received in structured response.')
// Convert the structured response to the expected format for processing
planSteps = structuredResult.data.tasks.map(
(task) => `[${task.effort}] ${task.description}`
)
await addHistoryEntry(featureId, 'model', {
step: 'structured_planning_response',
response: JSON.stringify(structuredResult.data),
})
} else {
// Schema validation should prevent this, but handle defensively
throw new Error(
'Structured response valid but contained neither tasks nor clarification.'
)
}
} else {
// Fallback to unstructured response if structured fails
console.warn(
`[TaskServer] Structured planning failed: ${structuredResult.error}. Falling back to unstructured format.`
)
// Use traditional Gemini call
const unstructuredFallbackPrompt = `${contextPromptPart}Generate a detailed, step-by-step coding implementation plan for the feature: "${feature_description}".
Engineer it in the best way possible, considering all side effects and edge cases. Be extremely thorough and meticulous.
The plan should ONLY include actionable tasks a developer needs to perform within the code. Exclude steps related to project management, deployment, manual testing, documentation updates, or obtaining approvals.
For each coding task, include an effort rating (low, medium, or high) based on implementation work involved. High effort tasks often require breakdown.
Use these effort definitions:
- Low: Simple, quick changes in one or few files, minimal logic changes.
- Medium: Requires moderate development time, involves changes across several files/components, includes writing new functions/classes.
- High: Significant development time, complex architectural changes, intricate algorithms, deep refactoring.
IF YOU NEED CLARIFICATION BEFORE YOU CAN PROPERLY CREATE THE PLAN:
Instead of returning a task list, use the following format to ask for clarification:
[CLARIFICATION_NEEDED]
Your specific question here. Be precise about what information you need to proceed.
Options: [Option A, Option B, Option C] (include this line only if providing multiple-choice options)
MULTIPLE_CHOICE_ONLY (include this if only the listed options are valid, omit if free text is also acceptable)
[END_CLARIFICATION]
For example:
[CLARIFICATION_NEEDED]
Should the feature implementation use SQL or NoSQL database for data storage?
Options: [SQL, NoSQL]
[END_CLARIFICATION]
IF NO CLARIFICATION IS NEEDED:
**IMPORTANT: Do NOT include any introductory sentences, summaries, concluding remarks, or any text other than the formatted task list itself. The response must start *directly* with the first task (e.g., "[low] Add console log...").**
Now, generate the list of coding tasks, ensuring each task strictly follows this format: [effort] Task Description`
let result = await planningModel.generateContent({
contents: [
{ role: 'user', parts: [{ text: unstructuredFallbackPrompt }] },
],
generationConfig: {
temperature: 0.7,
},
})
// We need to get the text response here for the old parsing logic to work
let responseText = aiService.extractTextFromResponse(result)
if (responseText === null) {
message =
'Error: Failed to get planning response from LLM or response was blocked. AI Agent: Do not try to call again. Simply alert the user.'
isError = true
// Record error in history, handling potential logging errors
try {
await addHistoryEntry(featureId, 'tool_response', {
tool: 'plan_feature',
isError: true,
message,
step: 'llm_response_processing',
})
} catch (historyError) {
console.error(
`[TaskServer] Failed to add error history entry during LLM processing failure: ${historyError}`
)
}
return { content: [{ type: 'text', text: message }], isError }
}
// If no plan steps were extracted from structured response, try text parsing from fallback
if (!planSteps.length && responseText) {
logToFile(
'[TaskServer] Attempting text parsing on unstructured fallback response.'
)
// IMPORTANT: Ensure parseGeminiPlanResponse ONLY extracts tasks and doesn't get confused by potential JSON remnants
planSteps = parseGeminiPlanResponse(responseText)
if (planSteps.length > 0) {
logToFile(
`[TaskServer] Extracted ${planSteps.length} tasks via text parsing.`
)
} else {
// If still no tasks, log error and return *serialized*
message =
'Error: The planning model did not return a recognizable list of tasks.'
isError = true
// Record error in history, handling potential logging errors
try {
await addHistoryEntry(featureId, 'tool_response', {
tool: 'plan_feature',
isError: true,
message,
step: 'response_parsing',
})
} catch (historyError) {
console.error(
`[TaskServer] Failed to add error history entry during response parsing failure: ${historyError}`
)
}
const errorResponse: PlanFeatureStandardResponse = {
status: 'error',
message: message,
featureId: featureId,
}
return {
content: [
{ type: 'text', text: JSON.stringify(errorResponse) },
],
isError: true,
}
}
}
}
}
// Process the plan steps using the centralized function
const finalTasks = await processAndFinalizePlan(
planSteps, // Use the extracted/parsed plan steps
planningModel,
featureId
)
task_count = finalTasks.length
message = `Successfully planned feature '${feature_description}' with ${task_count} tasks.`
logToFile(`[TaskServer] ${message}`)
// Record final success in history
await addHistoryEntry(featureId, 'tool_response', {
tool: 'plan_feature',
status: 'completed',
taskCount: task_count,
})
} catch (error: any) {
message = `Error during feature planning: ${error.message}`
isError = true
await logToFile(`[TaskServer] ${message} Stack: ${error.stack}`)
// Record error in history, handling potential logging errors
try {
await addHistoryEntry(featureId, 'tool_response', {
tool: 'plan_feature',
isError: true,
message: error.message,
step: 'planning_execution', // Indicate where the error occurred
})
} catch (historyError) {
console.error(
`[TaskServer] Failed to add error history entry during planning execution failure: ${historyError}`
)
}
}
} catch (outerError: any) {
// Catch errors happening before LLM call (e.g., history writing)
message = `[TaskServer] Unexpected error in handlePlanFeature: ${
outerError?.message || String(outerError)
}`
isError = true
await logToFile(`${message} Stack: ${outerError?.stack}`, 'error')
// Record error in history, handling potential logging errors
try {
await addHistoryEntry(featureId, 'tool_response', {
tool: 'plan_feature',
isError: true,
message: outerError?.message || String(outerError),
step: 'outer_catch_block', // Indicate where the error was caught
errorDetails: outerError?.stack, // Include stack trace if available
})
} catch (historyError: any) {
// Log the failure to record history, but don't crash the main process
await logToFile(
`[TaskServer] Failed to record history entry for outer error: ${
historyError?.message || String(historyError)
}`,
'error'
)
}
}
// Open the UI in the browser *if not already opened for clarification*
if (!browserOpened) {
try {
await logToFile(
`[TaskServer] Planning complete/failed. Launching UI: ${uiUrl}`
)
const open = await dynamicImportDefault('open')
await open(uiUrl)
await logToFile('[TaskServer] Browser launch initiated successfully')
} catch (openError: any) {
await logToFile(
`[TaskServer] Error launching browser post-process: ${openError.message}`
)
// Continue even if browser launch fails
}
}
// Prepare the final return content *as standard response object*
let responseData: PlanFeatureStandardResponse
if (!isError && task_count && task_count > 0) {
let firstTaskDesc: string | undefined
let updatedTasks: Task[] = []
try {
// Use databaseService instead of readTasks
await databaseService.connect()
updatedTasks = await databaseService.getTasksByFeatureId(featureId)
await databaseService.close()
if (updatedTasks.length > 0) {
const firstTask = updatedTasks[0]
// Format the first task for the return message
firstTaskDesc = firstTask.description // Store first task desc
} else {
// Fallback if tasks array is somehow empty after successful planning
firstTaskDesc = undefined
}
} catch (readError) {
logToFile(
`[TaskServer] Error reading tasks after finalization: ${readError}`
)
// Fallback to the original message if reading fails
firstTaskDesc = undefined
}
// Construct success response
responseData = {
status: 'completed',
message: `Successfully planned ${task_count || 0} tasks.${
firstTaskDesc ? ' First task: "' + firstTaskDesc + '"' : ''
}`,
featureId: featureId,
uiUrl: uiUrl,
firstTask:
updatedTasks.length > 0
? {
id: updatedTasks[0].id,
description: updatedTasks[0].description || '',
effort: updatedTasks[0].effort || 'medium',
}
: undefined,
}
} else {
// Construct error or no-tasks response
responseData = {
status: isError ? 'error' : 'completed', // 'completed' but with 0 tasks is possible
message: message, // Use the message determined earlier (could be error or success-with-0-tasks)
featureId: featureId,
uiUrl: uiUrl,
}
}
// Final return structure using the standard serialized format
return {
content: [
{
type: 'text',
text: JSON.stringify(responseData),
},
],
isError, // Keep isError consistent with internal state for SDK
}
}
```
--------------------------------------------------------------------------------
/frontend/src/routes/+page.svelte:
--------------------------------------------------------------------------------
```
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { fade } from 'svelte/transition';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Select from '$lib/components/ui/select';
import { Progress } from '$lib/components/ui/progress';
import { Loader2, CornerDownLeft, CornerDownRight, Pencil, Trash2, FileText, Eye } from 'lucide-svelte';
import { writable, type Writable } from 'svelte/store';
import type { Task, WebSocketMessage, ShowQuestionPayload, QuestionResponsePayload } from '$lib/types';
import { TaskStatus, TaskEffort } from '$lib/types';
import type { Selected } from 'bits-ui';
import QuestionModal from '$lib/components/QuestionModal.svelte';
import TaskFormModal from '$lib/components/TaskFormModal.svelte';
import ImportTasksModal from '$lib/components/ImportTasksModal.svelte';
// Convert to writable stores for better state management
const tasks: Writable<Task[]> = writable([]);
let nestedTasks: Task[] = [];
const loading: Writable<boolean> = writable(true);
const error: Writable<string | null> = writable(null);
let featureId: string | null = null;
let features: string[] = [];
let loadingFeatures = true;
let ws: WebSocket | null = null;
let wsStatus: 'connecting' | 'connected' | 'disconnected' = 'disconnected';
// Question modal state
let showQuestionModal = false;
let questionData: ShowQuestionPayload | null = null;
let selectedOption = '';
let userResponse = '';
// Task form modal state
let showTaskFormModal = false;
let editingTask: Task | null = null;
let isEditing = false;
let waitingOnLLM = false;
let showImportModal = false;
// Reactive statement to update nestedTasks when tasks store changes
$: {
const taskMap = new Map<string, Task & { children: Task[] }>();
const rootTasks: Task[] = [];
// Use the tasks from the store ($tasks)
$tasks.forEach(task => {
// Ensure the task object has the correct type including children array
const taskWithChildren: Task & { children: Task[] } = {
...task,
children: []
};
taskMap.set(task.id, taskWithChildren);
});
$tasks.forEach(task => {
const currentTask = taskMap.get(task.id)!; // Should always exist
if (task.parentTaskId && taskMap.has(task.parentTaskId)) {
taskMap.get(task.parentTaskId)!.children.push(currentTask);
} else {
rootTasks.push(currentTask);
}
});
nestedTasks = rootTasks;
}
// --- WebSocket Functions ---
function connectWebSocket() {
// Construct WebSocket URL (ws:// or wss:// based on protocol)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}`;
console.log(`[WS Client] Attempting to connect to ${wsUrl}...`);
wsStatus = 'connecting';
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[WS Client] WebSocket connection established.');
wsStatus = 'connected';
// Register this client for the current feature
if (featureId && ws) {
sendWsMessage({
type: 'client_registration',
featureId: featureId,
payload: { featureId: featureId, clientId: `browser-${Date.now()}` } // Basic client ID
});
}
};
ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
console.log('[WS Client] Received message:', message);
// Check if the message is for the currently viewed feature
if (message.featureId && message.featureId !== featureId) {
console.log('[WS Client] Ignoring message for different feature:', message.featureId);
return;
}
switch (message.type) {
case 'tasks_updated':
waitingOnLLM = false;
console.log(`[WS Client] Received tasks_updated for feature ${featureId}:`, message.payload.tasks);
if (message.payload?.tasks && Array.isArray(message.payload.tasks) && featureId) {
// Map incoming tasks using the helper function to ensure consistency
const mappedTasks = message.payload.tasks.map((task: any) =>
mapApiTaskToClientTask(task, featureId as string)
);
tasks.set(mappedTasks);
// Detailed logging after update
tasks.subscribe(currentTasks => {
console.log('[WS Client] tasks store after set():', currentTasks);
})();
// Explicitly set loading to false
loading.set(false);
error.set(null); // Clear any previous errors
} else {
console.warn('[WS Client] Invalid or missing tasks payload for tasks_updated message.');
// Optionally handle this case, e.g., set an error or leave tasks unchanged
}
break;
case 'status_changed':
console.log(`[WS Client] Received status_changed for task ${message.payload?.taskId}`);
if (message.payload?.taskId && message.payload?.status) {
// Map incoming status string to TaskStatus enum
let newStatus: TaskStatus;
switch (message.payload.status) {
case 'completed': newStatus = TaskStatus.COMPLETED; break;
case 'in_progress': newStatus = TaskStatus.IN_PROGRESS; break;
case 'decomposed': newStatus = TaskStatus.DECOMPOSED; break;
default: newStatus = TaskStatus.PENDING; break;
}
tasks.update(currentTasks =>
currentTasks.map(task =>
task.id === message.payload.taskId
? {
...task,
status: newStatus,
// Completed is true ONLY if status is COMPLETED
completed: newStatus === TaskStatus.COMPLETED
}
: task
)
);
// Status change doesn't imply general loading state change
}
break;
case 'show_question':
waitingOnLLM = false;
console.log('[WS Client] Received clarification question:', message.payload);
// Store question data and show modal
questionData = message.payload as ShowQuestionPayload;
showQuestionModal = true;
// When question arrives, we should stop loading indicator
loading.set(false);
break;
case 'error':
waitingOnLLM = false;
console.error('[WS Client] Received error message:', message.payload);
// Display user-facing error
error.set(message.payload?.message || 'Received error from server.');
// Error likely means loading is done (with an error)
loading.set(false);
break;
case 'task_created':
console.log('[WS Client] Received task_created:', message.payload);
if (message.payload?.task) {
// Map incoming task to our Task type
const newTask = mapApiTaskToClientTask(message.payload.task, message.featureId || featureId || '');
// Add the new task to the store
tasks.update(currentTasks => [...currentTasks, newTask]);
// Process nested structure
processNestedTasks();
}
break;
case 'task_updated':
console.log('[WS Client] Received task_updated:', message.payload);
if (message.payload?.task) {
// Map incoming task to our Task type
const updatedTask = mapApiTaskToClientTask(message.payload.task, message.featureId || featureId || '');
// Update the existing task in the store
tasks.update(currentTasks =>
currentTasks.map(task =>
task.id === updatedTask.id ? updatedTask : task
)
);
// Process nested structure
processNestedTasks();
}
break;
case 'task_deleted':
console.log('[WS Client] Received task_deleted:', message.payload);
if (message.payload?.taskId) {
// Remove the task from the store
tasks.update(currentTasks =>
currentTasks.filter(task => task.id !== message.payload.taskId)
);
// Process nested structure
processNestedTasks();
}
break;
case 'connection_established':
console.log('[WS Client] Server confirmed connection.');
break;
case 'client_registration':
console.log('[WS Client] Server confirmed registration:', message.payload);
break;
// Add other message type handlers if needed
}
} catch (e) {
console.error('[WS Client] Error processing message:', e);
loading.set(false); // Ensure loading is set to false on error
}
};
ws.onerror = (error) => {
console.error('[WS Client] WebSocket error:', error);
wsStatus = 'disconnected';
loading.set(false); // Ensure loading is false on WebSocket error
};
ws.onclose = (event) => {
console.log(`[WS Client] WebSocket connection closed. Code: ${event.code}, Reason: ${event.reason}`);
wsStatus = 'disconnected';
ws = null;
// Ensure loading is false when WebSocket disconnects
loading.set(false);
};
}
function sendWsMessage(message: WebSocketMessage) {
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify(message));
console.log('[WS Client] Sent message:', message);
} catch (e) {
console.error('[WS Client] Error sending message:', e);
}
} else {
console.warn('[WS Client] Cannot send message, WebSocket not open.');
}
}
// --- Component Lifecycle & Data Fetching ---
async function fetchTasks(selectedFeatureId?: string) {
loading.set(true);
error.set(null);
try {
// Construct the API endpoint based on whether we have a featureId
const endpoint = selectedFeatureId
? `/api/tasks/${selectedFeatureId}`
: '/api/tasks';
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error(`Failed to fetch tasks: ${response.statusText}`);
}
const data = await response.json();
// Convert API response to our Task type
const mappedData = data.map((task: any) => {
// Map incoming status string to TaskStatus enum
let status: TaskStatus;
switch (task.status) {
case 'completed': status = TaskStatus.COMPLETED; break;
case 'in_progress': status = TaskStatus.IN_PROGRESS; break;
case 'decomposed': status = TaskStatus.DECOMPOSED; break;
default: status = TaskStatus.PENDING; break;
}
// Ensure effort is one of our enum values
let effort: TaskEffort = TaskEffort.MEDIUM; // Default
if (task.effort === 'low') {
effort = TaskEffort.LOW;
} else if (task.effort === 'high') {
effort = TaskEffort.HIGH;
}
// Derive title from description if not present
const title = task.title || task.description;
// Ensure completed flag is consistent with status
const completed = status === TaskStatus.COMPLETED;
// Return the fully mapped task
return {
id: task.id,
title,
description: task.description,
status,
completed,
effort,
feature_id: task.feature_id || selectedFeatureId || undefined,
parentTaskId: task.parentTaskId,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
fromReview: task.fromReview
} as Task;
});
tasks.set(mappedData);
if (mappedData.length === 0) {
error.set('No tasks found for this feature.');
}
} catch (err) {
error.set(err instanceof Error ? err.message : 'An error occurred');
// Add more detailed logging
console.error('[fetchTasks] Error fetching tasks:', err);
if (err instanceof Error && err.cause) {
console.error('[fetchTasks] Error Cause:', err.cause);
}
tasks.set([]); // Clear any previous tasks
} finally {
// Always reset loading state when fetch completes
loading.set(false);
}
}
async function fetchFeatures() {
loadingFeatures = true;
try {
const response = await fetch('/api/features');
if (!response.ok) {
throw new Error('Failed to fetch features');
}
features = await response.json();
} catch (err) {
console.error('Error fetching features:', err);
features = [];
} finally {
loadingFeatures = false;
}
}
// New function to fetch pending question
async function fetchPendingQuestion(id: string): Promise<ShowQuestionPayload | null> {
console.log(`[Pending Question] Checking for feature ${id}...`);
try {
const response = await fetch(`/api/features/${id}/pending-question`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ShowQuestionPayload | null = await response.json();
if (data) {
console.log('[Pending Question] Found pending question:', data);
return data;
} else {
console.log('[Pending Question] No pending question found.');
return null;
}
} catch (err) {
console.error('[Pending Question] Error fetching pending question:', err);
error.set(err instanceof Error ? `Error checking for pending question: ${err.message}` : 'An error occurred while checking for pending questions.');
return null;
}
}
function processNestedTasks() {
// Define the type for map values explicitly
type TaskWithChildren = Task & { children: Task[] };
const taskMap = new Map<string, TaskWithChildren>(
$tasks.map(task => [task.id, { ...task, children: [] }])
);
const rootTasks: Task[] = [];
taskMap.forEach((task: TaskWithChildren) => {
if (task.parentTaskId && taskMap.has(task.parentTaskId)) {
const parent = taskMap.get(task.parentTaskId);
if (parent) {
parent.children.push(task);
} else {
rootTasks.push(task);
}
} else {
rootTasks.push(task);
}
});
// Optional: Sort root tasks or children if needed
// rootTasks.sort(...);
// taskMap.forEach(task => task.children.sort(...));
nestedTasks = rootTasks;
}
async function addTask(taskData: { title: string; effort: string; featureId: string, description?: string }) {
try {
const response = await fetch('/api/tasks', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
...taskData,
description: taskData.description || taskData.title // Use provided description, or title if none
})
});
if (!response.ok) {
throw new Error(`Failed to create task: ${response.statusText}`);
}
const newTask = await response.json();
console.log('[Task] New task created:', newTask);
// Refresh the tasks list
await fetchTasks(taskData.featureId);
// Clear any errors that might have been shown
error.set(null);
} catch (err) {
console.error('[Task] Error creating task:', err);
error.set(err instanceof Error ? err.message : 'Failed to create task');
}
}
function handleTaskFormSubmit(event: CustomEvent) {
const taskData = event.detail;
if (isEditing && editingTask) {
updateTask(editingTask.id, taskData);
} else {
addTask(taskData);
}
showTaskFormModal = false;
isEditing = false;
editingTask = null;
}
function openEditTaskModal(task: Task) {
editingTask = task;
isEditing = true;
showTaskFormModal = true;
}
async function updateTask(taskId: string, taskData: { title: string; effort: string; featureId: string }) {
try {
const response = await fetch(`/api/tasks/${taskId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
...taskData,
description: taskData.title, // Use title as description
featureId: taskData.featureId
})
});
if (!response.ok) {
throw new Error(`Failed to update task: ${response.statusText}`);
}
const updatedTask = await response.json();
console.log('[Task] Task updated:', updatedTask);
// Refresh the tasks list
await fetchTasks(taskData.featureId);
// Clear any errors that might have been shown
error.set(null);
} catch (err) {
console.error('[Task] Error updating task:', err);
error.set(err instanceof Error ? err.message : 'Failed to update task');
}
}
async function deleteTask(taskId: string, featureId: string) {
if (!confirm('Are you sure you want to delete this task?')) {
return;
}
try {
const response = await fetch(`/api/tasks/${taskId}?featureId=${featureId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Failed to delete task: ${response.statusText}`);
}
console.log('[Task] Task deleted:', taskId);
// Refresh the tasks list
await fetchTasks(featureId);
// Clear any errors that might have been shown
error.set(null);
} catch (err) {
console.error('[Task] Error deleting task:', err);
error.set(err instanceof Error ? err.message : 'Failed to delete task');
}
}
onMount(async () => {
loading.set(true); // Set loading true at the start
error.set(null); // Reset error
// Extract featureId from URL query parameters
featureId = $page.url.searchParams.get('featureId');
// Fetch available features first
await fetchFeatures();
// Determine the featureId to use (from URL or latest)
if (!featureId && features.length > 0) {
// Attempt to fetch default tasks to find the latest featureId
await fetchTasks();
if ($tasks.length > 0 && $tasks[0]?.feature_id) {
featureId = $tasks[0].feature_id;
console.log(`[onMount] Determined latest featureId: ${featureId}`);
} else {
console.log('[onMount] Could not determine latest featureId from default tasks.');
// If no featureId determined, use the first from the list if available
if (features.length > 0) {
featureId = features[0];
console.log(`[onMount] Using first available featureId: ${featureId}`);
}
}
}
// Now, if we have a featureId, check for pending questions and fetch tasks
if (featureId) {
console.log(`[onMount] Operating with featureId: ${featureId}`);
// Check for pending question first
const pendingQuestion = await fetchPendingQuestion(featureId);
if (pendingQuestion) {
questionData = pendingQuestion;
showQuestionModal = true;
// Still fetch tasks even if question is shown, they might exist
await fetchTasks(featureId);
} else {
// No pending question, just fetch tasks
await fetchTasks(featureId);
}
} else {
// No featureId could be determined
console.log('[onMount] No featureId available.');
if (!$error) { // Only set error if fetchTasks didn't already set one
error.set('No features found. Create a feature first using the task manager CLI.');
}
tasks.set([]); // Ensure tasks are empty
nestedTasks = [];
}
// Connect WebSocket AFTER initial data load and featureId determination
if (featureId) {
connectWebSocket();
}
});
onDestroy(() => {
// Clean up WebSocket connection
if (ws) {
console.log('[WS Client] Closing WebSocket connection.');
ws.close();
ws = null;
}
});
async function toggleTaskStatus(taskId: string) {
const tasksList = $tasks;
const taskIndex = tasksList.findIndex((t) => t.id === taskId);
if (taskIndex !== -1) {
const task = tasksList[taskIndex];
const newStatus = task.status === TaskStatus.COMPLETED ? TaskStatus.PENDING : TaskStatus.COMPLETED;
try {
// Make API call to update status in backend
const response = await fetch(`/api/tasks/${taskId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
featureId: task.feature_id,
status: newStatus,
completed: newStatus === TaskStatus.COMPLETED
})
});
if (!response.ok) {
throw new Error('Failed to update task status');
}
// Optionally update local store or rely on WebSocket update
} catch (err) {
console.error('Failed to update task status:', err);
// Optionally show error to user
}
}
}
function getEffortBadgeVariant(effort: string) {
switch (effort) {
case TaskEffort.LOW:
return 'secondary';
case TaskEffort.MEDIUM:
return 'default';
case TaskEffort.HIGH:
return 'destructive';
default:
return 'outline';
}
}
function getStatusBadgeVariant(status: TaskStatus): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case TaskStatus.COMPLETED:
return 'secondary';
case TaskStatus.IN_PROGRESS:
return 'default';
case TaskStatus.DECOMPOSED:
return 'outline';
case TaskStatus.PENDING:
default:
return 'outline';
}
}
function refreshTasks() {
if ($loading) return;
console.log('[Task List] Refreshing tasks...');
fetchTasks(featureId || undefined);
}
function handleFeatureChange(selectedItem: Selected<string> | undefined) {
const newFeatureId = selectedItem?.value; // Safely get value
if (newFeatureId && newFeatureId !== featureId) {
featureId = newFeatureId;
// Update URL
const url = new URL(window.location.href);
url.searchParams.set('featureId', newFeatureId);
window.history.pushState({}, '', url);
// Fetch tasks for the new feature
fetchTasks(newFeatureId);
// Re-register WebSocket for the new feature
if (ws && wsStatus === 'connected') {
sendWsMessage({
type: 'client_registration',
featureId: featureId,
payload: { featureId: featureId, clientId: `browser-${Date.now()}` }
});
}
}
}
// Handle user response to clarification question
function handleQuestionResponse(event: SubmitEvent) {
event.preventDefault();
console.log('[WS Client] User responded to question. Selected:', selectedOption, 'Text:', userResponse);
if (questionData && featureId) {
const response = selectedOption || userResponse;
sendWsMessage({
type: 'question_response',
featureId,
payload: {
questionId: questionData.questionId,
response: response
} as QuestionResponsePayload
});
showQuestionModal = false;
questionData = null;
waitingOnLLM = true;
// Reset form fields
selectedOption = '';
userResponse = '';
}
}
// Handle user cancellation of question
function handleQuestionCancel() {
console.log('[WS Client] User cancelled question');
showQuestionModal = false;
questionData = null;
}
// ... reactive variables ...
// Filter out decomposed tasks from progress calculation
$: actionableTasks = $tasks.filter(t => t.status !== TaskStatus.DECOMPOSED);
$: completedCount = actionableTasks.filter(t => t.completed).length;
$: totalActionableTasks = actionableTasks.length;
$: progress = totalActionableTasks > 0 ? (completedCount / totalActionableTasks) * 100 : 0;
$: firstPendingTaskIndex = $tasks.findIndex(t => t.status === TaskStatus.PENDING);
$: selectedFeatureLabel = features.find(f => f === featureId) || 'Select Feature';
// Call processNestedTasks whenever the raw tasks array changes
$: {
if ($tasks) {
processNestedTasks();
}
}
// Helper function to map API task response to client Task type
function mapApiTaskToClientTask(apiTask: any, currentFeatureId: string): Task {
// Map incoming status string to TaskStatus enum
let status: TaskStatus;
switch (apiTask.status) {
case 'completed': status = TaskStatus.COMPLETED; break;
case 'in_progress': status = TaskStatus.IN_PROGRESS; break;
case 'decomposed': status = TaskStatus.DECOMPOSED; break;
default: status = TaskStatus.PENDING; break;
}
// Ensure effort is one of our enum values
let effort: TaskEffort = TaskEffort.MEDIUM; // Default
if (apiTask.effort === 'low') {
effort = TaskEffort.LOW;
} else if (apiTask.effort === 'high') {
effort = TaskEffort.HIGH;
}
// Derive title from description if not present
const title = apiTask.title || apiTask.description;
// Ensure completed flag is consistent with status
const completed = status === TaskStatus.COMPLETED;
// Return the fully mapped task
return {
id: apiTask.id,
title,
description: apiTask.description,
status,
completed,
effort,
feature_id: apiTask.feature_id || currentFeatureId,
parentTaskId: apiTask.parentTaskId,
createdAt: apiTask.createdAt,
updatedAt: apiTask.updatedAt,
fromReview: apiTask.fromReview
} as Task;
}
async function handleImportTasks(event: CustomEvent) {
const { tasks } = event.detail;
if (!Array.isArray(tasks)) return;
for (const t of tasks) {
await addTask({
title: t.title,
effort: t.effort,
featureId: featureId || '',
description: t.description
});
}
showImportModal = false;
}
function handleCancelImport() {
showImportModal = false;
}
</script>
<div class="container mx-auto py-10 px-4 sm:px-6 lg:px-8 max-w-5xl">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold tracking-tight text-foreground">Task Manager</h1>
{#if features.length > 0}
<div class="w-64">
<Select.Root
onSelectedChange={handleFeatureChange}
selected={featureId ? { value: featureId, label: featureId } : undefined}
disabled={loadingFeatures}
>
<Select.Trigger class="w-full">
{featureId ? featureId.substring(0, 8) + '...' : 'Select Feature'}
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.GroupHeading>Available Features</Select.GroupHeading>
{#each features as feature}
<Select.Item value={feature} label={feature}>{feature.substring(0, 8)}...</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</div>
{/if}
</div>
{#if questionData}
<div class="flex flex-col items-center justify-center min-h-[300px]">
<div class="max-w-md w-full bg-background border border-border rounded-lg shadow-lg p-6">
<h2 class="text-xl font-semibold mb-4">Clarification Needed</h2>
<p class="text-foreground mb-5">{questionData.question}</p>
<form on:submit|preventDefault={handleQuestionResponse}>
{#if questionData.options && questionData.options.length > 0}
<div class="flex flex-col gap-3 mb-5">
{#each questionData.options as option}
<label class="flex items-center gap-2 p-3 border border-border rounded-md cursor-pointer hover:bg-muted transition-colors">
<input
type="radio"
name="option"
value={option}
bind:group={selectedOption}
class="focus:ring-primary"
/>
<span class="text-foreground">{option}</span>
</label>
{/each}
</div>
{/if}
{#if questionData.allowsText !== false}
<div class="mb-5">
<label for="text-response" class="block mb-2 font-medium text-foreground">
{questionData.options && questionData.options.length > 0 ? 'Or provide a custom response:' : 'Your response:'}
</label>
<textarea
id="text-response"
rows="3"
bind:value={userResponse}
placeholder="Type your response here..."
class="w-full p-3 border border-border rounded-md resize-y text-foreground bg-background focus:ring-primary focus:border-primary"
></textarea>
</div>
{/if}
<div class="flex justify-end gap-3 pt-2">
<button
type="submit"
class="bg-primary text-primary-foreground hover:bg-primary/90 px-4 py-2 rounded-md font-medium text-sm disabled:opacity-50"
disabled={!userResponse && (!questionData.options || !selectedOption)}
>
Submit Response
</button>
</div>
</form>
</div>
</div>
{:else if waitingOnLLM}
<div class="flex flex-col items-center justify-center min-h-[300px]">
<Loader2 class="h-12 w-12 animate-spin text-primary mb-4" />
<p class="text-lg text-muted-foreground">Waiting on LLM to plan after clarification...</p>
</div>
{:else if $loading}
<div class="flex justify-center items-center h-64">
<Loader2 class="h-12 w-12 animate-spin text-primary" />
</div>
{:else if $error}
<Card class="mb-6 border-destructive">
<CardHeader>
<CardTitle class="text-destructive">Error Loading Tasks</CardTitle>
<CardDescription class="text-destructive/90">{$error}</CardDescription>
</CardHeader>
</Card>
{:else}
<Card class="shadow-lg">
<CardHeader class="border-b border-border px-6 py-4">
<CardTitle class="text-xl font-semibold flex justify-between items-center">
<span class="flex-1">Tasks</span>
<div class="flex justify-between items-center gap-4 items-center">
<Badge variant="secondary">{$tasks.length}</Badge>
<Button on:click={() => showImportModal = true}>Import Tasks</Button>
</div>
</CardTitle>
<CardDescription class="mt-1">
Manage your tasks and track progress for the selected feature.
</CardDescription>
<div class="pt-4">
<Progress
value={progress}
class="w-full h-2 [&>div]:bg-green-500 [&>div]:transition-all [&>div]:duration-300 [&>div]:ease-in-out"
/>
</div>
</CardHeader>
<CardContent class="p-0">
<div class="divide-y divide-border">
{#each nestedTasks as task (task.id)}
{@const taskIndexInFlatList = $tasks.findIndex(t => t.id === task.id)}
{@const isNextPending = taskIndexInFlatList === firstPendingTaskIndex}
{@const isInProgress = task.status === TaskStatus.IN_PROGRESS}
{@const areAllChildrenComplete = task.children && task.children.length > 0 && task.children.every(c => c.status === TaskStatus.COMPLETED)}
<div
transition:fade={{ duration: 200 }}
class="task-row flex items-start space-x-4 p-4 hover:bg-muted/50 transition-colors
{isNextPending ? 'bg-muted/30' : ''}
{isInProgress ? 'in-progress-shine relative overflow-hidden' : ''}
{(task.status === TaskStatus.COMPLETED || (task.status === TaskStatus.DECOMPOSED && areAllChildrenComplete)) ? 'opacity-60' : ''}
{task.fromReview ? 'from-review-task' : ''}"
>
{#if task.status === TaskStatus.DECOMPOSED}
<div class="flex items-center justify-center h-6 w-6 mt-1 text-muted-foreground">
<CornerDownRight class="h-4 w-4" />
</div>
{:else}
<div class="flex flex-col items-center gap-1">
<Checkbox
id={`task-${task.id}`}
checked={task.completed}
onCheckedChange={() => toggleTaskStatus(task.id)}
aria-labelledby={`task-label-${task.id}`}
disabled={task.status === TaskStatus.IN_PROGRESS}
/>
{#if task.fromReview}
<span class="review-indicator" title="Task from review">
<Eye size={20} />
</span>
{/if}
</div>
{/if}
<div class="flex-1 grid gap-1">
<div class="flex items-center gap-2">
<label
for={`task-${task.id}`}
id={`task-label-${task.id}`}
class={`font-medium cursor-pointer ${(task.status === TaskStatus.COMPLETED || (task.status === TaskStatus.DECOMPOSED && areAllChildrenComplete)) ? 'line-through text-muted-foreground' : ''}`}
>
{task.title}
</label>
</div>
{#if task.description && task.description !== task.title}
<p class="text-sm text-muted-foreground">
{task.description}
</p>
{/if}
</div>
<div class="flex flex-col gap-1.5 items-end min-w-[100px]">
<div class="flex items-center gap-1.5">
<Badge variant={getStatusBadgeVariant(task.status)} class="capitalize">
{task.status.replace('_', ' ')}
</Badge>
</div>
{#if task.effort}
<Badge variant={getEffortBadgeVariant(task.effort)} class="capitalize">
{task.effort}
</Badge>
{/if}
</div>
<div class="flex gap-1 ml-4">
<button
class="text-muted-foreground hover:text-foreground p-1 rounded-sm hover:bg-muted transition-colors"
title="Edit task"
on:click|stopPropagation={() => openEditTaskModal(task)}
>
<Pencil size={16} />
</button>
<button
class="text-muted-foreground hover:text-destructive p-1 rounded-sm hover:bg-muted transition-colors"
title="Delete task"
on:click|stopPropagation={() => deleteTask(task.id, featureId || '')}
>
<Trash2 size={16} />
</button>
</div>
</div>
{#if task.children && task.children.length > 0}
<div class="ml-10 pl-4 py-2 border-l border-border divide-y divide-border">
{#each task.children as childTask (childTask.id)}
{@const childTaskIndexInFlatList = $tasks.findIndex(t => t.id === childTask.id)}
{@const isChildNextPending = childTaskIndexInFlatList === firstPendingTaskIndex}
{@const isChildInProgress = childTask.status === TaskStatus.IN_PROGRESS}
<div
transition:fade={{ duration: 200 }}
class="task-row flex items-start space-x-4 pt-3 pr-4 mb-3
{isChildNextPending ? 'bg-muted/30' : ''}
{isChildInProgress ? 'in-progress-shine relative overflow-hidden' : ''}
{childTask.status === TaskStatus.COMPLETED ? 'opacity-60' : ''}
{childTask.fromReview ? 'from-review-task' : ''}"
>
{#if childTask.status === TaskStatus.DECOMPOSED}
<div class="flex items-center justify-center h-6 w-6 mt-1 text-muted-foreground">
<CornerDownRight class="h-4 w-4" />
</div>
{:else}
<div class="flex flex-col items-center gap-1">
<Checkbox
id={`task-${childTask.id}`}
checked={childTask.completed}
onCheckedChange={() => toggleTaskStatus(childTask.id)}
aria-labelledby={`task-label-${childTask.id}`}
disabled={childTask.status === TaskStatus.IN_PROGRESS}
/>
{#if childTask.fromReview}
<span class="review-indicator" title="Task from review">
<Eye size={20} />
</span>
{/if}
</div>
{/if}
<div class="flex-1 grid gap-1">
<div class="flex items-center gap-2">
<label
for={`task-${childTask.id}`}
id={`task-label-${childTask.id}`}
class={`font-medium cursor-pointer ${childTask.status === TaskStatus.COMPLETED ? 'line-through text-muted-foreground' : ''}`}
>
{childTask.title}
</label>
</div>
{#if childTask.description && childTask.description !== childTask.title}
<p class="text-sm text-muted-foreground">
{childTask.description}
</p>
{/if}
</div>
<div class="flex flex-col gap-1.5 items-end min-w-[100px]">
<div class="flex items-center gap-1.5">
<Badge variant={getStatusBadgeVariant(childTask.status)} class="capitalize">
{childTask.status.replace('_', ' ')}
</Badge>
</div>
{#if childTask.effort}
<Badge variant={getEffortBadgeVariant(childTask.effort)} class="capitalize">
{childTask.effort}
</Badge>
{/if}
</div>
<div class="flex gap-1 ml-4">
<button
class="text-muted-foreground hover:text-foreground p-1 rounded-sm hover:bg-muted transition-colors"
title="Edit subtask"
on:click|stopPropagation={() => openEditTaskModal(childTask)}
>
<Pencil size={16} />
</button>
<button
class="text-muted-foreground hover:text-destructive p-1 rounded-sm hover:bg-muted transition-colors"
title="Delete subtask"
on:click|stopPropagation={() => deleteTask(childTask.id, featureId || '')}
>
<Trash2 size={16} />
</button>
</div>
</div>
{/each}
</div>
{/if}
{:else}
<div class="text-center py-8 text-muted-foreground">
No tasks found for this feature.
</div>
{/each}
</div>
</CardContent>
<CardFooter class="flex flex-col items-start gap-4 px-6 py-4 border-t border-border">
<div class="w-full flex justify-between items-center">
<span class="text-sm text-muted-foreground">
{completedCount} of {totalActionableTasks} actionable tasks completed
</span>
<div class="flex gap-2">
<Button variant="outline" size="sm" on:click={() => showTaskFormModal = true} disabled={!featureId}>
Add Task
</Button>
<Button variant="outline" size="sm" on:click={refreshTasks} disabled={$loading}>
{#if $loading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Refresh
</Button>
</div>
</div>
</CardFooter>
</Card>
{/if}
{#if featureId}
<TaskFormModal
open={showTaskFormModal}
featureId={featureId}
isEditing={isEditing}
editTask={editingTask ? {
id: editingTask.id,
title: editingTask.title || '',
effort: editingTask.effort || 'medium'
} : {
id: '',
title: '',
effort: 'medium'
}}
on:submit={handleTaskFormSubmit}
on:cancel={() => showTaskFormModal = false}
/>
{/if}
<ImportTasksModal
bind:open={showImportModal}
on:import={handleImportTasks}
on:cancel={handleCancelImport}
/>
</div>
<style>
.in-progress-shine::before {
content: '';
position: absolute;
top: 0;
left: -100%; /* Start off-screen */
width: 75%; /* Width of the shine */
height: 100%;
background: linear-gradient(
100deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.15) 50%, /* Subtle white shine */
rgba(255, 255, 255, 0) 100%
);
transform: skewX(-25deg); /* Angle the shine */
animation: shine 2.5s infinite linear; /* Animation properties */
opacity: 0.8;
}
@keyframes shine {
0% {
left: -100%;
}
50%, 100% { /* Speed up the animation and make it pause less */
left: 120%; /* Move across and off-screen */
}
}
.task-row {
position: relative; /* Needed for absolute positioning of ::before */
overflow: hidden; /* Keep shine contained */
}
.review-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
color: #3b82f6;
transition: all 0.2s ease;
margin-top: 10px;
}
.review-indicator:hover {
opacity: 0.8;
}
.from-review-task {
background-color: rgba(59, 130, 246, 0.08);
}
.from-review-task:hover {
background-color: rgba(59, 130, 246, 0.12);
}
</style>
```
--------------------------------------------------------------------------------
/src/lib/llmUtils.ts:
--------------------------------------------------------------------------------
```typescript
import { GenerateContentResult, GenerativeModel } from '@google/generative-ai'
import OpenAI from 'openai'
import crypto from 'crypto'
import {
BreakdownOptions,
EffortEstimationSchema,
TaskBreakdownSchema,
TaskBreakdownResponseSchema,
Task,
TaskListSchema,
HistoryEntry,
FeatureHistorySchema,
TaskSchema,
LLMClarificationRequestSchema,
} from '../models/types'
import { aiService } from '../services/aiService'
import { logToFile } from './logger'
import { safetySettings, OPENROUTER_MODEL, GEMINI_MODEL } from '../config'
import { z } from 'zod'
import { encoding_for_model } from 'tiktoken'
import { addHistoryEntry } from './dbUtils'
import webSocketService from '../services/webSocketService'
import { databaseService } from '../services/databaseService'
/**
* Parses the text response from Gemini into a list of tasks.
*/
export function parseGeminiPlanResponse(
responseText: string | undefined | null
): string[] {
// Basic parsing
if (!responseText) {
return []
}
// Split by newlines and clean up
const lines = responseText
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.match(/^[-*+]\s*$/))
// Process each line to remove markdown list markers and numbering
const cleanedLines = lines.map((line) => {
// Remove markdown list markers and numbering
return line
.replace(/^[-*+]\s*/, '') // Remove list markers like -, *, +
.replace(/^\d+\.\s*/, '') // Remove numbered list markers like 1. 2. etc.
.replace(/^[a-z]\)\s*/i, '') // Remove lettered list markers like a) b) etc.
.replace(/^\([a-z]\)\s*/i, '') // Remove lettered list markers like (a) (b) etc.
})
// Detect hierarchical structure based on indentation or subtask indicators
const tasks: string[] = []
let currentParentTask: string | null = null
for (const line of cleanedLines) {
// Check if this is a parent task or a subtask based on various indicators
const isSubtask =
line.match(/subtask|sub-task/i) || // Contains "subtask" or "sub-task"
line.startsWith(' ') || // Has leading indentation
line.match(/^[a-z]\.[\d]+/i) || // Contains notation like "a.1"
line.includes('→') || // Contains arrow indicators
line.match(/\([a-z]\)/i) // Contains notation like "(a)"
if (isSubtask && currentParentTask) {
// If it's a subtask and we have a parent, tag it with the parent task info
tasks.push(line)
} else {
// This is a new parent task
currentParentTask = line
tasks.push(line)
}
}
return tasks
}
/**
* Determines task effort using an LLM.
* Uses structured JSON output for consistent results.
* Works with both OpenRouter and Gemini models.
*/
export async function determineTaskEffort(
description: string,
model: GenerativeModel | OpenAI | null
): Promise<'low' | 'medium' | 'high'> {
if (!model) {
console.error('[TaskServer] Cannot determine effort: No model provided.')
// Default to medium effort if no model is available
return 'medium'
}
const prompt = `
Task: ${description}
Analyze this **coding task** and determine its estimated **effort level** based ONLY on the implementation work involved. A higher effort level often implies the task might need breaking down into sub-steps. Use these criteria:
- Low: Simple code changes likely contained in one or a few files, minimal logic changes, straightforward bug fixes. (e.g., renaming a variable, adding a console log, simple UI text change). Expected to be quick.
- Medium: Requires moderate development time, involves changes across several files or components with clear patterns, includes writing new functions or small classes, moderate refactoring. Might benefit from 1-3 sub-steps. (e.g., adding a new simple API endpoint, implementing a small feature).
- High: Involves significant development time, potentially spanning multiple days. Suggests complex architectural changes, intricate algorithm implementation, deep refactoring affecting multiple core components, requires careful design and likely needs breakdown into multiple sub-steps (3+). (e.g., redesigning a core system, implementing complex data processing).
Exclude factors like testing procedures, documentation, deployment, or project management overhead.
Respond with a JSON object that includes the effort level and optionally a short reasoning.
`
try {
// Use structured response with schema validation
if (model instanceof OpenAI) {
// Use OpenRouter with structured output
const result = await aiService.callOpenRouterWithSchema(
OPENROUTER_MODEL,
[{ role: 'user', content: prompt }],
EffortEstimationSchema,
{ temperature: 0.1, max_tokens: 100 }
)
if (result.success) {
return result.data.effort
} else {
console.warn(
`[TaskServer] Could not determine effort using structured output: ${result.error}. Defaulting to medium.`
)
return 'medium'
}
} else {
// Use Gemini with structured output
const result = await aiService.callGeminiWithSchema(
GEMINI_MODEL,
prompt,
EffortEstimationSchema,
{ temperature: 0.1, maxOutputTokens: 100 }
)
if (result.success) {
return result.data.effort
} else {
console.warn(
`[TaskServer] Could not determine effort using structured output: ${result.error}. Defaulting to medium.`
)
return 'medium'
}
}
} catch (error) {
console.error('[TaskServer] Error determining task effort:', error)
return 'medium' // Default to medium on error
}
}
/**
* Breaks down a high-effort task into subtasks using an LLM.
* Uses structured JSON output for consistent results.
* Works with both OpenRouter and Gemini models.
*/
export async function breakDownHighEffortTask(
taskDescription: string,
parentId: string,
model: GenerativeModel | OpenAI | null,
options: BreakdownOptions = {}
): Promise<string[]> {
if (!model) {
console.error('[TaskServer] Cannot break down task: No model provided.')
return []
}
// Use provided options or defaults
const {
minSubtasks = 2,
maxSubtasks = 5,
preferredEffort = 'medium',
maxRetries = 3,
} = options
// Enhanced prompt with clearer instructions for JSON output
const breakdownPrompt = `
I need to break down this high-effort coding task into smaller, actionable subtasks:
Task: "${taskDescription}"
Guidelines:
1. Create ${minSubtasks}-${maxSubtasks} subtasks.
2. Each subtask should ideally be '${preferredEffort}' effort, focusing on a specific part of the implementation.
3. Make each subtask a concrete coding action (e.g., "Create function X", "Refactor module Y", "Add field Z to interface").
4. The subtasks should represent a logical sequence for implementation.
5. Only include coding tasks, not testing, documentation, or deployment steps.
IMPORTANT RESPONSE FORMAT INSTRUCTIONS:
- Return ONLY a valid JSON object
- The JSON object MUST have a single key named "subtasks"
- "subtasks" MUST be an array of objects with exactly two fields each:
- "description": string - The subtask description
- "effort": string - MUST be either "low" or "medium"
- No other text before or after the JSON object
- No markdown formatting, code blocks, or comments
Example of EXACTLY how your response should be formatted:
{
"subtasks": [
{
"description": "Create the database schema for user profiles",
"effort": "medium"
},
{
"description": "Implement the user profile repository class",
"effort": "medium"
}
]
}
`
// Function to handle the actual API call with retry logic
async function attemptBreakdown(attempt: number): Promise<string[]> {
try {
// Use structured response with schema validation
if (model instanceof OpenAI) {
// Use OpenRouter with structured output
const result = await aiService.callOpenRouterWithSchema(
OPENROUTER_MODEL,
[{ role: 'user', content: breakdownPrompt }],
TaskBreakdownResponseSchema,
{ temperature: 0.2 } // Lower temperature for more consistent output
)
if (result.success) {
// Extract the descriptions from the structured response
return result.data.subtasks.map(
(subtask) => `[${subtask.effort}] ${subtask.description}`
)
} else {
console.warn(
`[TaskServer] Could not break down task using structured output (attempt ${attempt}): ${result.error}`
)
// Retry if attempts remain
if (attempt < maxRetries) {
logToFile(
`[TaskServer] Retrying task breakdown (attempt ${attempt + 1})`
)
return attemptBreakdown(attempt + 1)
}
return []
}
} else {
// Use Gemini with structured output
const result = await aiService.callGeminiWithSchema(
GEMINI_MODEL,
breakdownPrompt,
TaskBreakdownResponseSchema,
{ temperature: 0.2 } // Lower temperature for more consistent output
)
if (result.success) {
// Extract the descriptions from the structured response
return result.data.subtasks.map(
(subtask) => `[${subtask.effort}] ${subtask.description}`
)
} else {
console.warn(
`[TaskServer] Could not break down task using structured output (attempt ${attempt}): ${result.error}`
)
// Retry if attempts remain
if (attempt < maxRetries) {
logToFile(
`[TaskServer] Retrying task breakdown (attempt ${attempt + 1})`
)
return attemptBreakdown(attempt + 1)
}
return []
}
}
} catch (error) {
console.error(
`[TaskServer] Error breaking down high-effort task (attempt ${attempt}):`,
error
)
// Retry if attempts remain
if (attempt < maxRetries) {
logToFile(
`[TaskServer] Retrying task breakdown after error (attempt ${
attempt + 1
})`
)
return attemptBreakdown(attempt + 1)
}
return []
}
}
// Start the breakdown process with first attempt
return attemptBreakdown(1)
}
/**
* Extracts parent task ID from a task description if present.
* @param taskDescription The task description to check
* @returns An object with the cleaned description and parentTaskId if found
*/
export function extractParentTaskId(taskDescription: string): {
description: string
parentTaskId?: string
} {
const parentTaskMatch = taskDescription.match(/\[parentTask:([a-f0-9-]+)\]$/i)
if (parentTaskMatch) {
// Extract the parent task ID
const parentTaskId = parentTaskMatch[1]
// Remove the parent task tag from the description
const description = taskDescription.replace(
/\s*\[parentTask:[a-f0-9-]+\]$/i,
''
)
return { description, parentTaskId }
}
return { description: taskDescription }
}
/**
* Extracts effort rating from a task description.
* @param taskDescription The task description to check
* @returns An object with the cleaned description and effort
*/
export function extractEffort(taskDescription: string): {
description: string
effort: 'low' | 'medium' | 'high'
} {
const effortMatch = taskDescription.match(/^\[(low|medium|high)\]/i)
if (effortMatch) {
const effort = effortMatch[1].toLowerCase() as 'low' | 'medium' | 'high'
// Remove the effort tag from the description
const description = taskDescription.replace(
/^\[(low|medium|high)\]\s*/i,
''
)
return { description, effort }
}
// Default to medium if no effort found
return { description: taskDescription, effort: 'medium' }
}
/**
* A more robust approach to parsing LLM-generated JSON that might be malformed due to newlines
* or other common issues in AI responses.
*/
function robustJsonParse(text: string): any {
// First attempt: Try with standard JSON.parse
try {
return JSON.parse(text)
} catch (error: any) {
// If standard parsing fails, try more aggressive fixing
logToFile(
`[robustJsonParse] Standard parsing failed, attempting recovery: ${error}`
)
try {
// Detect the main expected structure type (tasks vs subtasks)
const isTasksArray = text.includes('"tasks"')
const isSubtasksArray = text.includes('"subtasks"')
const hasDescription = text.includes('"description"')
const hasEffort = text.includes('"effort"')
// Special handling for common OpenRouter/AI model response patterns
if ((isTasksArray || isSubtasksArray) && hasDescription && hasEffort) {
const arrayKey = isSubtasksArray ? 'subtasks' : 'tasks'
// 1. Enhanced regex that works for both tasks and subtasks arrays
const taskRegex =
/"description"\s*:\s*"((?:[^"\\]|\\"|\\|[\s\S])*?)"\s*,\s*"effort"\s*:\s*"(low|medium|high)"/g
const tasks = []
let match
while ((match = taskRegex.exec(text)) !== null) {
try {
if (match[1] && match[2]) {
tasks.push({
description: match[1].replace(/\\"/g, '"'),
effort: match[2],
})
}
} catch (innerError) {
logToFile(`[robustJsonParse] Error extracting task: ${innerError}`)
}
}
if (tasks.length > 0) {
logToFile(
`[robustJsonParse] Successfully extracted ${tasks.length} ${arrayKey} with regex`
)
return { [arrayKey]: tasks }
}
// 2. If regex extraction fails, try extracting JSON objects directly
if (tasks.length === 0) {
try {
const objectsExtracted = extractJSONObjects(text)
if (objectsExtracted.length > 0) {
// Filter valid task objects
const validTasks = objectsExtracted.filter(
(obj) =>
obj &&
typeof obj === 'object' &&
obj.description &&
obj.effort &&
typeof obj.description === 'string' &&
typeof obj.effort === 'string'
)
if (validTasks.length > 0) {
logToFile(
`[robustJsonParse] Successfully extracted ${validTasks.length} ${arrayKey} with object extraction`
)
return { [arrayKey]: validTasks }
}
}
} catch (objExtractionError) {
logToFile(
`[robustJsonParse] Object extraction failed: ${objExtractionError}`
)
}
}
}
// 3. Fall back to manual line-by-line parsing for JSON objects
const lines = text.split('\n')
let cleanJson = ''
let inString = false
for (const line of lines) {
let processedLine = line
// Count quote marks to track if we're inside a string
for (let i = 0; i < line.length; i++) {
if (line[i] === '"' && (i === 0 || line[i - 1] !== '\\')) {
inString = !inString
}
}
// Add a space instead of newline if we're in the middle of a string
cleanJson += inString ? ' ' + processedLine : processedLine
}
// 4. Balance braces and brackets if needed
cleanJson = balanceBracesAndBrackets(cleanJson)
// Final attempt to parse the cleaned JSON
return JSON.parse(cleanJson)
} catch (recoveryError) {
logToFile(
`[robustJsonParse] All recovery attempts failed: ${recoveryError}`
)
throw new Error(`Failed to parse JSON: ${error.message}`)
}
}
}
/**
* Extracts valid JSON objects from a potentially malformed string.
* Helps recover objects from truncated or malformed JSON.
*/
function extractJSONObjects(text: string): any[] {
const objects: any[] = []
// First try to find array boundaries
const arrayStartIndex = text.indexOf('[')
const arrayEndIndex = text.lastIndexOf(']')
if (arrayStartIndex !== -1 && arrayEndIndex > arrayStartIndex) {
// Extract array content
const arrayContent = text.substring(arrayStartIndex + 1, arrayEndIndex)
// Split by potential object boundaries, respecting nested objects
let depth = 0
let currentObject = ''
let inString = false
for (let i = 0; i < arrayContent.length; i++) {
const char = arrayContent[i]
// Track string boundaries
if (char === '"' && (i === 0 || arrayContent[i - 1] !== '\\')) {
inString = !inString
}
// Only track structure when not in a string
if (!inString) {
if (char === '{') {
depth++
if (depth === 1) {
// Start of a new object
currentObject = '{'
continue
}
} else if (char === '}') {
depth--
if (depth === 0) {
// End of an object, try to parse it
currentObject += '}'
try {
const obj = JSON.parse(currentObject)
objects.push(obj)
} catch (e) {
// If this object can't be parsed, just continue
}
currentObject = ''
continue
}
} else if (char === ',' && depth === 0) {
// Skip commas between objects
continue
}
}
// Add character to current object if we're inside one
if (depth > 0) {
currentObject += char
}
}
}
return objects
}
/**
* Balances braces and brackets in a JSON string to make it valid
*/
function balanceBracesAndBrackets(text: string): string {
let result = text
// Count opening and closing braces/brackets
const openBraces = (result.match(/\{/g) || []).length
const closeBraces = (result.match(/\}/g) || []).length
const openBrackets = (result.match(/\[/g) || []).length
const closeBrackets = (result.match(/\]/g) || []).length
// Add missing closing braces/brackets
if (openBraces > closeBraces) {
result += '}'.repeat(openBraces - closeBraces)
}
if (openBrackets > closeBrackets) {
result += ']'.repeat(openBrackets - closeBrackets)
}
return result
}
/**
* Parses and validates a JSON response string against a provided Zod schema.
*
* @param responseText - The raw JSON string from the LLM response
* @param schema - The Zod schema to validate against
* @returns An object containing either the validated data or error information
*/
export function parseAndValidateJsonResponse<T extends z.ZodType>(
responseText: string | null | undefined,
schema: T
):
| { success: true; data: z.infer<T> }
| { success: false; error: string; rawData: any | null } {
// Handle null or empty responses
if (!responseText) {
return {
success: false,
error: 'Response text is empty or null',
rawData: null,
}
}
// Enhanced logging for debugging
try {
logToFile(
`[parseAndValidateJsonResponse] Raw response text: ${responseText?.substring(
0,
1000
)}`
)
} catch (logError) {
// Ignore logging errors
}
// Extract JSON from the response if it's wrapped in markdown or other text
let jsonString = responseText
// Look for JSON in markdown code blocks
const jsonBlockMatch = responseText.match(/```(?:json)?\s*([\s\S]*?)\s*```/)
if (jsonBlockMatch && jsonBlockMatch[1]) {
jsonString = jsonBlockMatch[1]
}
// --- Additional cleaning: extract first valid JSON object from text ---
function extractJsonFromText(text: string): string {
// Remove markdown code fences
text = text.replace(/```(?:json)?/gi, '').replace(/```/g, '')
// Find the first { and last }
const firstBrace = text.indexOf('{')
const lastBrace = text.lastIndexOf('}')
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
return text.substring(firstBrace, lastBrace + 1)
}
return text.trim()
}
jsonString = extractJsonFromText(jsonString)
// Try to identify expected content type for better recovery
const expectsSubtasks =
responseText.includes('"subtasks"') || responseText.includes('subtasks')
const expectsTasks =
responseText.includes('"tasks"') || responseText.includes('tasks')
// --- Auto-fix common JSON issues (trailing commas, comments) ---
function fixCommonJsonIssues(text: string): string {
// Remove JavaScript-style comments
text = text.replace(/\/\/.*$/gm, '')
text = text.replace(/\/\*[\s\S]*?\*\//g, '')
// Remove trailing commas in objects and arrays
text = text.replace(/,\s*([}\]])/g, '$1')
// Fix broken newlines in the middle of strings
text = text.replace(/([^\\])"\s*\n\s*"/g, '$1')
// Normalize string values that got broken across lines
text = text.replace(/([^\\])"\s*\n\s*([^"])/g, '$1", "$2')
// Fix incomplete JSON objects
const openBraces = (text.match(/\{/g) || []).length
const closeBraces = (text.match(/\}/g) || []).length
if (openBraces > closeBraces) {
text = text + '}'.repeat(openBraces - closeBraces)
}
// Fix unclosed quotes at end of string
if ((text.match(/"/g) || []).length % 2 !== 0) {
// Check if the last quote is an opening quote (likely in the middle of a string)
const lastQuotePos = text.lastIndexOf('"')
const endsWithOpenQuote =
lastQuotePos !== -1 &&
text.substring(lastQuotePos).split('"').length === 2
if (endsWithOpenQuote) {
text = text + '"'
}
}
return text
}
jsonString = fixCommonJsonIssues(jsonString)
// --- End auto-fix ---
try {
logToFile(
`[parseAndValidateJsonResponse] Cleaned JSON string: ${jsonString?.substring(
0,
1000
)}`
)
} catch (logError) {
// Ignore logging errors
}
// Attempt to parse the JSON using robust parser
let parsedData: any
try {
parsedData = robustJsonParse(jsonString)
logToFile(
`[parseAndValidateJsonResponse] JSON parsed successfully with robust parser`
)
} catch (parseError) {
// If primary parsing failed, try reconstructing specific expected structures
try {
// For tasks/subtasks, try to reconstruct using direct object extraction
if (expectsTasks || expectsSubtasks) {
const arrayKey = expectsSubtasks ? 'subtasks' : 'tasks'
// Extract task objects directly from text
const extractedObjects = extractJSONObjects(jsonString)
if (extractedObjects.length > 0) {
// Filter out invalid objects
const validItems = extractedObjects.filter(
(obj) =>
obj &&
typeof obj === 'object' &&
obj.description &&
obj.effort &&
typeof obj.description === 'string' &&
typeof obj.effort === 'string'
)
if (validItems.length > 0) {
parsedData = { [arrayKey]: validItems }
logToFile(
`[parseAndValidateJsonResponse] Successfully reconstructed ${arrayKey} array with ${validItems.length} items`
)
// Validate against schema immediately
const validationResult = schema.safeParse(parsedData)
if (validationResult.success) {
return {
success: true,
data: validationResult.data,
}
}
}
}
// If we can see where tasks are, try regex extraction
const regex = new RegExp(
`"(description|desc|name)"\\s*:\\s*"([^"]*)"[\\s\\S]*?"(effort|difficulty)"\\s*:\\s*"(low|medium|high)"`,
'gi'
)
const items = []
let match
while ((match = regex.exec(responseText)) !== null) {
try {
items.push({
description: match[2],
effort: match[4].toLowerCase(),
})
} catch (e) {
// Skip invalid matches
}
}
if (items.length > 0) {
parsedData = { [arrayKey]: items }
logToFile(
`[parseAndValidateJsonResponse] Successfully extracted ${items.length} ${arrayKey} with regex`
)
// Validate against schema
const validationResult = schema.safeParse(parsedData)
if (validationResult.success) {
return {
success: true,
data: validationResult.data,
}
}
}
}
} catch (reconstructionError) {
logToFile(
`[parseAndValidateJsonResponse] Reconstruction error: ${reconstructionError}`
)
// Continue to normal error handling
}
// All parsing methods have failed
logToFile(
`[parseAndValidateJsonResponse] All parsing attempts failed: ${parseError}`
)
return {
success: false,
error: `Failed to parse JSON: ${(parseError as Error).message}`,
rawData: responseText,
}
}
// Validate against the schema
const validationResult = schema.safeParse(parsedData)
if (validationResult.success) {
return {
success: true,
data: validationResult.data,
}
} else {
logToFile(
`[parseAndValidateJsonResponse] Schema validation failed. Errors: ${JSON.stringify(
validationResult.error.errors
)}`
)
// Attempt to recover partial valid data
const recoveredData = attemptPartialResponseRecovery(parsedData, schema)
if (recoveredData) {
logToFile(
`[parseAndValidateJsonResponse] Successfully recovered partial response`
)
return {
success: true,
data: recoveredData,
}
}
return {
success: false,
error: `Schema validation failed: ${validationResult.error.message}`,
rawData: parsedData,
}
}
}
/**
* Attempts to recover partial valid data from a failed schema validation.
* Particularly useful for array of tasks or subtasks where some items might be valid.
*/
function attemptPartialResponseRecovery(
parsedData: any,
schema: z.ZodType
): any | null {
try {
logToFile(
`[attemptPartialResponseRecovery] Attempting to recover partial valid response`
)
// Handle common case: tasks array with valid and invalid items
if (
parsedData &&
((parsedData.tasks && Array.isArray(parsedData.tasks)) ||
(parsedData.subtasks && Array.isArray(parsedData.subtasks)))
) {
const isSubtasksArray =
parsedData.subtasks && Array.isArray(parsedData.subtasks)
const arrayKey = isSubtasksArray ? 'subtasks' : 'tasks'
const items = isSubtasksArray ? parsedData.subtasks : parsedData.tasks
// Filter out invalid task items
const validItems = items.filter(
(item: any) =>
item &&
typeof item === 'object' &&
item.description &&
item.effort &&
typeof item.description === 'string' &&
typeof item.effort === 'string'
)
if (validItems.length > 0) {
const recoveredData = { ...parsedData, [arrayKey]: validItems }
const validationResult = schema.safeParse(recoveredData)
if (validationResult.success) {
logToFile(
`[attemptPartialResponseRecovery] Recovery successful, found ${validItems.length} valid ${arrayKey}`
)
return validationResult.data
}
}
}
return null
} catch (error) {
logToFile(
`[attemptPartialResponseRecovery] Recovery attempt failed: ${error}`
)
return null
}
}
/**
* Ensures all task descriptions have an effort rating prefix.
* Determines effort using an LLM if missing.
*/
export async function ensureEffortRatings(
taskDescriptions: string[],
model: GenerativeModel | OpenAI | null
): Promise<string[]> {
const effortRatedTasks: string[] = []
for (const taskDesc of taskDescriptions) {
const effortMatch = taskDesc.match(/^\[(low|medium|high)\]/i)
if (effortMatch) {
// Ensure consistent casing
const effort = effortMatch[1].toLowerCase() as 'low' | 'medium' | 'high'
const cleanDesc = taskDesc.replace(/^\[(low|medium|high)\]\s*/i, '')
effortRatedTasks.push(`[${effort}] ${cleanDesc}`)
} else {
let effort: 'low' | 'medium' | 'high' = 'medium' // Default effort
try {
if (model) {
// Only call if model is available
effort = await determineTaskEffort(taskDesc, model)
}
} catch (error) {
console.error(
`[TaskServer] Error determining effort for task "${taskDesc.substring(
0,
40
)}...". Defaulting to medium:`,
error
)
}
effortRatedTasks.push(`[${effort}] ${taskDesc}`)
}
}
return effortRatedTasks
}
/**
* Processes tasks: breaks down high-effort ones, ensures effort, and creates Task objects.
*/
export async function processAndBreakdownTasks(
initialTasksWithEffort: string[],
model: GenerativeModel | OpenAI | null,
featureId: string,
fromReviewContext: boolean
): Promise<{ finalTasks: Task[]; complexTaskMap: Map<string, string> }> {
const finalProcessedSteps: string[] = []
const complexTaskMap = new Map<string, string>()
let breakdownSuccesses = 0
let breakdownFailures = 0
for (const step of initialTasksWithEffort) {
const effortMatch = step.match(/^\[(low|medium|high)\]/i)
const isHighEffort = effortMatch && effortMatch[1].toLowerCase() === 'high'
if (isHighEffort) {
const taskDescription = step.replace(/^\[high\]\s*/i, '')
const parentId = crypto.randomUUID()
complexTaskMap.set(taskDescription, parentId) // Map original description to ID
try {
await addHistoryEntry(featureId, 'model', {
step: 'task_breakdown_attempt',
task: step,
parentId,
})
const subtasks = await breakDownHighEffortTask(
taskDescription,
parentId,
model,
{ minSubtasks: 2, maxSubtasks: 5, preferredEffort: 'medium' }
)
if (subtasks.length > 0) {
// Add parent container task (marked completed later)
finalProcessedSteps.push(`${step} [parentContainer]`) // Add marker
// Process and add subtasks immediately after parent
// Ensure subtasks also have effort ratings
const subtasksWithEffort = await ensureEffortRatings(subtasks, model)
const subtasksWithParentId = subtasksWithEffort.map((subtaskDesc) => {
const { description: cleanSubDesc } = extractEffort(subtaskDesc) // Already has effort
return `${subtaskDesc} [parentTask:${parentId}]`
})
finalProcessedSteps.push(...subtasksWithParentId)
await addHistoryEntry(featureId, 'model', {
step: 'task_breakdown_success',
task: step,
parentId,
subtasks: subtasksWithParentId,
})
breakdownSuccesses++
} else {
// Breakdown failed, keep original high-effort task
finalProcessedSteps.push(step)
await addHistoryEntry(featureId, 'model', {
step: 'task_breakdown_failure',
task: step,
})
breakdownFailures++
}
} catch (breakdownError) {
console.error(
`[TaskServer] Error during breakdown for task "${taskDescription.substring(
0,
40
)}...":`,
breakdownError
)
finalProcessedSteps.push(step) // Keep original task on error
await addHistoryEntry(featureId, 'model', {
step: 'task_breakdown_error',
task: step,
error:
breakdownError instanceof Error
? breakdownError.message
: String(breakdownError),
})
breakdownFailures++
}
} else {
// Keep low/medium effort tasks as is
finalProcessedSteps.push(step)
}
}
await logToFile(
`[TaskServer] Breakdown processing complete: ${breakdownSuccesses} successes, ${breakdownFailures} failures.`
)
// --- Create Task Objects ---
const finalTasks: Task[] = []
const taskCreationErrors: string[] = []
for (const step of finalProcessedSteps) {
try {
const isParentContainer = step.includes('[parentContainer]')
const descriptionWithTags = step.replace('[parentContainer]', '').trim()
const { description: descWithoutParent, parentTaskId } =
extractParentTaskId(descriptionWithTags)
const { description: cleanDescription, effort } =
extractEffort(descWithoutParent)
// Validate effort extracted or default
const validatedEffort = ['low', 'medium', 'high'].includes(effort)
? effort
: 'medium'
// Get the predetermined ID for parent containers, otherwise generate new
const originalHighEffortDesc = isParentContainer ? cleanDescription : null
const taskId =
(originalHighEffortDesc &&
complexTaskMap.get(originalHighEffortDesc)) ||
crypto.randomUUID()
// If it's a parent container, set status to 'decomposed', otherwise 'pending'
const status = isParentContainer ? 'decomposed' : 'pending'
const taskDataToValidate: Omit<
Task,
'title' | 'subTasks' | 'dependencies' | 'history' | 'isManual'
> = {
id: taskId,
feature_id: featureId,
status,
description: cleanDescription,
effort: validatedEffort,
completed: false, // All new tasks/subtasks start as not completed.
...(parentTaskId && { parentTaskId }),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...(fromReviewContext && { fromReview: true }), // Set fromReview if in review context
}
// --- Enhanced Logging ---
logToFile(
`[processAndBreakdownTasks] Preparing ${
isParentContainer ? 'Parent' : parentTaskId ? 'Subtask' : 'Task'
} for validation: ID=${taskId}, Status=${status}, Parent=${
parentTaskId || 'N/A'
}, Desc="${cleanDescription.substring(0, 50)}..."`
)
logToFile(
`[processAndBreakdownTasks] Task data before validation: ${JSON.stringify(
taskDataToValidate
)}`
)
// --- End Enhanced Logging ---
// Validate against the Task schema before pushing
const validationResult = TaskSchema.safeParse(taskDataToValidate)
if (validationResult.success) {
// --- Enhanced Logging ---
logToFile(
`[processAndBreakdownTasks] Validation successful for Task ID: ${taskId}`
)
// --- End Enhanced Logging ---
finalTasks.push(validationResult.data)
} else {
// --- Enhanced Logging ---
const errorMsg = `Task "${cleanDescription.substring(
0,
30
)}..." (ID: ${taskId}) failed validation: ${
validationResult.error.message
}`
logToFile(`[processAndBreakdownTasks] ${errorMsg}`)
// --- End Enhanced Logging ---
taskCreationErrors.push(errorMsg)
console.warn(
`[TaskServer] Task validation failed for "${cleanDescription.substring(
0,
30
)}..." (ID: ${taskId}):`,
validationResult.error.flatten()
)
}
} catch (creationError) {
const errorMsg = `Error creating task object for step "${step.substring(
0,
30
)}...": ${
creationError instanceof Error
? creationError.message
: String(creationError)
}`
// --- Enhanced Logging ---
logToFile(`[processAndBreakdownTasks] ${errorMsg}`)
// --- End Enhanced Logging ---
taskCreationErrors.push(errorMsg)
console.error(
`[TaskServer] Error creating task object for step "${step.substring(
0,
30
)}...":`,
creationError
)
}
}
if (taskCreationErrors.length > 0) {
console.error(
`[TaskServer] ${taskCreationErrors.length} errors occurred during task object creation/validation.`
)
await addHistoryEntry(featureId, 'model', {
step: 'task_creation_errors',
errors: taskCreationErrors,
})
// Decide if we should throw or return partial results. Returning for now.
}
return { finalTasks, complexTaskMap }
}
/**
* Processes raw plan steps, ensures effort ratings are assigned, breaks down high-effort tasks,
* saves the final task list, and notifies WebSocket clients of the update.
*
* @param rawPlanSteps Array of task descriptions (format: "[effort] description").
* @param model The generative model to use for effort estimation/task breakdown.
* @param featureId The ID of the feature being planned.
* @param fromReview Optional flag to set fromReview: true on all saved tasks.
* @returns The final list of processed Task objects.
*/
export async function processAndFinalizePlan(
rawPlanSteps: string[],
model: GenerativeModel | OpenAI | null,
featureId: string,
fromReview: boolean = false // Add default value
): Promise<Task[]> {
logToFile(
`[TaskServer] Processing and finalizing plan for feature ${featureId}...`
)
let existingTasks: Task[] = []
let finalTasks: Task[] = []
const complexTaskMap = new Map<string, string>() // To track original description of broken down tasks
try {
// 1. Ensure all raw steps have effort ratings.
// ensureEffortRatings preserves existing [high] prefixes from rawPlanSteps
// and assigns effort to those without a prefix.
const initialTasksWithEffort = await ensureEffortRatings(
rawPlanSteps,
model
)
// Explicitly define the tasks to be sent for breakdown processing.
// This includes tasks from rawPlanSteps that were marked [high]
// (as ensureEffortRatings preserves such tags) and will be
// unconditionally processed by processAndBreakdownTasks.
const tasksForBreakdownProcessing = initialTasksWithEffort
// 2. Process tasks: Breakdown high-effort ones.
// processAndBreakdownTasks will identify and attempt to break down tasks
// with a "[high]" prefix within tasksForBreakdownProcessing.
const { finalTasks: processedTasks, complexTaskMap: breakdownMap } =
await processAndBreakdownTasks(
tasksForBreakdownProcessing, // Using the explicitly defined variable
model,
featureId, // Pass featureId for logging/history
fromReview // Pass the fromReview context flag
)
// Merge complexTaskMap from breakdown
breakdownMap.forEach((value, key) => complexTaskMap.set(key, value))
// --- Start Database Operations ---
await databaseService.connect()
logToFile(
`[processAndFinalizePlan] Database connected. Fetching existing tasks...`
)
// 3. Fetch existing tasks to compare
existingTasks = await databaseService.getTasksByFeatureId(featureId)
const existingTaskMap = new Map(existingTasks.map((t) => [t.id, t]))
const processedTaskMap = new Map(processedTasks.map((t) => [t.id, t]))
const tasksToAdd: Task[] = []
const tasksToUpdate: { id: string; updates: Partial<Task> }[] = []
const taskIdsToDelete: string[] = []
// 4. Compare processed tasks with existing tasks
for (const processedTask of processedTasks) {
if (existingTaskMap.has(processedTask.id)) {
// Task exists, check for updates
const existing = existingTaskMap.get(processedTask.id)!
// updates object should only contain keys matching DB columns (snake_case)
const updates: Partial<
Pick<Task, 'description' | 'effort' | 'fromReview'> & {
parentTaskId?: string
}
> = {}
if (existing.description !== processedTask.description) {
updates.description = processedTask.description
}
if (existing.effort !== processedTask.effort) {
updates.effort = processedTask.effort
}
// Compare snake_case from DB (existing) with camelCase from processed Task
if (existing.parentTaskId !== processedTask.parentTaskId) {
// Add snake_case key to updates object for DB
updates.parentTaskId = processedTask.parentTaskId
}
// Always update the 'fromReview' flag if this process is from review
if (fromReview && !existing.fromReview) {
// Use camelCase here as Task type expects it, DB service handles conversion to snake_case
updates.fromReview = true
await logToFile(
`[processAndFinalizePlan] Updating task ${existing.id} to set fromReview = true. Context fromReview: ${fromReview}, existing.fromReview: ${existing.fromReview}`
)
}
// Check if any updates are needed using the keys in the updates object
if (Object.keys(updates).length > 0) {
tasksToUpdate.push({ id: processedTask.id, updates })
}
} else {
// New task to add
tasksToAdd.push(processedTask)
}
}
if (!fromReview) {
// Identify tasks to delete (exist in DB but not in new plan)
for (const existingTask of existingTasks) {
if (!processedTaskMap.has(existingTask.id)) {
taskIdsToDelete.push(existingTask.id)
}
}
}
// 5. Apply changes to the database
logToFile(
`[processAndFinalizePlan] Applying DB changes: ${tasksToAdd.length} adds, ${tasksToUpdate.length} updates, ${taskIdsToDelete.length} deletes.`
)
for (const { id, updates } of tasksToUpdate) {
// Check if the task being updated was decomposed
const isDecomposed = complexTaskMap.has(id)
if (isDecomposed) {
// If decomposed, mark status as 'decomposed' and completed = true
await databaseService.updateTaskStatus(id, 'decomposed', true)
// Only update other details if necessary (rare for decomposed tasks)
if (Object.keys(updates).length > 0) {
// Pass updates object (contains snake_case key) to DB service
await databaseService.updateTaskDetails(id, updates)
}
} else {
// Otherwise, just update details
// Pass updates object (contains snake_case key) to DB service
await databaseService.updateTaskDetails(id, updates)
}
}
for (const task of tasksToAdd) {
// Ensure parent task exists if specified using camelCase from Task type
if (task.parentTaskId) {
const parentExistsInDB = existingTaskMap.has(task.parentTaskId)
const parentExistsInProcessed = processedTaskMap.has(task.parentTaskId)
if (!parentExistsInDB && !parentExistsInProcessed) {
logToFile(
`[processAndFinalizePlan] Warning: Parent task ${task.parentTaskId} for task ${task.id} not found. Setting parent to null.`
)
// Use camelCase when modifying the task object
task.parentTaskId = undefined
}
}
// Prepare object for DB insertion with snake_case keys
const now = Math.floor(Date.now() / 1000)
const dbTaskPayload: any = {
id: task.id,
title: task.title,
description: task.description,
status: task.status,
completed: task.completed ? 1 : 0,
effort: task.effort,
feature_id: featureId,
created_at:
task.createdAt && typeof task.createdAt === 'number'
? Math.floor(new Date(task.createdAt * 1000).getTime() / 1000)
: now, // Map and convert
updated_at:
task.updatedAt && typeof task.updatedAt === 'number'
? Math.floor(new Date(task.updatedAt * 1000).getTime() / 1000)
: now, // Map and convert
// Use camelCase 'fromReview' to align with the Task interface expected by addTask
fromReview: fromReview || task.fromReview || false,
}
await logToFile(
`[processAndFinalizePlan] Adding task ${task.id}. Context fromReview: ${fromReview}, task.fromReview property: ${task.fromReview}, dbTaskPayload.fromReview value: ${dbTaskPayload.fromReview}`,
'debug'
)
try {
// Ensure that the object passed to addTask conforms to the Task interface
await databaseService.addTask({
id: dbTaskPayload.id,
title: dbTaskPayload.title,
description: dbTaskPayload.description,
status: dbTaskPayload.status,
completed: dbTaskPayload.completed === 1, // Ensure boolean
effort: dbTaskPayload.effort,
feature_id: dbTaskPayload.feature_id,
created_at: dbTaskPayload.created_at,
updated_at: dbTaskPayload.updated_at,
fromReview: dbTaskPayload.fromReview, // This is now correctly camelCased
})
} catch (dbError) {
logToFile(
`[processAndFinalizePlan] Error adding task to database: ${dbError}`
)
console.error(`[TaskServer] Error adding task to database:`, dbError)
throw dbError
}
}
for (const taskId of taskIdsToDelete) {
await databaseService.deleteTask(taskId)
}
// 6. Fetch the final list of tasks after all modifications
finalTasks = await databaseService.getTasksByFeatureId(featureId)
logToFile(
`[processAndFinalizePlan] Final task count for feature ${featureId}: ${finalTasks.length}`
)
// --- End Database Operations ---
} catch (error) {
logToFile(
`[processAndFinalizePlan] Error during plan finalization for feature ${featureId}: ${error}`
)
console.error(`[TaskServer] Error during plan finalization:`, error)
// Re-throw the error to be handled by the caller (e.g., tool handler)
throw error
} finally {
// Ensure database connection is closed, even if errors occurred
try {
await databaseService.close()
logToFile(`[processAndFinalizePlan] Database connection closed.`)
} catch (closeError) {
logToFile(
`[processAndFinalizePlan] Error closing database connection: ${closeError}`
)
console.error(`[TaskServer] Error closing database:`, closeError)
}
}
// 7. Notify UI about the updated tasks (outside the main try/catch for DB ops)
try {
// Add detailed logging to debug
if (finalTasks.length > 0) {
await logToFile(
`[processAndFinalizePlan] Sample of final task from DB (finalTasks[0]): ${JSON.stringify(
finalTasks[0],
null,
2
)}`
)
} else {
await logToFile(
`[processAndFinalizePlan] No final tasks to log from DB sample.`
)
}
const formattedTasks = finalTasks.map((task: any) => {
// Create a clean task object for the WebSocket
return {
id: task.id,
description: task.description,
status: task.status,
effort: task.effort,
parentTaskId: task.parentTaskId,
completed: task.completed,
title: task.title,
fromReview: task.fromReview || task.from_review === 1, // Handle both camelCase and snake_case
createdAt:
typeof task.createdAt === 'number'
? new Date(task.createdAt * 1000).toISOString()
: undefined,
updatedAt:
typeof task.updatedAt === 'number'
? new Date(task.updatedAt * 1000).toISOString()
: undefined,
}
})
// Log the first formatted task
if (formattedTasks.length > 0) {
await logToFile(
`[processAndFinalizePlan] First formatted task for WebSocket (formattedTasks[0]): ${JSON.stringify(
formattedTasks[0],
null,
2
)}`,
'debug'
)
} else {
await logToFile(
`[processAndFinalizePlan] No formatted tasks to log for WebSocket sample.`,
'debug'
)
}
webSocketService.notifyTasksUpdated(featureId, formattedTasks)
logToFile(`[processAndFinalizePlan] WebSocket notification sent.`)
} catch (wsError) {
logToFile(
`[processAndFinalizePlan] Error sending WebSocket notification: ${wsError}`
)
console.error(`[TaskServer] Error sending WebSocket update:`, wsError)
// Do not re-throw WS errors, as the main operation succeeded
}
return finalTasks
}
/**
* Detects if the LLM response contains a clarification request.
* This function searches for both JSON-formatted clarification requests and
* special prefix format like [CLARIFICATION_NEEDED].
*
* @param responseText The raw response from the LLM
* @returns An object with success flag and either the parsed clarification request or error message
*/
export function detectClarificationRequest(
responseText: string | null | undefined
):
| {
detected: true
clarificationRequest: z.infer<typeof LLMClarificationRequestSchema>
rawResponse: string
}
| { detected: false; rawResponse: string | null } {
if (!responseText) {
return { detected: false, rawResponse: null }
}
// Check for [CLARIFICATION_NEEDED] format
const prefixMatch = responseText.match(
/\[CLARIFICATION_NEEDED\](.*?)(\[END_CLARIFICATION\]|$)/s
)
if (prefixMatch) {
const questionText = prefixMatch[1].trim()
// Parse out options if they exist
const optionsMatch = questionText.match(/Options:\s*\[(.*?)\]/)
const options = optionsMatch
? optionsMatch[1].split(',').map((o) => o.trim())
: undefined
// Check if text input is allowed
const allowsText = !questionText.includes('MULTIPLE_CHOICE_ONLY')
// Create a clarification request object
return {
detected: true,
clarificationRequest: {
type: 'clarification_needed',
question: questionText
.replace(/Options:\s*\[.*?\]/, '')
.replace('MULTIPLE_CHOICE_ONLY', '')
.trim(),
options,
allowsText,
},
rawResponse: responseText,
}
}
// Try to parse as JSON
try {
// Check if we have a JSON object in the response
const jsonMatch = responseText.match(/\{[\s\S]*\}/)
if (jsonMatch) {
const jsonStr = jsonMatch[0]
const parsedJson = JSON.parse(jsonStr)
// Check if it's a clarification request
if (
parsedJson.type === 'clarification_needed' ||
parsedJson.clarification_needed ||
parsedJson.needs_clarification
) {
// Attempt to validate against our schema
const result = LLMClarificationRequestSchema.safeParse({
type: 'clarification_needed',
question: parsedJson.question || parsedJson.message || '',
options: parsedJson.options || undefined,
allowsText: parsedJson.allowsText !== false,
})
if (result.success) {
return {
detected: true,
clarificationRequest: result.data,
rawResponse: responseText,
}
}
}
}
return { detected: false, rawResponse: responseText }
} catch (error) {
// If JSON parsing fails, it's not a JSON-formatted clarification request
return { detected: false, rawResponse: responseText }
}
}
```