├── CRASH.jpg ├── vitest.config.ts ├── tsconfig.json ├── LICENSE ├── .github └── workflows │ └── publish.yml ├── package.json ├── src ├── constants.ts ├── types.ts ├── index.ts ├── config.ts ├── schema.ts ├── formatter.ts └── server.ts ├── tests ├── config.test.ts ├── formatter.test.ts └── server.test.ts └── README.md /CRASH.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikkoxgonzales/crash-mcp/HEAD/CRASH.jpg -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'node', 7 | include: ['tests/**/*.test.ts'], 8 | coverage: { 9 | provider: 'v8', 10 | reporter: ['text', 'html'], 11 | include: ['src/**/*.ts'], 12 | exclude: ['src/index.ts'], // Main entry point with side effects 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ES2020", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "outDir": "dist", 9 | "rootDir": "src", 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules", "dist"] 16 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Nikko Gonzales (nikkoxgonzales@gmail.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 19 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 20 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 21 | OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish & Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [18, 20, 22] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | cache: 'npm' 20 | - run: npm ci 21 | - run: npm run build 22 | - run: npm test 23 | 24 | release: 25 | needs: test 26 | runs-on: ubuntu-latest 27 | permissions: 28 | contents: write 29 | 30 | steps: 31 | - uses: actions/checkout@v4 32 | 33 | - name: Create Release 34 | uses: softprops/action-gh-release@v1 35 | with: 36 | generate_release_notes: true 37 | 38 | publish: 39 | needs: release 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: actions/setup-node@v4 44 | with: 45 | node-version: 20 46 | registry-url: 'https://registry.npmjs.org' 47 | - run: npm ci 48 | - run: npm run build 49 | - run: npm test 50 | - run: npm publish 51 | env: 52 | NODE_AUTH_TOKEN: ${{ secrets.NPMJS_TOKEN }} 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crash-mcp", 3 | "version": "3.0.3", 4 | "description": "MCP server for CRASH - Cascaded Reasoning with Adaptive Step Handling", 5 | "type": "module", 6 | "main": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "bin": { 9 | "crash-mcp": "./dist/index.js" 10 | }, 11 | "files": [ 12 | "dist", 13 | "README.md", 14 | "LICENSE" 15 | ], 16 | "scripts": { 17 | "build": "tsc && chmod +x dist/index.js", 18 | "start": "node dist/index.js", 19 | "dev": "npx @modelcontextprotocol/inspector dist/index.js", 20 | "test": "vitest run", 21 | "test:watch": "vitest" 22 | }, 23 | "keywords": [ 24 | "mcp", 25 | "model-context-protocol", 26 | "crash", 27 | "cascaded-reasoning", 28 | "adaptive-step-handling", 29 | "problem-solving", 30 | "structured-thinking", 31 | "llm", 32 | "ai" 33 | ], 34 | "author": { 35 | "name": "Nikko Gonzales", 36 | "email": "nikkoxgonzales@gmail.com" 37 | }, 38 | "license": "MIT", 39 | "repository": { 40 | "type": "git", 41 | "url": "https://github.com/nikkoxgonzales/crash-mcp.git" 42 | }, 43 | "dependencies": { 44 | "@modelcontextprotocol/sdk": "1.17.3", 45 | "chalk": "^5.4.1" 46 | }, 47 | "devDependencies": { 48 | "@types/node": "^22.15.21", 49 | "typescript": "^5.8.3", 50 | "vitest": "^3.1.4" 51 | } 52 | } -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | // Valid thought prefixes for strict mode validation 4 | export const VALID_PREFIXES = [ 5 | 'OK, I ', 6 | 'But ', 7 | 'Wait ', 8 | 'Therefore ', 9 | 'I see the issue now. ', 10 | 'I have completed ' 11 | ] as const; 12 | 13 | // Valid purpose types 14 | export const VALID_PURPOSES = [ 15 | 'analysis', 16 | 'action', 17 | 'reflection', 18 | 'decision', 19 | 'summary', 20 | 'validation', 21 | 'exploration', 22 | 'hypothesis', 23 | 'correction', 24 | 'planning' 25 | ] as const; 26 | 27 | // Phrases that indicate task completion 28 | export const COMPLETION_PHRASES = [ 29 | 'I have completed', 30 | 'Task completed', 31 | 'Solution found' 32 | ] as const; 33 | 34 | // Pre-computed lowercase completion phrases for O(1) matching 35 | export const COMPLETION_PHRASES_LOWER = COMPLETION_PHRASES.map(p => p.toLowerCase()); 36 | 37 | // Pre-computed Set for O(1) purpose validation 38 | export const VALID_PURPOSES_SET = new Set(VALID_PURPOSES.map(p => p.toLowerCase())); 39 | 40 | // Purpose-to-color mapping for console output 41 | export const PURPOSE_COLORS: Record = { 42 | analysis: chalk.blue, 43 | action: chalk.green, 44 | reflection: chalk.yellow, 45 | decision: chalk.magenta, 46 | summary: chalk.cyan, 47 | validation: chalk.greenBright, 48 | exploration: chalk.yellowBright, 49 | hypothesis: chalk.blueBright, 50 | correction: chalk.redBright, 51 | planning: chalk.cyanBright, 52 | }; 53 | 54 | // Default color for unknown purposes 55 | export const DEFAULT_PURPOSE_COLOR = chalk.white; 56 | 57 | // Required fields for CrashStep validation 58 | export const REQUIRED_STEP_FIELDS = [ 59 | 'step_number', 60 | 'estimated_total', 61 | 'purpose', 62 | 'context', 63 | 'thought', 64 | 'outcome', 65 | 'next_action', 66 | 'rationale' 67 | ] as const; 68 | 69 | // Confidence bounds 70 | export const CONFIDENCE_MIN = 0; 71 | export const CONFIDENCE_MAX = 1; 72 | export const LOW_CONFIDENCE_THRESHOLD = 0.5; 73 | 74 | // Session cleanup interval (number of steps between cleanups) 75 | export const SESSION_CLEANUP_INTERVAL = 10; 76 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // Extended purpose types with more flexibility 2 | export type PurposeType = 3 | | 'analysis' 4 | | 'action' 5 | | 'reflection' 6 | | 'decision' 7 | | 'summary' 8 | | 'validation' 9 | | 'exploration' 10 | | 'hypothesis' 11 | | 'correction' 12 | | 'planning' 13 | | string; // Allow custom purposes 14 | 15 | // Structured action for better tool integration 16 | export interface StructuredAction { 17 | tool?: string; 18 | action: string; 19 | parameters?: Record; 20 | expectedOutput?: string; 21 | } 22 | 23 | // Enhanced step with new optional fields 24 | export interface CrashStep { 25 | // Required fields (backward compatible) 26 | step_number: number; 27 | estimated_total: number; 28 | purpose: PurposeType; 29 | context: string; 30 | thought: string; 31 | outcome: string; 32 | next_action: string | StructuredAction; // Now supports both formats 33 | rationale: string; 34 | 35 | // New optional fields for enhanced functionality 36 | confidence?: number; // 0-1 scale 37 | uncertainty_notes?: string; 38 | 39 | // Revision support 40 | revises_step?: number; 41 | revision_reason?: string; 42 | revised_by?: number; // Step number that revised this step 43 | 44 | // Completion 45 | is_final_step?: boolean; 46 | 47 | // Branching support 48 | branch_from?: number; 49 | branch_id?: string; 50 | branch_name?: string; 51 | 52 | // Tool integration 53 | tools_used?: string[]; 54 | external_context?: Record; 55 | dependencies?: number[]; // Step numbers this depends on 56 | 57 | // Metadata 58 | timestamp?: string; 59 | duration_ms?: number; 60 | 61 | // Session support 62 | session_id?: string; 63 | } 64 | 65 | // Branch tracking 66 | export interface Branch { 67 | id: string; 68 | name: string; 69 | from_step: number; 70 | steps: CrashStep[]; 71 | status: 'active' | 'merged' | 'abandoned'; 72 | created_at: string; 73 | depth: number; // Branch depth level (1 = first level branch) 74 | } 75 | 76 | // Enhanced history with branching support 77 | export interface CrashHistory { 78 | steps: CrashStep[]; 79 | branches?: Branch[]; 80 | completed: boolean; 81 | session_id?: string; 82 | created_at?: string; 83 | updated_at?: string; 84 | metadata?: { 85 | total_duration_ms?: number; 86 | revisions_count?: number; 87 | branches_created?: number; 88 | tools_used?: string[]; 89 | }; 90 | } 91 | 92 | // Session entry with timestamp for timeout management 93 | export interface SessionEntry { 94 | history: CrashHistory; 95 | lastAccessed: number; // Unix timestamp in milliseconds 96 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 5 | import { 6 | CallToolRequestSchema, 7 | ListToolsRequestSchema, 8 | } from '@modelcontextprotocol/sdk/types.js'; 9 | import { readFileSync } from 'fs'; 10 | import { dirname, join } from 'path'; 11 | import { fileURLToPath } from 'url'; 12 | import { CRASH_TOOL } from './schema.js'; 13 | import { loadConfig } from './config.js'; 14 | import { CrashServer } from './server.js'; 15 | 16 | const __filename = fileURLToPath(import.meta.url); 17 | const __dirname = dirname(__filename); 18 | const pkg = JSON.parse( 19 | readFileSync(join(__dirname, '..', 'package.json'), 'utf8'), 20 | ); 21 | const { name, version } = pkg; 22 | 23 | // Create MCP server instance with tools capability 24 | const server = new Server( 25 | { 26 | name, 27 | version, 28 | }, 29 | { 30 | capabilities: { 31 | tools: {}, 32 | }, 33 | }, 34 | ); 35 | 36 | // Load configuration 37 | const config = loadConfig(); 38 | 39 | // Show configuration on startup 40 | console.error('🚀 CRASH MCP Server Starting...'); 41 | console.error(`📋 Configuration:`); 42 | console.error(` - Strict Mode: ${config.validation.strictMode}`); 43 | console.error(` - Revisions: ${config.features.enableRevisions ? 'Enabled' : 'Disabled'}`); 44 | console.error(` - Branching: ${config.features.enableBranching ? 'Enabled' : 'Disabled'} (max depth: ${config.system.maxBranchDepth})`); 45 | console.error(` - Sessions: ${config.features.enableSessions ? 'Enabled' : 'Disabled'} (timeout: ${config.system.sessionTimeout}min)`); 46 | console.error(` - Output Format: ${config.display.outputFormat}`); 47 | console.error(` - Max History: ${config.system.maxHistorySize} steps`); 48 | 49 | const crashServer = new CrashServer(config); 50 | 51 | // Expose tool 52 | server.setRequestHandler(ListToolsRequestSchema, async () => ({ 53 | tools: [CRASH_TOOL], 54 | })); 55 | 56 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 57 | if (request.params.name === 'crash') { 58 | return crashServer.processStep(request.params.arguments); 59 | } 60 | 61 | return { 62 | content: [ 63 | { 64 | type: 'text', 65 | text: `Unknown tool: ${request.params.name}`, 66 | }, 67 | ], 68 | isError: true, 69 | }; 70 | }); 71 | 72 | async function runServer() { 73 | const transport = new StdioServerTransport(); 74 | await server.connect(transport); 75 | console.error('✅ CRASH MCP Server running on stdio'); 76 | console.error('🧠 CRASH - Cascaded Reasoning with Adaptive Step Handling'); 77 | console.error('📚 Use "crash" tool for structured reasoning'); 78 | 79 | if (config.validation.strictMode) { 80 | console.error('⚠️ Running in STRICT MODE - validation rules enforced'); 81 | } else { 82 | console.error('🎯 Running in FLEXIBLE MODE - natural language allowed'); 83 | } 84 | } 85 | 86 | runServer().catch((error) => { 87 | console.error('Fatal error running server:', error); 88 | process.exit(1); 89 | }); -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export interface CrashConfig { 2 | // Validation settings 3 | validation: { 4 | requireThoughtPrefix: boolean; 5 | requireRationalePrefix: boolean; 6 | allowCustomPurpose: boolean; 7 | strictMode: boolean; // Legacy compatibility mode 8 | }; 9 | 10 | // Feature flags 11 | features: { 12 | enableRevisions: boolean; 13 | enableBranching: boolean; 14 | enableConfidence: boolean; 15 | enableStructuredActions: boolean; 16 | enableSessions: boolean; 17 | }; 18 | 19 | // Display settings 20 | display: { 21 | colorOutput: boolean; 22 | outputFormat: 'console' | 'json' | 'markdown'; 23 | }; 24 | 25 | // System settings 26 | system: { 27 | maxHistorySize: number; 28 | maxBranchDepth: number; 29 | sessionTimeout: number; // in minutes 30 | }; 31 | } 32 | 33 | export const DEFAULT_CONFIG: CrashConfig = { 34 | validation: { 35 | requireThoughtPrefix: false, // Changed from true for flexibility 36 | requireRationalePrefix: false, // Changed from true for flexibility 37 | allowCustomPurpose: true, 38 | strictMode: false, // Set to true for legacy behavior 39 | }, 40 | features: { 41 | enableRevisions: true, 42 | enableBranching: true, 43 | enableConfidence: true, 44 | enableStructuredActions: true, 45 | enableSessions: false, // Disabled by default for simplicity 46 | }, 47 | display: { 48 | colorOutput: true, 49 | outputFormat: 'console', 50 | }, 51 | system: { 52 | maxHistorySize: 100, 53 | maxBranchDepth: 5, 54 | sessionTimeout: 60, 55 | }, 56 | }; 57 | 58 | // Valid output formats for validation 59 | const VALID_OUTPUT_FORMATS = ['console', 'json', 'markdown'] as const; 60 | 61 | /** 62 | * Safely parse an integer from environment variable with validation 63 | * Returns undefined if invalid, allowing fallback to default 64 | */ 65 | function parseIntEnv(value: string | undefined, min: number = 1): number | undefined { 66 | if (!value) return undefined; 67 | const parsed = parseInt(value, 10); 68 | if (isNaN(parsed) || parsed < min) return undefined; 69 | return parsed; 70 | } 71 | 72 | export function loadConfig(): CrashConfig { 73 | // Deep clone to avoid mutating DEFAULT_CONFIG 74 | const config: CrashConfig = { 75 | validation: { ...DEFAULT_CONFIG.validation }, 76 | features: { ...DEFAULT_CONFIG.features }, 77 | display: { ...DEFAULT_CONFIG.display }, 78 | system: { ...DEFAULT_CONFIG.system }, 79 | }; 80 | 81 | // Override from environment variables 82 | if (process.env.CRASH_STRICT_MODE === 'true') { 83 | config.validation.strictMode = true; 84 | config.validation.requireThoughtPrefix = true; 85 | config.validation.requireRationalePrefix = true; 86 | config.validation.allowCustomPurpose = false; 87 | } 88 | 89 | // Parse MAX_HISTORY_SIZE with validation (must be >= 1) 90 | const maxHistorySize = parseIntEnv(process.env.MAX_HISTORY_SIZE, 1); 91 | if (maxHistorySize !== undefined) { 92 | config.system.maxHistorySize = maxHistorySize; 93 | } 94 | 95 | // Validate output format against allowed values 96 | if (process.env.CRASH_OUTPUT_FORMAT) { 97 | const format = process.env.CRASH_OUTPUT_FORMAT.toLowerCase(); 98 | if (VALID_OUTPUT_FORMATS.includes(format as typeof VALID_OUTPUT_FORMATS[number])) { 99 | config.display.outputFormat = format as typeof VALID_OUTPUT_FORMATS[number]; 100 | } else { 101 | console.error(`⚠️ Invalid CRASH_OUTPUT_FORMAT '${process.env.CRASH_OUTPUT_FORMAT}', using default 'console'. Valid options: ${VALID_OUTPUT_FORMATS.join(', ')}`); 102 | } 103 | } 104 | 105 | if (process.env.CRASH_NO_COLOR === 'true') { 106 | config.display.colorOutput = false; 107 | } 108 | 109 | // Parse CRASH_SESSION_TIMEOUT with validation (must be >= 1 minute) 110 | const sessionTimeout = parseIntEnv(process.env.CRASH_SESSION_TIMEOUT, 1); 111 | if (sessionTimeout !== undefined) { 112 | config.system.sessionTimeout = sessionTimeout; 113 | } 114 | 115 | // Parse CRASH_MAX_BRANCH_DEPTH with validation (must be >= 1) 116 | const maxBranchDepth = parseIntEnv(process.env.CRASH_MAX_BRANCH_DEPTH, 1); 117 | if (maxBranchDepth !== undefined) { 118 | config.system.maxBranchDepth = maxBranchDepth; 119 | } 120 | 121 | if (process.env.CRASH_ENABLE_SESSIONS === 'true') { 122 | config.features.enableSessions = true; 123 | } 124 | 125 | return config; 126 | } -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from '@modelcontextprotocol/sdk/types.js'; 2 | 3 | const TOOL_DESCRIPTION = `Record a structured reasoning step for complex problem-solving. 4 | 5 | Use this tool to break down multi-step problems into trackable reasoning steps. Each step captures your current thinking, expected outcome, and planned next action. 6 | 7 | WHEN TO USE: 8 | - Multi-step analysis, debugging, or planning tasks 9 | - Tasks requiring systematic exploration of options 10 | - Problems where you need to track confidence or revise earlier thinking 11 | - Exploring multiple solution paths via branching 12 | 13 | WORKFLOW: 14 | 1. Start with step_number=1, estimate your total steps 15 | 2. Describe your thought process, expected outcome, and next action 16 | 3. Continue calling for each reasoning step, adjusting estimated_total as needed 17 | 4. Use confidence (0-1) when uncertain about conclusions 18 | 5. Use revises_step to correct earlier reasoning when you find errors 19 | 6. Use branch_from to explore alternative approaches 20 | 7. Set is_final_step=true when reasoning is complete 21 | 22 | Returns JSON summary with step count, completion status, and next action.`; 23 | 24 | export const CRASH_TOOL: Tool = { 25 | name: 'crash', 26 | description: TOOL_DESCRIPTION, 27 | inputSchema: { 28 | type: 'object', 29 | properties: { 30 | // Core required fields 31 | step_number: { 32 | type: 'integer', 33 | description: 'Sequential step number starting from 1. Increment for each new reasoning step.', 34 | minimum: 1 35 | }, 36 | estimated_total: { 37 | type: 'integer', 38 | description: 'Current estimate of total steps needed. Adjust as you learn more about the problem.', 39 | minimum: 1 40 | }, 41 | purpose: { 42 | type: 'string', 43 | description: 'Category of this reasoning step. Standard values: analysis (examining information), action (taking an action), reflection (reviewing progress), decision (making a choice), summary (consolidating findings), validation (checking results), exploration (investigating options), hypothesis (forming theories), correction (fixing errors), planning (outlining approach). Custom strings allowed in flexible mode.' 44 | }, 45 | context: { 46 | type: 'string', 47 | description: 'What is already known or has been completed. Include relevant findings from previous steps to avoid redundant work.' 48 | }, 49 | thought: { 50 | type: 'string', 51 | description: 'Your current reasoning process. Express naturally - describe what you are thinking and why.' 52 | }, 53 | outcome: { 54 | type: 'string', 55 | description: 'The expected or actual result from this step. What did you learn or accomplish?' 56 | }, 57 | next_action: { 58 | oneOf: [ 59 | { 60 | type: 'string', 61 | description: 'Simple description of your next action' 62 | }, 63 | { 64 | type: 'object', 65 | description: 'Structured action with tool details', 66 | properties: { 67 | tool: { type: 'string', description: 'Name of tool to use' }, 68 | action: { type: 'string', description: 'Specific action to perform' }, 69 | parameters: { type: 'object', description: 'Parameters to pass to the tool' }, 70 | expectedOutput: { type: 'string', description: 'What you expect this action to return' } 71 | }, 72 | required: ['action'] 73 | } 74 | ], 75 | description: 'What you will do next. Can be a simple string or structured object with tool details.' 76 | }, 77 | rationale: { 78 | type: 'string', 79 | description: 'Why you chose this next action. Explain your reasoning for the approach.' 80 | }, 81 | 82 | // Completion 83 | is_final_step: { 84 | type: 'boolean', 85 | description: 'Set to true to explicitly mark this as the final reasoning step. The reasoning chain will be marked complete.' 86 | }, 87 | 88 | // Confidence tracking 89 | confidence: { 90 | type: 'number', 91 | description: 'Your confidence in this step (0-1 scale). Use lower values when uncertain: 0.3 = low confidence, 0.5 = moderate, 0.8+ = high confidence.', 92 | minimum: 0, 93 | maximum: 1 94 | }, 95 | uncertainty_notes: { 96 | type: 'string', 97 | description: 'Describe specific uncertainties or doubts. What assumptions are you making? What could be wrong?' 98 | }, 99 | 100 | // Revision support 101 | revises_step: { 102 | type: 'integer', 103 | description: 'Step number you are revising or correcting. The original step will be marked as revised.', 104 | minimum: 1 105 | }, 106 | revision_reason: { 107 | type: 'string', 108 | description: 'Why you are revising the earlier step. What was wrong or incomplete?' 109 | }, 110 | 111 | // Branching support 112 | branch_from: { 113 | type: 'integer', 114 | description: 'Step number to branch from for exploring an alternative approach. Creates a new solution path.', 115 | minimum: 1 116 | }, 117 | branch_id: { 118 | type: 'string', 119 | description: 'Unique identifier for this branch. Auto-generated if not provided.' 120 | }, 121 | branch_name: { 122 | type: 'string', 123 | description: 'Human-readable name for this branch (e.g., "Alternative A: Use caching")' 124 | }, 125 | 126 | // Tool integration 127 | tools_used: { 128 | type: 'array', 129 | items: { type: 'string' }, 130 | description: 'List of tools you used during this step for tracking purposes.' 131 | }, 132 | external_context: { 133 | type: 'object', 134 | description: 'External data or tool outputs relevant to this step. Store important results here.' 135 | }, 136 | dependencies: { 137 | type: 'array', 138 | items: { type: 'integer' }, 139 | description: 'Step numbers this step depends on. Validated against existing steps in history.' 140 | }, 141 | 142 | // Session support 143 | session_id: { 144 | type: 'string', 145 | description: 'Session identifier for grouping related reasoning chains. Sessions expire after configured timeout.' 146 | } 147 | }, 148 | required: [ 149 | 'step_number', 150 | 'estimated_total', 151 | 'purpose', 152 | 'context', 153 | 'thought', 154 | 'outcome', 155 | 'next_action', 156 | 'rationale' 157 | ] 158 | } 159 | }; 160 | -------------------------------------------------------------------------------- /src/formatter.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { CrashStep, CrashHistory, Branch, StructuredAction } from './types.js'; 3 | import { PURPOSE_COLORS, DEFAULT_PURPOSE_COLOR } from './constants.js'; 4 | 5 | export class CrashFormatter { 6 | private colorEnabled: boolean; 7 | 8 | constructor(colorEnabled: boolean = true) { 9 | this.colorEnabled = colorEnabled; 10 | } 11 | 12 | private color(text: string, colorFn: typeof chalk.blue): string { 13 | return this.colorEnabled ? colorFn(text) : text; 14 | } 15 | 16 | private getPurposeColor(purpose: string): typeof chalk.blue { 17 | // Use static PURPOSE_COLORS from constants (avoids recreating object on every call) 18 | return PURPOSE_COLORS[purpose.toLowerCase()] || DEFAULT_PURPOSE_COLOR; 19 | } 20 | 21 | private formatConfidence(confidence?: number): string { 22 | if (confidence === undefined) return ''; 23 | 24 | const percentage = Math.round(confidence * 100); 25 | let symbol = '●'; 26 | let color = chalk.green; 27 | 28 | if (confidence < 0.3) { 29 | color = chalk.red; 30 | symbol = '○'; 31 | } else if (confidence < 0.7) { 32 | color = chalk.yellow; 33 | symbol = '◐'; 34 | } 35 | 36 | return this.colorEnabled ? color(` ${symbol} ${percentage}%`) : ` [${percentage}%]`; 37 | } 38 | 39 | private formatStructuredAction(action: string | StructuredAction): string { 40 | if (typeof action === 'string') { 41 | return action; 42 | } 43 | 44 | let result = action.action; 45 | if (action.tool) { 46 | result = `[${action.tool}] ${result}`; 47 | } 48 | if (action.parameters && Object.keys(action.parameters).length > 0) { 49 | result += ` (${JSON.stringify(action.parameters)})`; 50 | } 51 | return result; 52 | } 53 | 54 | formatStepConsole(step: CrashStep): string { 55 | const color = this.getPurposeColor(step.purpose); 56 | const purposeText = step.purpose.toUpperCase(); 57 | 58 | // Build header with step info 59 | let header = this.color(`[Step ${step.step_number}/${step.estimated_total}] ${purposeText}`, color); 60 | header += this.formatConfidence(step.confidence); 61 | 62 | // Add revision/branch indicators 63 | if (step.revises_step) { 64 | header += this.color(` ↻ Revises #${step.revises_step}`, chalk.yellow); 65 | } 66 | if (step.branch_from) { 67 | header += this.color(` ⟿ Branch from #${step.branch_from}`, chalk.magenta); 68 | if (step.branch_name) { 69 | header += this.color(` (${step.branch_name})`, chalk.gray); 70 | } 71 | } 72 | 73 | // Format main content 74 | const lines: string[] = [header]; 75 | 76 | if (step.context) { 77 | lines.push(this.color('Context:', chalk.gray) + ' ' + step.context); 78 | } 79 | 80 | lines.push(this.color('Thought:', chalk.white) + ' ' + step.thought); 81 | 82 | if (step.uncertainty_notes) { 83 | lines.push(this.color('Uncertainty:', chalk.yellow) + ' ' + step.uncertainty_notes); 84 | } 85 | 86 | if (step.revision_reason) { 87 | lines.push(this.color('Revision Reason:', chalk.yellow) + ' ' + step.revision_reason); 88 | } 89 | 90 | lines.push(this.color('Outcome:', chalk.gray) + ' ' + step.outcome); 91 | 92 | const nextAction = this.formatStructuredAction(step.next_action); 93 | lines.push(this.color('Next:', chalk.gray) + ' ' + nextAction + ' - ' + step.rationale); 94 | 95 | if (step.tools_used && step.tools_used.length > 0) { 96 | lines.push(this.color('Tools Used:', chalk.gray) + ' ' + step.tools_used.join(', ')); 97 | } 98 | 99 | if (step.dependencies && step.dependencies.length > 0) { 100 | lines.push(this.color('Depends On:', chalk.gray) + ' Steps ' + step.dependencies.join(', ')); 101 | } 102 | 103 | lines.push(this.color('─'.repeat(60), chalk.gray)); 104 | 105 | return lines.join('\n'); 106 | } 107 | 108 | formatStepMarkdown(step: CrashStep): string { 109 | const lines: string[] = []; 110 | 111 | // Header 112 | lines.push(`### Step ${step.step_number}/${step.estimated_total}: ${step.purpose.toUpperCase()}`); 113 | 114 | // Metadata badges 115 | const badges: string[] = []; 116 | if (step.confidence !== undefined) { 117 | badges.push(`![Confidence](https://img.shields.io/badge/confidence-${Math.round(step.confidence * 100)}%25-blue)`); 118 | } 119 | if (step.revises_step) { 120 | badges.push(`![Revises](https://img.shields.io/badge/revises-step%20${step.revises_step}-yellow)`); 121 | } 122 | if (step.branch_from) { 123 | badges.push(`![Branch](https://img.shields.io/badge/branch-from%20${step.branch_from}-purple)`); 124 | } 125 | if (badges.length > 0) { 126 | lines.push(badges.join(' ')); 127 | } 128 | 129 | // Content 130 | lines.push(''); 131 | lines.push(`**Context:** ${step.context}`); 132 | lines.push(''); 133 | lines.push(`**Thought:** ${step.thought}`); 134 | 135 | if (step.uncertainty_notes) { 136 | lines.push(''); 137 | lines.push(`> ⚠️ **Uncertainty:** ${step.uncertainty_notes}`); 138 | } 139 | 140 | if (step.revision_reason) { 141 | lines.push(''); 142 | lines.push(`> 🔄 **Revision Reason:** ${step.revision_reason}`); 143 | } 144 | 145 | lines.push(''); 146 | lines.push(`**Outcome:** ${step.outcome}`); 147 | lines.push(''); 148 | 149 | const nextAction = this.formatStructuredAction(step.next_action); 150 | lines.push(`**Next Action:** ${nextAction}`); 151 | lines.push(`- *Rationale:* ${step.rationale}`); 152 | 153 | if (step.tools_used && step.tools_used.length > 0) { 154 | lines.push(''); 155 | lines.push(`**Tools Used:** ${step.tools_used.join(', ')}`); 156 | } 157 | 158 | lines.push(''); 159 | lines.push('---'); 160 | 161 | return lines.join('\n'); 162 | } 163 | 164 | formatStepJSON(step: CrashStep): string { 165 | return JSON.stringify(step, null, 2); 166 | } 167 | 168 | formatHistorySummary(history: CrashHistory): string { 169 | const lines: string[] = []; 170 | 171 | lines.push(this.color('=== CRASH Session Summary ===', chalk.bold)); 172 | lines.push(`Total Steps: ${history.steps.length}`); 173 | lines.push(`Status: ${history.completed ? '✓ Completed' : '⟳ In Progress'}`); 174 | 175 | if (history.metadata) { 176 | const meta = history.metadata; 177 | if (meta.revisions_count) { 178 | lines.push(`Revisions: ${meta.revisions_count}`); 179 | } 180 | if (meta.branches_created) { 181 | lines.push(`Branches Created: ${meta.branches_created}`); 182 | } 183 | if (meta.total_duration_ms) { 184 | lines.push(`Duration: ${(meta.total_duration_ms / 1000).toFixed(2)}s`); 185 | } 186 | if (meta.tools_used && meta.tools_used.length > 0) { 187 | lines.push(`Tools Used: ${meta.tools_used.join(', ')}`); 188 | } 189 | } 190 | 191 | // Show confidence distribution 192 | const confidenceSteps = history.steps.filter(s => s.confidence !== undefined); 193 | if (confidenceSteps.length > 0) { 194 | const avgConfidence = confidenceSteps.reduce((sum, s) => sum + (s.confidence || 0), 0) / confidenceSteps.length; 195 | lines.push(`Average Confidence: ${Math.round(avgConfidence * 100)}%`); 196 | } 197 | 198 | // Show branches if any 199 | if (history.branches && history.branches.length > 0) { 200 | lines.push(''); 201 | lines.push('Branches:'); 202 | history.branches.forEach(branch => { 203 | const status = branch.status === 'active' ? '●' : branch.status === 'merged' ? '✓' : '✗'; 204 | lines.push(` ${status} ${branch.name} (${branch.steps.length} steps)`); 205 | }); 206 | } 207 | 208 | lines.push(this.color('='.repeat(30), chalk.gray)); 209 | 210 | return lines.join('\n'); 211 | } 212 | 213 | formatBranchTree(history: CrashHistory): string { 214 | const lines: string[] = []; 215 | lines.push('Branch Structure:'); 216 | 217 | // Pre-index branches by from_step for O(1) lookup (optimization) 218 | const branchesByStep = new Map(); 219 | if (history.branches) { 220 | for (const branch of history.branches) { 221 | const existing = branchesByStep.get(branch.from_step) || []; 222 | existing.push(branch); 223 | branchesByStep.set(branch.from_step, existing); 224 | } 225 | } 226 | 227 | // Create a visual tree of branches 228 | const mainSteps = history.steps.filter(s => !s.branch_id); 229 | lines.push('Main:'); 230 | mainSteps.forEach(step => { 231 | lines.push(` └─ Step ${step.step_number}: ${step.purpose}`); 232 | 233 | // Use pre-indexed lookup instead of filtering all branches 234 | const branchesFromStep = branchesByStep.get(step.step_number) || []; 235 | branchesFromStep.forEach(branch => { 236 | lines.push(` └─ Branch: ${branch.name}`); 237 | branch.steps.forEach(bStep => { 238 | lines.push(` └─ Step ${bStep.step_number}: ${bStep.purpose}`); 239 | }); 240 | }); 241 | }); 242 | 243 | return lines.join('\n'); 244 | } 245 | } -------------------------------------------------------------------------------- /tests/config.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 2 | import { DEFAULT_CONFIG } from '../src/config.js'; 3 | 4 | // Note: We dynamically import loadConfig in each test to ensure fresh env var reading 5 | 6 | describe('config', () => { 7 | beforeEach(() => { 8 | vi.resetModules(); 9 | vi.unstubAllEnvs(); 10 | }); 11 | 12 | afterEach(() => { 13 | vi.unstubAllEnvs(); 14 | }); 15 | 16 | describe('DEFAULT_CONFIG', () => { 17 | it('should have flexible mode defaults', () => { 18 | expect(DEFAULT_CONFIG.validation.strictMode).toBe(false); 19 | expect(DEFAULT_CONFIG.validation.requireThoughtPrefix).toBe(false); 20 | expect(DEFAULT_CONFIG.validation.requireRationalePrefix).toBe(false); 21 | expect(DEFAULT_CONFIG.validation.allowCustomPurpose).toBe(true); 22 | }); 23 | 24 | it('should have features enabled by default', () => { 25 | expect(DEFAULT_CONFIG.features.enableRevisions).toBe(true); 26 | expect(DEFAULT_CONFIG.features.enableBranching).toBe(true); 27 | expect(DEFAULT_CONFIG.features.enableConfidence).toBe(true); 28 | expect(DEFAULT_CONFIG.features.enableStructuredActions).toBe(true); 29 | }); 30 | 31 | it('should have sessions disabled by default', () => { 32 | expect(DEFAULT_CONFIG.features.enableSessions).toBe(false); 33 | }); 34 | 35 | it('should have sensible system defaults', () => { 36 | expect(DEFAULT_CONFIG.system.maxHistorySize).toBe(100); 37 | expect(DEFAULT_CONFIG.system.maxBranchDepth).toBe(5); 38 | expect(DEFAULT_CONFIG.system.sessionTimeout).toBe(60); 39 | }); 40 | }); 41 | 42 | describe('loadConfig', () => { 43 | it('should return default config when no env vars set', async () => { 44 | const { loadConfig } = await import('../src/config.js'); 45 | const config = loadConfig(); 46 | expect(config).toEqual(DEFAULT_CONFIG); 47 | }); 48 | 49 | describe('CRASH_STRICT_MODE', () => { 50 | it('should enable strict mode when set to true', async () => { 51 | vi.stubEnv('CRASH_STRICT_MODE', 'true'); 52 | const { loadConfig } = await import('../src/config.js'); 53 | const config = loadConfig(); 54 | 55 | expect(config.validation.strictMode).toBe(true); 56 | expect(config.validation.requireThoughtPrefix).toBe(true); 57 | expect(config.validation.requireRationalePrefix).toBe(true); 58 | expect(config.validation.allowCustomPurpose).toBe(false); 59 | }); 60 | 61 | it('should not enable strict mode when set to false', async () => { 62 | vi.stubEnv('CRASH_STRICT_MODE', 'false'); 63 | const { loadConfig } = await import('../src/config.js'); 64 | const config = loadConfig(); 65 | 66 | expect(config.validation.strictMode).toBe(false); 67 | }); 68 | 69 | it('should not enable strict mode when not set', async () => { 70 | // Explicitly not stubbing CRASH_STRICT_MODE 71 | const { loadConfig } = await import('../src/config.js'); 72 | const config = loadConfig(); 73 | 74 | expect(config.validation.strictMode).toBe(false); 75 | }); 76 | }); 77 | 78 | describe('MAX_HISTORY_SIZE', () => { 79 | it('should set max history size from env var', async () => { 80 | vi.stubEnv('MAX_HISTORY_SIZE', '50'); 81 | const { loadConfig } = await import('../src/config.js'); 82 | const config = loadConfig(); 83 | 84 | expect(config.system.maxHistorySize).toBe(50); 85 | }); 86 | 87 | it('should handle large values', async () => { 88 | vi.stubEnv('MAX_HISTORY_SIZE', '1000'); 89 | const { loadConfig } = await import('../src/config.js'); 90 | const config = loadConfig(); 91 | 92 | expect(config.system.maxHistorySize).toBe(1000); 93 | }); 94 | 95 | it('should use default for NaN values', async () => { 96 | vi.stubEnv('MAX_HISTORY_SIZE', 'abc'); 97 | const { loadConfig } = await import('../src/config.js'); 98 | const config = loadConfig(); 99 | 100 | expect(config.system.maxHistorySize).toBe(100); // Default 101 | }); 102 | 103 | it('should use default for zero values', async () => { 104 | vi.stubEnv('MAX_HISTORY_SIZE', '0'); 105 | const { loadConfig } = await import('../src/config.js'); 106 | const config = loadConfig(); 107 | 108 | expect(config.system.maxHistorySize).toBe(100); // Default 109 | }); 110 | 111 | it('should use default for negative values', async () => { 112 | vi.stubEnv('MAX_HISTORY_SIZE', '-5'); 113 | const { loadConfig } = await import('../src/config.js'); 114 | const config = loadConfig(); 115 | 116 | expect(config.system.maxHistorySize).toBe(100); // Default 117 | }); 118 | }); 119 | 120 | describe('CRASH_OUTPUT_FORMAT', () => { 121 | it('should set output format to json', async () => { 122 | vi.stubEnv('CRASH_OUTPUT_FORMAT', 'json'); 123 | const { loadConfig } = await import('../src/config.js'); 124 | const config = loadConfig(); 125 | 126 | expect(config.display.outputFormat).toBe('json'); 127 | }); 128 | 129 | it('should set output format to markdown', async () => { 130 | vi.stubEnv('CRASH_OUTPUT_FORMAT', 'markdown'); 131 | const { loadConfig } = await import('../src/config.js'); 132 | const config = loadConfig(); 133 | 134 | expect(config.display.outputFormat).toBe('markdown'); 135 | }); 136 | 137 | it('should set output format to console', async () => { 138 | vi.stubEnv('CRASH_OUTPUT_FORMAT', 'console'); 139 | const { loadConfig } = await import('../src/config.js'); 140 | const config = loadConfig(); 141 | 142 | expect(config.display.outputFormat).toBe('console'); 143 | }); 144 | 145 | it('should use default for invalid output format', async () => { 146 | vi.stubEnv('CRASH_OUTPUT_FORMAT', 'invalid'); 147 | vi.spyOn(console, 'error').mockImplementation(() => {}); 148 | const { loadConfig } = await import('../src/config.js'); 149 | const config = loadConfig(); 150 | 151 | expect(config.display.outputFormat).toBe('console'); // Default 152 | }); 153 | 154 | it('should be case-insensitive', async () => { 155 | vi.stubEnv('CRASH_OUTPUT_FORMAT', 'JSON'); 156 | const { loadConfig } = await import('../src/config.js'); 157 | const config = loadConfig(); 158 | 159 | expect(config.display.outputFormat).toBe('json'); 160 | }); 161 | }); 162 | 163 | describe('CRASH_NO_COLOR', () => { 164 | it('should disable colors when set to true', async () => { 165 | vi.stubEnv('CRASH_NO_COLOR', 'true'); 166 | const { loadConfig } = await import('../src/config.js'); 167 | const config = loadConfig(); 168 | 169 | expect(config.display.colorOutput).toBe(false); 170 | }); 171 | 172 | it('should keep colors enabled when not set', async () => { 173 | // Explicitly not stubbing CRASH_NO_COLOR 174 | const { loadConfig } = await import('../src/config.js'); 175 | const config = loadConfig(); 176 | 177 | expect(config.display.colorOutput).toBe(true); 178 | }); 179 | }); 180 | 181 | describe('CRASH_SESSION_TIMEOUT', () => { 182 | it('should set session timeout from env var', async () => { 183 | vi.stubEnv('CRASH_SESSION_TIMEOUT', '30'); 184 | const { loadConfig } = await import('../src/config.js'); 185 | const config = loadConfig(); 186 | 187 | expect(config.system.sessionTimeout).toBe(30); 188 | }); 189 | }); 190 | 191 | describe('CRASH_MAX_BRANCH_DEPTH', () => { 192 | it('should set max branch depth from env var', async () => { 193 | vi.stubEnv('CRASH_MAX_BRANCH_DEPTH', '3'); 194 | const { loadConfig } = await import('../src/config.js'); 195 | const config = loadConfig(); 196 | 197 | expect(config.system.maxBranchDepth).toBe(3); 198 | }); 199 | }); 200 | 201 | describe('CRASH_ENABLE_SESSIONS', () => { 202 | it('should enable sessions when set to true', async () => { 203 | vi.stubEnv('CRASH_ENABLE_SESSIONS', 'true'); 204 | const { loadConfig } = await import('../src/config.js'); 205 | const config = loadConfig(); 206 | 207 | expect(config.features.enableSessions).toBe(true); 208 | }); 209 | 210 | it('should keep sessions disabled when set to false', async () => { 211 | vi.stubEnv('CRASH_ENABLE_SESSIONS', 'false'); 212 | const { loadConfig } = await import('../src/config.js'); 213 | const config = loadConfig(); 214 | 215 | expect(config.features.enableSessions).toBe(false); 216 | }); 217 | 218 | it('should keep sessions disabled when not set', async () => { 219 | // Explicitly not stubbing CRASH_ENABLE_SESSIONS 220 | const { loadConfig } = await import('../src/config.js'); 221 | const config = loadConfig(); 222 | 223 | expect(config.features.enableSessions).toBe(false); 224 | }); 225 | }); 226 | 227 | it('should handle multiple env vars together', async () => { 228 | vi.stubEnv('CRASH_STRICT_MODE', 'true'); 229 | vi.stubEnv('MAX_HISTORY_SIZE', '200'); 230 | vi.stubEnv('CRASH_OUTPUT_FORMAT', 'markdown'); 231 | vi.stubEnv('CRASH_SESSION_TIMEOUT', '120'); 232 | vi.stubEnv('CRASH_MAX_BRANCH_DEPTH', '10'); 233 | vi.stubEnv('CRASH_ENABLE_SESSIONS', 'true'); 234 | 235 | const { loadConfig } = await import('../src/config.js'); 236 | const config = loadConfig(); 237 | 238 | expect(config.validation.strictMode).toBe(true); 239 | expect(config.system.maxHistorySize).toBe(200); 240 | expect(config.display.outputFormat).toBe('markdown'); 241 | expect(config.system.sessionTimeout).toBe(120); 242 | expect(config.system.maxBranchDepth).toBe(10); 243 | expect(config.features.enableSessions).toBe(true); 244 | }); 245 | }); 246 | }); 247 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![CRASH.jpg](CRASH.jpg) 2 | 3 | --- 4 | 5 | # CRASH 6 | ## Cascaded Reasoning with Adaptive Step Handling 7 | 8 | An MCP (Model Context Protocol) server for structured, iterative reasoning. CRASH helps AI assistants break down complex problems into trackable steps with confidence tracking, revision support, and branching for exploring alternatives. 9 | 10 | > Inspired by [MCP Sequential Thinking Server](https://github.com/modelcontextprotocol/servers/blob/main/src/sequentialthinking) 11 | 12 | --- 13 | 14 | ## Why CRASH? 15 | 16 | I created this because typing "use sequential_thinking" was cumbersome. Now I can simply say "use crash" instead. 17 | 18 | CRASH is more token-efficient than sequential thinking - it doesn't include code in thoughts and has streamlined prompting. It's my go-to solution when an agent can't solve an issue in one shot or when plan mode falls short. 19 | 20 | ### Claude Code's Assessment 21 | 22 | ``` 23 | CRASH helped significantly for this specific task: 24 | 25 | Where CRASH helped: 26 | - Systematic analysis: Forced me to break down the issue methodically 27 | - Solution exploration: Explored multiple approaches before settling on the best one 28 | - Planning validation: Each step built on the previous one logically 29 | 30 | The key difference: 31 | CRASH forced me to be more thorough in the analysis phase. Without it, I might have 32 | rushed to implement the first solution rather than exploring cleaner approaches. 33 | 34 | Verdict: CRASH adds value for complex problems requiring systematic analysis of 35 | multiple solution paths. For simpler tasks, internal planning is sufficient and faster. 36 | ``` 37 | 38 | --- 39 | 40 | ## Features 41 | 42 | - **Structured reasoning steps** - Track thought process, outcomes, and next actions 43 | - **Confidence tracking** - Express uncertainty with 0-1 scores, get warnings on low confidence 44 | - **Revision mechanism** - Correct previous steps, with original steps marked as revised 45 | - **Branching support** - Explore multiple solution paths with depth limits 46 | - **Dependency validation** - Declare and validate step dependencies 47 | - **Session management** - Group related reasoning chains with automatic timeout cleanup 48 | - **Multiple output formats** - Console (colored), JSON, or Markdown 49 | - **Flexible validation** - Strict mode for rigid rules, flexible mode for natural language 50 | 51 | --- 52 | 53 | ## Installation 54 | 55 | ```bash 56 | npm install crash-mcp 57 | ``` 58 | 59 | Or use directly with npx: 60 | 61 | ```bash 62 | npx crash-mcp 63 | ``` 64 | 65 | ### Quick Setup 66 | 67 | Most MCP clients use this JSON configuration: 68 | 69 | ```json 70 | { 71 | "mcpServers": { 72 | "crash": { 73 | "command": "npx", 74 | "args": ["-y", "crash-mcp"] 75 | } 76 | } 77 | } 78 | ``` 79 | 80 | ### Configuration by Client 81 | 82 | | Client | Setup Method | 83 | |--------|-------------| 84 | | **Claude Code** | `claude mcp add crash -- npx -y crash-mcp` | 85 | | **Cursor** | Add to `~/.cursor/mcp.json` | 86 | | **VS Code** | Add to settings JSON under `mcp.servers` | 87 | | **Claude Desktop** | Add to `claude_desktop_config.json` | 88 | | **Windsurf** | Add to MCP config file | 89 | | **JetBrains** | Settings > Tools > AI Assistant > MCP | 90 | | **Others** | Use standard MCP JSON config above | 91 | 92 |
93 | Windows Users 94 | 95 | Use the cmd wrapper: 96 | 97 | ```json 98 | { 99 | "mcpServers": { 100 | "crash": { 101 | "command": "cmd", 102 | "args": ["/c", "npx", "-y", "crash-mcp"] 103 | } 104 | } 105 | } 106 | ``` 107 | 108 |
109 | 110 |
111 | With Environment Variables 112 | 113 | ```json 114 | { 115 | "mcpServers": { 116 | "crash": { 117 | "command": "npx", 118 | "args": ["-y", "crash-mcp"], 119 | "env": { 120 | "CRASH_STRICT_MODE": "false", 121 | "MAX_HISTORY_SIZE": "100", 122 | "CRASH_OUTPUT_FORMAT": "console", 123 | "CRASH_SESSION_TIMEOUT": "60", 124 | "CRASH_MAX_BRANCH_DEPTH": "5" 125 | } 126 | } 127 | } 128 | } 129 | ``` 130 | 131 |
132 | 133 |
134 | Using Docker 135 | 136 | ```dockerfile 137 | FROM node:18-alpine 138 | WORKDIR /app 139 | RUN npm install -g crash-mcp 140 | CMD ["crash-mcp"] 141 | ``` 142 | 143 | ```json 144 | { 145 | "mcpServers": { 146 | "crash": { 147 | "command": "docker", 148 | "args": ["run", "-i", "--rm", "crash-mcp"] 149 | } 150 | } 151 | } 152 | ``` 153 | 154 |
155 | 156 |
157 | Alternative Runtimes 158 | 159 | **Bun:** 160 | ```json 161 | { "command": "bunx", "args": ["-y", "crash-mcp"] } 162 | ``` 163 | 164 | **Deno:** 165 | ```json 166 | { 167 | "command": "deno", 168 | "args": ["run", "--allow-env", "--allow-net", "npm:crash-mcp"] 169 | } 170 | ``` 171 | 172 |
173 | 174 | --- 175 | 176 | ## Configuration 177 | 178 | | Variable | Default | Description | 179 | |----------|---------|-------------| 180 | | `CRASH_STRICT_MODE` | `false` | Enable strict validation (requires specific prefixes) | 181 | | `MAX_HISTORY_SIZE` | `100` | Maximum steps to retain in history | 182 | | `CRASH_OUTPUT_FORMAT` | `console` | Output format: `console`, `json`, `markdown` | 183 | | `CRASH_NO_COLOR` | `false` | Disable colored console output | 184 | | `CRASH_SESSION_TIMEOUT` | `60` | Session timeout in minutes | 185 | | `CRASH_MAX_BRANCH_DEPTH` | `5` | Maximum branch nesting depth | 186 | | `CRASH_ENABLE_SESSIONS` | `false` | Enable session management | 187 | 188 | --- 189 | 190 | ## Usage 191 | 192 | ### Required Parameters 193 | 194 | | Parameter | Type | Description | 195 | |-----------|------|-------------| 196 | | `step_number` | integer | Sequential step number (starts at 1) | 197 | | `estimated_total` | integer | Estimated total steps (adjustable) | 198 | | `purpose` | string | Step category: analysis, action, validation, exploration, hypothesis, correction, planning, or custom | 199 | | `context` | string | What's already known to avoid redundancy | 200 | | `thought` | string | Current reasoning process | 201 | | `outcome` | string | Expected or actual result | 202 | | `next_action` | string/object | Next action (simple string or structured with tool details) | 203 | | `rationale` | string | Why this next action was chosen | 204 | 205 | ### Optional Parameters 206 | 207 | | Parameter | Type | Description | 208 | |-----------|------|-------------| 209 | | `is_final_step` | boolean | Mark as final step to complete reasoning | 210 | | `confidence` | number | Confidence level 0-1 (warnings below 0.5) | 211 | | `uncertainty_notes` | string | Describe doubts or assumptions | 212 | | `revises_step` | integer | Step number being corrected | 213 | | `revision_reason` | string | Why revision is needed | 214 | | `branch_from` | integer | Step to branch from | 215 | | `branch_id` | string | Unique branch identifier | 216 | | `branch_name` | string | Human-readable branch name | 217 | | `dependencies` | integer[] | Step numbers this depends on | 218 | | `session_id` | string | Group related reasoning chains | 219 | | `tools_used` | string[] | Tools used in this step | 220 | | `external_context` | object | External data relevant to step | 221 | 222 | --- 223 | 224 | ## Examples 225 | 226 | ### Basic Usage 227 | 228 | ```json 229 | { 230 | "step_number": 1, 231 | "estimated_total": 3, 232 | "purpose": "analysis", 233 | "context": "User requested optimization of database queries", 234 | "thought": "I need to first understand the current query patterns before proposing changes", 235 | "outcome": "Identified slow queries for optimization", 236 | "next_action": "analyze query execution plans", 237 | "rationale": "Understanding execution plans will reveal bottlenecks" 238 | } 239 | ``` 240 | 241 | ### With Confidence and Final Step 242 | 243 | ```json 244 | { 245 | "step_number": 3, 246 | "estimated_total": 3, 247 | "purpose": "summary", 248 | "context": "Analyzed queries and tested index optimizations", 249 | "thought": "The index on user_id reduced query time from 2s to 50ms", 250 | "outcome": "Performance issue resolved with new index", 251 | "next_action": "document the change", 252 | "rationale": "Team should know about the optimization", 253 | "confidence": 0.9, 254 | "is_final_step": true 255 | } 256 | ``` 257 | 258 | ### Revision Example 259 | 260 | ```json 261 | { 262 | "step_number": 4, 263 | "estimated_total": 5, 264 | "purpose": "correction", 265 | "context": "Previous analysis missed a critical join condition", 266 | "thought": "The join was causing a cartesian product, not the index", 267 | "outcome": "Corrected root cause identification", 268 | "next_action": "fix the join condition", 269 | "rationale": "This is the actual performance issue", 270 | "revises_step": 2, 271 | "revision_reason": "Overlooked critical join in initial analysis" 272 | } 273 | ``` 274 | 275 | ### Branching Example 276 | 277 | ```json 278 | { 279 | "step_number": 3, 280 | "estimated_total": 6, 281 | "purpose": "exploration", 282 | "context": "Two optimization approaches identified", 283 | "thought": "Exploring the indexing approach first as it's lower risk", 284 | "outcome": "Branch created for index optimization testing", 285 | "next_action": "test index performance", 286 | "rationale": "This approach has lower risk than query rewrite", 287 | "branch_from": 2, 288 | "branch_id": "index-optimization", 289 | "branch_name": "Index-based optimization" 290 | } 291 | ``` 292 | 293 | --- 294 | 295 | ## When to Use CRASH 296 | 297 | **Good fit:** 298 | - Complex multi-step problem solving 299 | - Code analysis and optimization 300 | - System design with multiple considerations 301 | - Debugging requiring systematic investigation 302 | - Exploring multiple solution paths 303 | - Tasks where you need to track confidence 304 | 305 | **Not needed:** 306 | - Simple, single-step tasks 307 | - Pure information retrieval 308 | - Deterministic procedures with no uncertainty 309 | 310 | --- 311 | 312 | ## Development 313 | 314 | ```bash 315 | npm install # Install dependencies 316 | npm run build # Build TypeScript 317 | npm run dev # Run with MCP inspector 318 | npm start # Start built server 319 | ``` 320 | 321 | --- 322 | 323 | ## Troubleshooting 324 | 325 |
326 | Module Not Found Errors 327 | 328 | Try using `bunx` instead of `npx`: 329 | 330 | ```json 331 | { "command": "bunx", "args": ["-y", "crash-mcp"] } 332 | ``` 333 | 334 |
335 | 336 |
337 | ESM Resolution Issues 338 | 339 | Try the experimental VM modules flag: 340 | 341 | ```json 342 | { "args": ["-y", "--node-options=--experimental-vm-modules", "crash-mcp"] } 343 | ``` 344 | 345 |
346 | 347 | --- 348 | 349 | ## Credits 350 | 351 | - [MCP Sequential Thinking Server](https://github.com/modelcontextprotocol/servers/blob/main/src/sequentialthinking) - Primary inspiration 352 | - [MCP Protocol Specification](https://modelcontextprotocol.io/) 353 | 354 | ## Author 355 | 356 | **Nikko Gonzales** - [nikkoxgonzales](https://github.com/nikkoxgonzales) 357 | 358 | ## License 359 | 360 | MIT 361 | -------------------------------------------------------------------------------- /tests/formatter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { CrashFormatter } from '../src/formatter.js'; 3 | import { CrashStep, CrashHistory } from '../src/types.js'; 4 | 5 | describe('CrashFormatter', () => { 6 | const createBaseStep = (overrides: Partial = {}): CrashStep => ({ 7 | step_number: 1, 8 | estimated_total: 3, 9 | purpose: 'analysis', 10 | context: 'Test context', 11 | thought: 'Test thought', 12 | outcome: 'Test outcome', 13 | next_action: 'Test next action', 14 | rationale: 'Test rationale', 15 | ...overrides, 16 | }); 17 | 18 | describe('formatStepConsole', () => { 19 | it('should format basic step without colors', () => { 20 | const formatter = new CrashFormatter(false); 21 | const step = createBaseStep(); 22 | const output = formatter.formatStepConsole(step); 23 | 24 | expect(output).toContain('[Step 1/3] ANALYSIS'); 25 | expect(output).toContain('Context: Test context'); 26 | expect(output).toContain('Thought: Test thought'); 27 | expect(output).toContain('Outcome: Test outcome'); 28 | expect(output).toContain('Next: Test next action - Test rationale'); 29 | }); 30 | 31 | it('should include confidence when provided', () => { 32 | const formatter = new CrashFormatter(false); 33 | const step = createBaseStep({ confidence: 0.85 }); 34 | const output = formatter.formatStepConsole(step); 35 | 36 | expect(output).toContain('[85%]'); 37 | }); 38 | 39 | it('should show revision info when revising another step', () => { 40 | const formatter = new CrashFormatter(false); 41 | const step = createBaseStep({ 42 | revises_step: 2, 43 | revision_reason: 'Found error in step 2', 44 | }); 45 | const output = formatter.formatStepConsole(step); 46 | 47 | expect(output).toContain('Revises #2'); 48 | expect(output).toContain('Revision Reason: Found error in step 2'); 49 | }); 50 | 51 | it('should show branch info when branching', () => { 52 | const formatter = new CrashFormatter(false); 53 | const step = createBaseStep({ 54 | branch_from: 1, 55 | branch_name: 'Alternative approach', 56 | }); 57 | const output = formatter.formatStepConsole(step); 58 | 59 | expect(output).toContain('Branch from #1'); 60 | expect(output).toContain('(Alternative approach)'); 61 | }); 62 | 63 | it('should show uncertainty notes when provided', () => { 64 | const formatter = new CrashFormatter(false); 65 | const step = createBaseStep({ 66 | uncertainty_notes: 'Not sure about this approach', 67 | }); 68 | const output = formatter.formatStepConsole(step); 69 | 70 | expect(output).toContain('Uncertainty: Not sure about this approach'); 71 | }); 72 | 73 | it('should show tools used when provided', () => { 74 | const formatter = new CrashFormatter(false); 75 | const step = createBaseStep({ 76 | tools_used: ['Read', 'Grep', 'Edit'], 77 | }); 78 | const output = formatter.formatStepConsole(step); 79 | 80 | expect(output).toContain('Tools Used: Read, Grep, Edit'); 81 | }); 82 | 83 | it('should show dependencies when provided', () => { 84 | const formatter = new CrashFormatter(false); 85 | const step = createBaseStep({ 86 | dependencies: [1, 2, 3], 87 | }); 88 | const output = formatter.formatStepConsole(step); 89 | 90 | expect(output).toContain('Depends On: Steps 1, 2, 3'); 91 | }); 92 | 93 | it('should format structured action', () => { 94 | const formatter = new CrashFormatter(false); 95 | const step = createBaseStep({ 96 | next_action: { 97 | tool: 'Read', 98 | action: 'Read the config file', 99 | parameters: { path: '/config.ts' }, 100 | }, 101 | }); 102 | const output = formatter.formatStepConsole(step); 103 | 104 | expect(output).toContain('[Read] Read the config file'); 105 | expect(output).toContain('{"path":"/config.ts"}'); 106 | }); 107 | 108 | it('should handle all purpose types', () => { 109 | const formatter = new CrashFormatter(false); 110 | const purposes = [ 111 | 'analysis', 'action', 'reflection', 'decision', 'summary', 112 | 'validation', 'exploration', 'hypothesis', 'correction', 'planning', 113 | ]; 114 | 115 | for (const purpose of purposes) { 116 | const step = createBaseStep({ purpose }); 117 | const output = formatter.formatStepConsole(step); 118 | expect(output).toContain(`[Step 1/3] ${purpose.toUpperCase()}`); 119 | } 120 | }); 121 | 122 | it('should handle custom purpose types', () => { 123 | const formatter = new CrashFormatter(false); 124 | const step = createBaseStep({ purpose: 'custom-purpose' }); 125 | const output = formatter.formatStepConsole(step); 126 | 127 | expect(output).toContain('[Step 1/3] CUSTOM-PURPOSE'); 128 | }); 129 | }); 130 | 131 | describe('formatStepMarkdown', () => { 132 | it('should format step as markdown', () => { 133 | const formatter = new CrashFormatter(false); 134 | const step = createBaseStep(); 135 | const output = formatter.formatStepMarkdown(step); 136 | 137 | expect(output).toContain('### Step 1/3: ANALYSIS'); 138 | expect(output).toContain('**Context:** Test context'); 139 | expect(output).toContain('**Thought:** Test thought'); 140 | expect(output).toContain('**Outcome:** Test outcome'); 141 | expect(output).toContain('**Next Action:** Test next action'); 142 | expect(output).toContain('*Rationale:* Test rationale'); 143 | expect(output).toContain('---'); 144 | }); 145 | 146 | it('should include confidence badge', () => { 147 | const formatter = new CrashFormatter(false); 148 | const step = createBaseStep({ confidence: 0.75 }); 149 | const output = formatter.formatStepMarkdown(step); 150 | 151 | expect(output).toContain('![Confidence]'); 152 | expect(output).toContain('75%'); 153 | }); 154 | 155 | it('should include revision badge', () => { 156 | const formatter = new CrashFormatter(false); 157 | const step = createBaseStep({ revises_step: 2 }); 158 | const output = formatter.formatStepMarkdown(step); 159 | 160 | expect(output).toContain('![Revises]'); 161 | expect(output).toContain('step%202'); 162 | }); 163 | 164 | it('should include branch badge', () => { 165 | const formatter = new CrashFormatter(false); 166 | const step = createBaseStep({ branch_from: 3 }); 167 | const output = formatter.formatStepMarkdown(step); 168 | 169 | expect(output).toContain('![Branch]'); 170 | expect(output).toContain('from%203'); 171 | }); 172 | 173 | it('should show uncertainty as blockquote', () => { 174 | const formatter = new CrashFormatter(false); 175 | const step = createBaseStep({ 176 | uncertainty_notes: 'High uncertainty here', 177 | }); 178 | const output = formatter.formatStepMarkdown(step); 179 | 180 | expect(output).toContain('> ⚠️ **Uncertainty:** High uncertainty here'); 181 | }); 182 | 183 | it('should show revision reason as blockquote', () => { 184 | const formatter = new CrashFormatter(false); 185 | const step = createBaseStep({ 186 | revises_step: 1, 187 | revision_reason: 'Previous analysis was incorrect', 188 | }); 189 | const output = formatter.formatStepMarkdown(step); 190 | 191 | expect(output).toContain('> 🔄 **Revision Reason:** Previous analysis was incorrect'); 192 | }); 193 | 194 | it('should show tools used', () => { 195 | const formatter = new CrashFormatter(false); 196 | const step = createBaseStep({ 197 | tools_used: ['Bash', 'Read'], 198 | }); 199 | const output = formatter.formatStepMarkdown(step); 200 | 201 | expect(output).toContain('**Tools Used:** Bash, Read'); 202 | }); 203 | }); 204 | 205 | describe('formatStepJSON', () => { 206 | it('should return valid JSON', () => { 207 | const formatter = new CrashFormatter(false); 208 | const step = createBaseStep(); 209 | const output = formatter.formatStepJSON(step); 210 | 211 | const parsed = JSON.parse(output); 212 | expect(parsed.step_number).toBe(1); 213 | expect(parsed.estimated_total).toBe(3); 214 | expect(parsed.purpose).toBe('analysis'); 215 | }); 216 | 217 | it('should include all optional fields', () => { 218 | const formatter = new CrashFormatter(false); 219 | const step = createBaseStep({ 220 | confidence: 0.9, 221 | revises_step: 1, 222 | branch_from: 2, 223 | tools_used: ['Test'], 224 | }); 225 | const output = formatter.formatStepJSON(step); 226 | 227 | const parsed = JSON.parse(output); 228 | expect(parsed.confidence).toBe(0.9); 229 | expect(parsed.revises_step).toBe(1); 230 | expect(parsed.branch_from).toBe(2); 231 | expect(parsed.tools_used).toEqual(['Test']); 232 | }); 233 | }); 234 | 235 | describe('formatHistorySummary', () => { 236 | it('should show basic summary', () => { 237 | const formatter = new CrashFormatter(false); 238 | const history: CrashHistory = { 239 | steps: [createBaseStep()], 240 | completed: false, 241 | }; 242 | const output = formatter.formatHistorySummary(history); 243 | 244 | expect(output).toContain('CRASH Session Summary'); 245 | expect(output).toContain('Total Steps: 1'); 246 | expect(output).toContain('In Progress'); 247 | }); 248 | 249 | it('should show completed status', () => { 250 | const formatter = new CrashFormatter(false); 251 | const history: CrashHistory = { 252 | steps: [createBaseStep()], 253 | completed: true, 254 | }; 255 | const output = formatter.formatHistorySummary(history); 256 | 257 | expect(output).toContain('✓ Completed'); 258 | }); 259 | 260 | it('should show metadata when present', () => { 261 | const formatter = new CrashFormatter(false); 262 | const history: CrashHistory = { 263 | steps: [createBaseStep()], 264 | completed: false, 265 | metadata: { 266 | revisions_count: 2, 267 | branches_created: 1, 268 | total_duration_ms: 5000, 269 | tools_used: ['Read', 'Edit'], 270 | }, 271 | }; 272 | const output = formatter.formatHistorySummary(history); 273 | 274 | expect(output).toContain('Revisions: 2'); 275 | expect(output).toContain('Branches Created: 1'); 276 | expect(output).toContain('Duration: 5.00s'); 277 | expect(output).toContain('Tools Used: Read, Edit'); 278 | }); 279 | 280 | it('should show average confidence', () => { 281 | const formatter = new CrashFormatter(false); 282 | const history: CrashHistory = { 283 | steps: [ 284 | createBaseStep({ confidence: 0.8 }), 285 | createBaseStep({ step_number: 2, confidence: 0.6 }), 286 | ], 287 | completed: false, 288 | }; 289 | const output = formatter.formatHistorySummary(history); 290 | 291 | expect(output).toContain('Average Confidence: 70%'); 292 | }); 293 | 294 | it('should show branches when present', () => { 295 | const formatter = new CrashFormatter(false); 296 | const history: CrashHistory = { 297 | steps: [createBaseStep()], 298 | completed: false, 299 | branches: [ 300 | { 301 | id: 'branch-1', 302 | name: 'Alternative A', 303 | from_step: 1, 304 | steps: [createBaseStep({ step_number: 2 })], 305 | status: 'active', 306 | created_at: new Date().toISOString(), 307 | depth: 1, 308 | }, 309 | ], 310 | }; 311 | const output = formatter.formatHistorySummary(history); 312 | 313 | expect(output).toContain('Branches:'); 314 | expect(output).toContain('● Alternative A (1 steps)'); 315 | }); 316 | 317 | it('should show different branch status symbols', () => { 318 | const formatter = new CrashFormatter(false); 319 | const history: CrashHistory = { 320 | steps: [], 321 | completed: false, 322 | branches: [ 323 | { 324 | id: 'active', 325 | name: 'Active Branch', 326 | from_step: 1, 327 | steps: [], 328 | status: 'active', 329 | created_at: '', 330 | depth: 1, 331 | }, 332 | { 333 | id: 'merged', 334 | name: 'Merged Branch', 335 | from_step: 1, 336 | steps: [], 337 | status: 'merged', 338 | created_at: '', 339 | depth: 1, 340 | }, 341 | { 342 | id: 'abandoned', 343 | name: 'Abandoned Branch', 344 | from_step: 1, 345 | steps: [], 346 | status: 'abandoned', 347 | created_at: '', 348 | depth: 1, 349 | }, 350 | ], 351 | }; 352 | const output = formatter.formatHistorySummary(history); 353 | 354 | expect(output).toContain('● Active Branch'); 355 | expect(output).toContain('✓ Merged Branch'); 356 | expect(output).toContain('✗ Abandoned Branch'); 357 | }); 358 | }); 359 | 360 | describe('formatBranchTree', () => { 361 | it('should show main steps', () => { 362 | const formatter = new CrashFormatter(false); 363 | const history: CrashHistory = { 364 | steps: [ 365 | createBaseStep({ step_number: 1, purpose: 'analysis' }), 366 | createBaseStep({ step_number: 2, purpose: 'action' }), 367 | ], 368 | completed: false, 369 | }; 370 | const output = formatter.formatBranchTree(history); 371 | 372 | expect(output).toContain('Branch Structure:'); 373 | expect(output).toContain('Main:'); 374 | expect(output).toContain('Step 1: analysis'); 375 | expect(output).toContain('Step 2: action'); 376 | }); 377 | 378 | it('should show branches from steps', () => { 379 | const formatter = new CrashFormatter(false); 380 | const branchStep = createBaseStep({ 381 | step_number: 2, 382 | purpose: 'exploration', 383 | branch_id: 'branch-1', 384 | }); 385 | const history: CrashHistory = { 386 | steps: [createBaseStep({ step_number: 1 }), branchStep], 387 | completed: false, 388 | branches: [ 389 | { 390 | id: 'branch-1', 391 | name: 'Alt approach', 392 | from_step: 1, 393 | steps: [branchStep], 394 | status: 'active', 395 | created_at: '', 396 | depth: 1, 397 | }, 398 | ], 399 | }; 400 | const output = formatter.formatBranchTree(history); 401 | 402 | expect(output).toContain('Branch: Alt approach'); 403 | }); 404 | }); 405 | 406 | describe('color handling', () => { 407 | it('should respect color disabled flag', () => { 408 | const formatter = new CrashFormatter(false); 409 | const step = createBaseStep(); 410 | const output = formatter.formatStepConsole(step); 411 | 412 | // When colors are disabled, no ANSI escape codes 413 | expect(output).not.toMatch(/\x1b\[/); 414 | expect(output).toContain('[Step 1/3] ANALYSIS'); 415 | }); 416 | 417 | it('should create formatter with color enabled flag', () => { 418 | // We can't reliably test color output in non-TTY environments 419 | // because chalk auto-detects and may disable colors 420 | // Instead, we test that the formatter accepts the flag 421 | const formatterWithColor = new CrashFormatter(true); 422 | const formatterNoColor = new CrashFormatter(false); 423 | 424 | const step = createBaseStep(); 425 | const outputWithColor = formatterWithColor.formatStepConsole(step); 426 | const outputNoColor = formatterNoColor.formatStepConsole(step); 427 | 428 | // Both should contain the content 429 | expect(outputWithColor).toContain('[Step 1/3]'); 430 | expect(outputNoColor).toContain('[Step 1/3]'); 431 | 432 | // The no-color output should definitely have no ANSI codes 433 | expect(outputNoColor).not.toMatch(/\x1b\[/); 434 | }); 435 | }); 436 | }); 437 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { CrashStep, CrashHistory, Branch, SessionEntry } from './types.js'; 2 | import { CrashConfig } from './config.js'; 3 | import { CrashFormatter } from './formatter.js'; 4 | import { 5 | VALID_PREFIXES, 6 | VALID_PURPOSES, 7 | VALID_PURPOSES_SET, 8 | COMPLETION_PHRASES_LOWER, 9 | REQUIRED_STEP_FIELDS, 10 | CONFIDENCE_MIN, 11 | CONFIDENCE_MAX, 12 | LOW_CONFIDENCE_THRESHOLD, 13 | SESSION_CLEANUP_INTERVAL, 14 | } from './constants.js'; 15 | 16 | export class CrashServer { 17 | private history: CrashHistory; 18 | private config: CrashConfig; 19 | private formatter: CrashFormatter; 20 | private startTime: number; 21 | private branches: Map = new Map(); 22 | private sessions: Map = new Map(); 23 | // Performance optimization: O(1) step lookup by number 24 | private stepIndex: Map = new Map(); 25 | // Performance optimization: cached step numbers for dependency validation 26 | private stepNumbers: Set = new Set(); 27 | // Performance optimization: tools used as Set for efficient deduplication 28 | private toolsUsedSet: Set = new Set(); 29 | // Performance optimization: O(1) step-to-branch lookup 30 | private stepToBranchMap: Map = new Map(); 31 | // Session cleanup counter for batched cleanup 32 | private stepsSinceCleanup: number = 0; 33 | 34 | constructor(config: CrashConfig) { 35 | this.config = config; 36 | this.formatter = new CrashFormatter(config.display.colorOutput); 37 | this.history = this.createNewHistory(); 38 | this.startTime = Date.now(); 39 | } 40 | 41 | public createNewHistory(): CrashHistory { 42 | return { 43 | steps: [], 44 | branches: [], 45 | completed: false, 46 | created_at: new Date().toISOString(), 47 | updated_at: new Date().toISOString(), 48 | metadata: { 49 | total_duration_ms: 0, 50 | revisions_count: 0, 51 | branches_created: 0, 52 | tools_used: [], 53 | } 54 | }; 55 | } 56 | 57 | public validateThoughtPrefix(thought: string): boolean { 58 | if (!this.config.validation.requireThoughtPrefix) { 59 | return true; // Skip validation if not required 60 | } 61 | return VALID_PREFIXES.some(prefix => thought.startsWith(prefix)); 62 | } 63 | 64 | public validateRationale(rationale: string): boolean { 65 | if (!this.config.validation.requireRationalePrefix) { 66 | return true; // Skip validation if not required 67 | } 68 | return rationale.startsWith('To '); 69 | } 70 | 71 | public validatePurpose(purpose: string): boolean { 72 | if (this.config.validation.allowCustomPurpose) { 73 | return true; // Any string is valid 74 | } 75 | // Use pre-computed Set for O(1) lookup instead of O(n) array includes 76 | return VALID_PURPOSES_SET.has(purpose.toLowerCase()); 77 | } 78 | 79 | public extractToolsUsed(step: CrashStep): string[] { 80 | const tools: string[] = []; 81 | 82 | // From explicit tools_used field 83 | if (step.tools_used) { 84 | tools.push(...step.tools_used); 85 | } 86 | 87 | // From structured action 88 | if (typeof step.next_action === 'object' && step.next_action.tool) { 89 | tools.push(step.next_action.tool); 90 | } 91 | 92 | return [...new Set(tools)]; // Remove duplicates 93 | } 94 | 95 | // Pre-computed timeout in milliseconds for performance 96 | private get sessionTimeoutMs(): number { 97 | return this.config.system.sessionTimeout * 60 * 1000; 98 | } 99 | 100 | public cleanupExpiredSessions(force: boolean = false): void { 101 | if (!this.config.features.enableSessions) return; 102 | 103 | // Batch cleanup: only run every SESSION_CLEANUP_INTERVAL steps unless forced 104 | this.stepsSinceCleanup++; 105 | if (!force && this.stepsSinceCleanup < SESSION_CLEANUP_INTERVAL) { 106 | return; 107 | } 108 | this.stepsSinceCleanup = 0; 109 | 110 | const now = Date.now(); 111 | const timeoutMs = this.sessionTimeoutMs; 112 | 113 | for (const [sessionId, entry] of this.sessions.entries()) { 114 | if (now - entry.lastAccessed > timeoutMs) { 115 | this.sessions.delete(sessionId); 116 | console.error(`🗑️ Session ${sessionId} expired and removed`); 117 | } 118 | } 119 | } 120 | 121 | public handleRevision(step: CrashStep): { valid: boolean; error?: string } { 122 | if (!step.revises_step || !this.config.features.enableRevisions) { 123 | return { valid: true }; 124 | } 125 | 126 | // Validate: cannot revise a future step 127 | if (step.revises_step >= step.step_number) { 128 | const error = `Cannot revise step ${step.revises_step} from step ${step.step_number}: can only revise earlier steps`; 129 | console.error(`⚠️ ${error}`); 130 | return { valid: false, error }; 131 | } 132 | 133 | // Use stepIndex for O(1) lookup instead of .find() 134 | const originalStep = this.stepIndex.get(step.revises_step); 135 | if (originalStep) { 136 | originalStep.revised_by = step.step_number; 137 | console.error(`📝 Revising step ${step.revises_step}: ${step.revision_reason || 'No reason provided'}`); 138 | } else { 139 | const availableSteps = Array.from(this.stepNumbers).sort((a, b) => a - b).join(', '); 140 | console.error(`⚠️ Warning: Cannot find step ${step.revises_step} to revise. Available steps: ${availableSteps || 'none'}`); 141 | } 142 | 143 | if (this.history.metadata) { 144 | this.history.metadata.revisions_count = (this.history.metadata.revisions_count || 0) + 1; 145 | } 146 | 147 | return { valid: true }; 148 | } 149 | 150 | // Memoization cache for branch depth calculations 151 | private branchDepthCache: Map = new Map(); 152 | 153 | /** 154 | * Calculate the depth of a branch starting from a given step. 155 | * Uses memoization for performance. 156 | * 157 | * Depth 1 = branching from main history 158 | * Depth 2 = branching from a depth-1 branch 159 | * etc. 160 | */ 161 | public calculateBranchDepth(stepNumber: number): number { 162 | // Check cache first 163 | if (this.branchDepthCache.has(stepNumber)) { 164 | return this.branchDepthCache.get(stepNumber)!; 165 | } 166 | 167 | // Use stepToBranchMap for O(1) lookup instead of O(n*m) loop 168 | const branchId = this.stepToBranchMap.get(stepNumber); 169 | if (branchId) { 170 | const branch = this.branches.get(branchId); 171 | if (branch) { 172 | // This step is in a branch, so new branch from here would be branch.depth + 1 173 | const depth = branch.depth + 1; 174 | this.branchDepthCache.set(stepNumber, depth); 175 | return depth; 176 | } 177 | } 178 | 179 | // Step is in main history (not in any branch), so branching from here is depth 1 180 | this.branchDepthCache.set(stepNumber, 1); 181 | return 1; 182 | } 183 | 184 | /** 185 | * Clear branch depth cache when branches are modified 186 | */ 187 | private invalidateBranchDepthCache(): void { 188 | this.branchDepthCache.clear(); 189 | } 190 | 191 | public handleBranching(step: CrashStep): { success: boolean; error?: string } { 192 | if (!step.branch_from || !this.config.features.enableBranching) { 193 | return { success: true }; 194 | } 195 | 196 | // Validate that step is not branching from itself 197 | if (step.branch_from === step.step_number) { 198 | const error = `Cannot branch from self (step ${step.step_number})`; 199 | console.error(`⚠️ ${error}`); 200 | return { success: false, error }; 201 | } 202 | 203 | // Validate that branch_from step exists 204 | if (!this.stepNumbers.has(step.branch_from)) { 205 | const availableSteps = Array.from(this.stepNumbers).sort((a, b) => a - b).join(', '); 206 | const error = `Cannot branch from step ${step.branch_from}: step does not exist. Available steps: ${availableSteps || 'none'}`; 207 | console.error(`⚠️ ${error}`); 208 | return { success: false, error }; 209 | } 210 | 211 | const branchId = step.branch_id || `branch-${Date.now()}`; 212 | const branchName = step.branch_name || `Alternative ${this.branches.size + 1}`; 213 | 214 | if (!this.branches.has(branchId)) { 215 | // Calculate depth and check against max 216 | const depth = this.calculateBranchDepth(step.branch_from); 217 | if (depth > this.config.system.maxBranchDepth) { 218 | const error = `Branch depth ${depth} exceeds maximum ${this.config.system.maxBranchDepth}. Max allowed: ${this.config.system.maxBranchDepth}`; 219 | console.error(`⚠️ ${error}`); 220 | return { success: false, error }; 221 | } 222 | 223 | const newBranch: Branch = { 224 | id: branchId, 225 | name: branchName, 226 | from_step: step.branch_from, 227 | steps: [], 228 | status: 'active', 229 | created_at: new Date().toISOString(), 230 | depth: depth 231 | }; 232 | 233 | this.branches.set(branchId, newBranch); 234 | // Invalidate depth cache since branch structure changed 235 | this.invalidateBranchDepthCache(); 236 | 237 | if (this.history.metadata) { 238 | this.history.metadata.branches_created = (this.history.metadata.branches_created || 0) + 1; 239 | } 240 | 241 | console.error(`🌿 Created branch "${branchName}" from step ${step.branch_from} (depth: ${depth})`); 242 | } 243 | 244 | // Add step to branch and update step-to-branch index 245 | const branch = this.branches.get(branchId); 246 | if (branch) { 247 | step.branch_id = branchId; 248 | branch.steps.push(step); 249 | this.stepToBranchMap.set(step.step_number, branchId); 250 | } 251 | 252 | return { success: true }; 253 | } 254 | 255 | public validateDependencies(step: CrashStep): { valid: boolean; missing: number[]; circular?: boolean } { 256 | if (!step.dependencies || step.dependencies.length === 0) { 257 | return { valid: true, missing: [] }; 258 | } 259 | 260 | // Check for self-dependency (simplest circular dependency) 261 | if (step.dependencies.includes(step.step_number)) { 262 | console.error(`⚠️ Circular dependency: step ${step.step_number} cannot depend on itself`); 263 | return { valid: false, missing: [], circular: true }; 264 | } 265 | 266 | // Check for dependencies on future steps (not circular, just invalid) 267 | const futureDeps = step.dependencies.filter(dep => dep >= step.step_number); 268 | if (futureDeps.length > 0) { 269 | console.error(`⚠️ Invalid dependencies: step ${step.step_number} cannot depend on future steps ${futureDeps.join(', ')}`); 270 | return { valid: false, missing: futureDeps, circular: false }; 271 | } 272 | 273 | // Use cached stepNumbers Set for O(1) lookups instead of rebuilding 274 | const missing = step.dependencies.filter(dep => !this.stepNumbers.has(dep)); 275 | 276 | if (missing.length > 0) { 277 | const availableSteps = Array.from(this.stepNumbers).sort((a, b) => a - b).join(', '); 278 | console.error(`⚠️ Missing dependencies: steps ${missing.join(', ')} not found. Available: ${availableSteps || 'none'}`); 279 | } 280 | 281 | return { valid: missing.length === 0, missing }; 282 | } 283 | 284 | private formatOutput(step: CrashStep): string { 285 | switch (this.config.display.outputFormat) { 286 | case 'json': 287 | return this.formatter.formatStepJSON(step); 288 | case 'markdown': 289 | return this.formatter.formatStepMarkdown(step); 290 | default: 291 | return this.formatter.formatStepConsole(step); 292 | } 293 | } 294 | 295 | /** 296 | * Validate that a string field is non-empty 297 | */ 298 | private isNonEmptyString(value: unknown): value is string { 299 | return typeof value === 'string' && value.trim().length > 0; 300 | } 301 | 302 | /** 303 | * Validate required fields with detailed error messages 304 | */ 305 | private validateRequiredFields(step: CrashStep): { valid: boolean; missing: string[] } { 306 | const missing: string[] = []; 307 | 308 | // Check step_number is a positive integer (not just truthy, since 0 would fail) 309 | if (typeof step.step_number !== 'number' || step.step_number < 1 || !Number.isInteger(step.step_number)) { 310 | missing.push('step_number (must be positive integer >= 1)'); 311 | } 312 | 313 | // Check estimated_total is a positive integer 314 | if (typeof step.estimated_total !== 'number' || step.estimated_total < 1 || !Number.isInteger(step.estimated_total)) { 315 | missing.push('estimated_total (must be positive integer >= 1)'); 316 | } 317 | 318 | // Check string fields are non-empty 319 | if (!this.isNonEmptyString(step.purpose)) missing.push('purpose'); 320 | if (!this.isNonEmptyString(step.context)) missing.push('context'); 321 | if (!this.isNonEmptyString(step.thought)) missing.push('thought'); 322 | if (!this.isNonEmptyString(step.outcome)) missing.push('outcome'); 323 | if (!this.isNonEmptyString(step.rationale)) missing.push('rationale'); 324 | 325 | // Check next_action is either non-empty string or valid object 326 | if (typeof step.next_action === 'string') { 327 | if (!this.isNonEmptyString(step.next_action)) { 328 | missing.push('next_action'); 329 | } 330 | } else if (typeof step.next_action === 'object' && step.next_action !== null) { 331 | if (!this.isNonEmptyString(step.next_action.action)) { 332 | missing.push('next_action.action'); 333 | } 334 | } else { 335 | missing.push('next_action'); 336 | } 337 | 338 | return { valid: missing.length === 0, missing }; 339 | } 340 | 341 | /** 342 | * Validate confidence is within bounds if provided 343 | */ 344 | private validateConfidence(step: CrashStep): { valid: boolean; error?: string } { 345 | if (step.confidence === undefined) { 346 | return { valid: true }; 347 | } 348 | 349 | if (typeof step.confidence !== 'number' || isNaN(step.confidence) || !isFinite(step.confidence)) { 350 | return { valid: false, error: `Confidence must be a finite number, got ${step.confidence}` }; 351 | } 352 | 353 | if (step.confidence < CONFIDENCE_MIN || step.confidence > CONFIDENCE_MAX) { 354 | return { 355 | valid: false, 356 | error: `Confidence ${step.confidence} out of bounds [${CONFIDENCE_MIN}, ${CONFIDENCE_MAX}]` 357 | }; 358 | } 359 | 360 | return { valid: true }; 361 | } 362 | 363 | /** 364 | * Trim history and clean up orphaned references 365 | */ 366 | private trimHistory(): void { 367 | if (this.history.steps.length <= this.config.system.maxHistorySize) { 368 | return; 369 | } 370 | 371 | const oldSteps = this.history.steps.slice(0, -this.config.system.maxHistorySize); 372 | const removedStepNumbers = new Set(oldSteps.map(s => s.step_number)); 373 | 374 | // Trim the steps array 375 | this.history.steps = this.history.steps.slice(-this.config.system.maxHistorySize); 376 | 377 | // Update stepIndex, stepNumbers, and stepToBranchMap caches 378 | for (const stepNum of removedStepNumbers) { 379 | this.stepIndex.delete(stepNum); 380 | this.stepNumbers.delete(stepNum); 381 | this.stepToBranchMap.delete(stepNum); 382 | } 383 | 384 | // Clean up branches that reference removed steps 385 | for (const [branchId, branch] of this.branches.entries()) { 386 | if (removedStepNumbers.has(branch.from_step)) { 387 | // Also clean up stepToBranchMap for all steps in the deleted branch 388 | for (const branchStep of branch.steps) { 389 | this.stepToBranchMap.delete(branchStep.step_number); 390 | } 391 | this.branches.delete(branchId); 392 | console.error(`🗑️ Branch "${branch.name}" removed (from_step ${branch.from_step} was trimmed)`); 393 | } 394 | } 395 | 396 | // Invalidate branch depth cache since structure may have changed 397 | this.invalidateBranchDepthCache(); 398 | 399 | console.error(`📋 History trimmed to ${this.config.system.maxHistorySize} steps (removed ${removedStepNumbers.size} old steps)`); 400 | } 401 | 402 | public async processStep(input: unknown): Promise<{ 403 | content: Array<{ type: string; text: string }>; 404 | isError?: boolean; 405 | }> { 406 | try { 407 | const step = input as CrashStep; 408 | const stepStartTime = Date.now(); 409 | 410 | // Validate required fields with detailed error messages 411 | const fieldValidation = this.validateRequiredFields(step); 412 | if (!fieldValidation.valid) { 413 | throw new Error(`Missing or invalid required fields: ${fieldValidation.missing.join(', ')}`); 414 | } 415 | 416 | // Validate confidence bounds 417 | const confidenceValidation = this.validateConfidence(step); 418 | if (!confidenceValidation.valid) { 419 | throw new Error(confidenceValidation.error); 420 | } 421 | 422 | // Apply strict mode if enabled 423 | if (this.config.validation.strictMode) { 424 | if (!this.validateThoughtPrefix(step.thought)) { 425 | throw new Error(`Thought must start with one of: ${VALID_PREFIXES.join(', ')} (strict mode)`); 426 | } 427 | if (!this.validateRationale(step.rationale)) { 428 | throw new Error('Rationale must start with "To " (strict mode)'); 429 | } 430 | if (!this.validatePurpose(step.purpose)) { 431 | throw new Error(`Invalid purpose "${step.purpose}". Valid: ${VALID_PURPOSES.join(', ')} (strict mode)`); 432 | } 433 | } else { 434 | // Flexible validation 435 | if (!this.validatePurpose(step.purpose)) { 436 | console.error(`⚠️ Using custom purpose: ${step.purpose}`); 437 | } 438 | } 439 | 440 | // Add timestamp 441 | step.timestamp = new Date().toISOString(); 442 | 443 | // Handle session management with batched cleanup 444 | if (step.session_id && this.config.features.enableSessions) { 445 | this.cleanupExpiredSessions(); // Uses batched cleanup internally 446 | 447 | if (!this.sessions.has(step.session_id)) { 448 | this.sessions.set(step.session_id, { 449 | history: this.createNewHistory(), 450 | lastAccessed: Date.now() 451 | }); 452 | } 453 | const sessionEntry = this.sessions.get(step.session_id)!; 454 | sessionEntry.lastAccessed = Date.now(); 455 | this.history = sessionEntry.history; 456 | 457 | // Rebuild indexes from session's history (fixes desync bug) 458 | this.stepIndex.clear(); 459 | this.stepNumbers.clear(); 460 | this.toolsUsedSet.clear(); 461 | this.stepToBranchMap.clear(); 462 | this.branchDepthCache.clear(); 463 | for (const existingStep of this.history.steps) { 464 | this.stepIndex.set(existingStep.step_number, existingStep); 465 | this.stepNumbers.add(existingStep.step_number); 466 | // Rebuild step-to-branch map if step is in a branch 467 | if (existingStep.branch_id) { 468 | this.stepToBranchMap.set(existingStep.step_number, existingStep.branch_id); 469 | } 470 | } 471 | if (this.history.metadata?.tools_used) { 472 | for (const tool of this.history.metadata.tools_used) { 473 | this.toolsUsedSet.add(tool); 474 | } 475 | } 476 | } 477 | 478 | // Validate dependencies (uses cached stepNumbers) 479 | const depValidation = this.validateDependencies(step); 480 | if (depValidation.circular) { 481 | throw new Error(`Circular dependency detected for step ${step.step_number}`); 482 | } 483 | if (!depValidation.valid) { 484 | console.error(`⚠️ Proceeding with missing dependencies: ${depValidation.missing.join(', ')}`); 485 | } 486 | 487 | // Check if completed - explicit flag takes priority 488 | if (step.is_final_step === true) { 489 | this.history.completed = true; 490 | } else { 491 | // Fallback to phrase detection using pre-computed lowercase phrases 492 | const thoughtLower = step.thought.toLowerCase(); 493 | if (COMPLETION_PHRASES_LOWER.some(phrase => thoughtLower.includes(phrase))) { 494 | this.history.completed = true; 495 | } 496 | } 497 | 498 | // Handle revision with validation 499 | const revisionResult = this.handleRevision(step); 500 | if (!revisionResult.valid) { 501 | throw new Error(revisionResult.error); 502 | } 503 | 504 | // Handle branching with depth validation 505 | const branchResult = this.handleBranching(step); 506 | if (!branchResult.success) { 507 | throw new Error(branchResult.error); 508 | } 509 | 510 | // Track tools used efficiently with Set 511 | const toolsUsed = this.extractToolsUsed(step); 512 | for (const tool of toolsUsed) { 513 | this.toolsUsedSet.add(tool); 514 | } 515 | if (this.history.metadata) { 516 | this.history.metadata.tools_used = Array.from(this.toolsUsedSet); 517 | } 518 | 519 | // Add to history and update indexes 520 | this.history.steps.push(step); 521 | this.stepIndex.set(step.step_number, step); 522 | this.stepNumbers.add(step.step_number); 523 | 524 | // Update metadata 525 | if (this.history.metadata) { 526 | step.duration_ms = Date.now() - stepStartTime; 527 | this.history.metadata.total_duration_ms = Date.now() - this.startTime; 528 | } 529 | this.history.updated_at = new Date().toISOString(); 530 | 531 | // Trim history if needed (with orphaned reference cleanup) 532 | this.trimHistory(); 533 | 534 | // Display formatted step 535 | const formattedOutput = this.formatOutput(step); 536 | console.error(formattedOutput); 537 | 538 | // Show confidence warning if low 539 | if (step.confidence !== undefined && step.confidence < LOW_CONFIDENCE_THRESHOLD) { 540 | console.error(`⚠️ Low confidence (${Math.round(step.confidence * 100)}%): ${step.uncertainty_notes || 'Consider verification'}`); 541 | } 542 | 543 | // Prepare response 544 | const responseData: any = { 545 | step_number: step.step_number, 546 | estimated_total: step.estimated_total, 547 | completed: this.history.completed, 548 | total_steps: this.history.steps.length, 549 | next_action: step.next_action, 550 | }; 551 | 552 | // Add optional response fields 553 | if (step.confidence !== undefined) { 554 | responseData.confidence = step.confidence; 555 | } 556 | if (step.revises_step) { 557 | responseData.revised_step = step.revises_step; 558 | } 559 | if (step.branch_id) { 560 | responseData.branch = { 561 | id: step.branch_id, 562 | name: step.branch_name, 563 | from: step.branch_from 564 | }; 565 | } 566 | 567 | return { 568 | content: [ 569 | { 570 | type: 'text', 571 | text: JSON.stringify(responseData, null, 2), 572 | }, 573 | ], 574 | }; 575 | } catch (error) { 576 | return { 577 | content: [ 578 | { 579 | type: 'text', 580 | text: JSON.stringify( 581 | { 582 | error: error instanceof Error ? error.message : String(error), 583 | status: 'failed', 584 | hint: this.config.validation.strictMode 585 | ? 'Strict mode is enabled. Set CRASH_STRICT_MODE=false for flexible validation.' 586 | : 'Check that all required fields are provided.', 587 | }, 588 | null, 589 | 2 590 | ), 591 | }, 592 | ], 593 | isError: true, 594 | }; 595 | } 596 | } 597 | 598 | public clearHistory(): void { 599 | this.history = this.createNewHistory(); 600 | this.branches.clear(); 601 | this.stepIndex.clear(); 602 | this.stepNumbers.clear(); 603 | this.toolsUsedSet.clear(); 604 | this.stepToBranchMap.clear(); 605 | this.branchDepthCache.clear(); 606 | this.stepsSinceCleanup = 0; 607 | this.startTime = Date.now(); 608 | console.error('🔄 CRASH history cleared'); 609 | } 610 | 611 | public getHistorySummary(): string { 612 | return this.formatter.formatHistorySummary(this.history); 613 | } 614 | 615 | public getBranchTree(): string { 616 | return this.formatter.formatBranchTree(this.history); 617 | } 618 | 619 | public exportHistory(format: 'json' | 'markdown' | 'text' = 'json'): string { 620 | // Derive branches array from Map (single source of truth) 621 | const exportableHistory: CrashHistory = { 622 | ...this.history, 623 | branches: Array.from(this.branches.values()), 624 | }; 625 | 626 | switch (format) { 627 | case 'markdown': 628 | return exportableHistory.steps.map(step => this.formatter.formatStepMarkdown(step)).join('\n\n'); 629 | case 'text': 630 | return exportableHistory.steps.map(step => this.formatter.formatStepConsole(step)).join('\n\n'); 631 | default: 632 | return JSON.stringify(exportableHistory, null, 2); 633 | } 634 | } 635 | 636 | // Getters for testing 637 | public getHistory(): CrashHistory { 638 | return this.history; 639 | } 640 | 641 | public getSessions(): Map { 642 | return this.sessions; 643 | } 644 | 645 | public getBranches(): Map { 646 | return this.branches; 647 | } 648 | 649 | public getConfig(): CrashConfig { 650 | return this.config; 651 | } 652 | } 653 | -------------------------------------------------------------------------------- /tests/server.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 2 | import { CrashServer } from '../src/server.js'; 3 | import { DEFAULT_CONFIG, CrashConfig } from '../src/config.js'; 4 | import { CrashStep } from '../src/types.js'; 5 | 6 | describe('CrashServer', () => { 7 | const createConfig = (overrides: Partial = {}): CrashConfig => ({ 8 | ...DEFAULT_CONFIG, 9 | display: { ...DEFAULT_CONFIG.display, colorOutput: false }, 10 | ...overrides, 11 | }); 12 | 13 | const createBaseStep = (overrides: Partial = {}): CrashStep => ({ 14 | step_number: 1, 15 | estimated_total: 3, 16 | purpose: 'analysis', 17 | context: 'Test context', 18 | thought: 'Test thought', 19 | outcome: 'Test outcome', 20 | next_action: 'Test next action', 21 | rationale: 'Test rationale', 22 | ...overrides, 23 | }); 24 | 25 | // Suppress console.error during tests 26 | beforeEach(() => { 27 | vi.spyOn(console, 'error').mockImplementation(() => {}); 28 | }); 29 | 30 | describe('constructor', () => { 31 | it('should create server with default config', () => { 32 | const server = new CrashServer(createConfig()); 33 | expect(server.getHistory()).toBeDefined(); 34 | expect(server.getHistory().steps).toEqual([]); 35 | expect(server.getHistory().completed).toBe(false); 36 | }); 37 | }); 38 | 39 | describe('validateThoughtPrefix', () => { 40 | it('should pass any thought when validation disabled', () => { 41 | const server = new CrashServer(createConfig({ 42 | validation: { ...DEFAULT_CONFIG.validation, requireThoughtPrefix: false } 43 | })); 44 | 45 | expect(server.validateThoughtPrefix('Any thought')).toBe(true); 46 | }); 47 | 48 | it('should validate valid prefixes when enabled', () => { 49 | const server = new CrashServer(createConfig({ 50 | validation: { ...DEFAULT_CONFIG.validation, requireThoughtPrefix: true } 51 | })); 52 | 53 | expect(server.validateThoughtPrefix('OK, I will analyze this')).toBe(true); 54 | expect(server.validateThoughtPrefix('But we need to consider')).toBe(true); 55 | expect(server.validateThoughtPrefix('Wait this is wrong')).toBe(true); 56 | expect(server.validateThoughtPrefix('Therefore the answer is')).toBe(true); 57 | expect(server.validateThoughtPrefix('I see the issue now. The problem is')).toBe(true); 58 | expect(server.validateThoughtPrefix('I have completed the task')).toBe(true); 59 | }); 60 | 61 | it('should reject invalid prefixes when enabled', () => { 62 | const server = new CrashServer(createConfig({ 63 | validation: { ...DEFAULT_CONFIG.validation, requireThoughtPrefix: true } 64 | })); 65 | 66 | expect(server.validateThoughtPrefix('This is my thought')).toBe(false); 67 | expect(server.validateThoughtPrefix('Let me think')).toBe(false); 68 | }); 69 | }); 70 | 71 | describe('validateRationale', () => { 72 | it('should pass any rationale when validation disabled', () => { 73 | const server = new CrashServer(createConfig({ 74 | validation: { ...DEFAULT_CONFIG.validation, requireRationalePrefix: false } 75 | })); 76 | 77 | expect(server.validateRationale('Any rationale')).toBe(true); 78 | }); 79 | 80 | it('should validate "To" prefix when enabled', () => { 81 | const server = new CrashServer(createConfig({ 82 | validation: { ...DEFAULT_CONFIG.validation, requireRationalePrefix: true } 83 | })); 84 | 85 | expect(server.validateRationale('To understand the problem')).toBe(true); 86 | expect(server.validateRationale('Because it is needed')).toBe(false); 87 | }); 88 | }); 89 | 90 | describe('validatePurpose', () => { 91 | it('should pass any purpose when custom allowed', () => { 92 | const server = new CrashServer(createConfig({ 93 | validation: { ...DEFAULT_CONFIG.validation, allowCustomPurpose: true } 94 | })); 95 | 96 | expect(server.validatePurpose('custom-purpose')).toBe(true); 97 | expect(server.validatePurpose('anything')).toBe(true); 98 | }); 99 | 100 | it('should validate standard purposes when custom not allowed', () => { 101 | const server = new CrashServer(createConfig({ 102 | validation: { ...DEFAULT_CONFIG.validation, allowCustomPurpose: false } 103 | })); 104 | 105 | const validPurposes = [ 106 | 'analysis', 'action', 'reflection', 'decision', 'summary', 107 | 'validation', 'exploration', 'hypothesis', 'correction', 'planning' 108 | ]; 109 | 110 | for (const purpose of validPurposes) { 111 | expect(server.validatePurpose(purpose)).toBe(true); 112 | } 113 | 114 | expect(server.validatePurpose('custom')).toBe(false); 115 | expect(server.validatePurpose('invalid')).toBe(false); 116 | }); 117 | 118 | it('should be case-insensitive', () => { 119 | const server = new CrashServer(createConfig({ 120 | validation: { ...DEFAULT_CONFIG.validation, allowCustomPurpose: false } 121 | })); 122 | 123 | expect(server.validatePurpose('ANALYSIS')).toBe(true); 124 | expect(server.validatePurpose('Analysis')).toBe(true); 125 | }); 126 | }); 127 | 128 | describe('extractToolsUsed', () => { 129 | it('should extract from tools_used field', () => { 130 | const server = new CrashServer(createConfig()); 131 | const step = createBaseStep({ tools_used: ['Read', 'Edit'] }); 132 | 133 | expect(server.extractToolsUsed(step)).toEqual(['Read', 'Edit']); 134 | }); 135 | 136 | it('should extract from structured action', () => { 137 | const server = new CrashServer(createConfig()); 138 | const step = createBaseStep({ 139 | next_action: { tool: 'Bash', action: 'Run command' } 140 | }); 141 | 142 | expect(server.extractToolsUsed(step)).toEqual(['Bash']); 143 | }); 144 | 145 | it('should combine and dedupe tools', () => { 146 | const server = new CrashServer(createConfig()); 147 | const step = createBaseStep({ 148 | tools_used: ['Read', 'Edit'], 149 | next_action: { tool: 'Read', action: 'Read file' } 150 | }); 151 | 152 | expect(server.extractToolsUsed(step)).toEqual(['Read', 'Edit']); 153 | }); 154 | 155 | it('should return empty array when no tools', () => { 156 | const server = new CrashServer(createConfig()); 157 | const step = createBaseStep(); 158 | 159 | expect(server.extractToolsUsed(step)).toEqual([]); 160 | }); 161 | }); 162 | 163 | describe('validateDependencies', () => { 164 | it('should pass when no dependencies', () => { 165 | const server = new CrashServer(createConfig()); 166 | 167 | expect(server.validateDependencies(createBaseStep())).toEqual({ 168 | valid: true, 169 | missing: [] 170 | }); 171 | }); 172 | 173 | it('should detect missing dependencies', () => { 174 | const server = new CrashServer(createConfig()); 175 | // Use step_number: 10 to avoid circular/future dependency detection 176 | const step = createBaseStep({ step_number: 10, dependencies: [1, 2, 3] }); 177 | 178 | const result = server.validateDependencies(step); 179 | expect(result.valid).toBe(false); 180 | expect(result.missing).toEqual([1, 2, 3]); 181 | }); 182 | 183 | it('should detect self-dependency as circular', () => { 184 | const server = new CrashServer(createConfig()); 185 | const step = createBaseStep({ step_number: 1, dependencies: [1] }); 186 | 187 | const result = server.validateDependencies(step); 188 | expect(result.valid).toBe(false); 189 | expect(result.circular).toBe(true); 190 | }); 191 | 192 | it('should detect future dependencies as invalid (not circular)', () => { 193 | const server = new CrashServer(createConfig()); 194 | const step = createBaseStep({ step_number: 1, dependencies: [5, 10] }); 195 | 196 | const result = server.validateDependencies(step); 197 | expect(result.valid).toBe(false); 198 | // Future dependencies are invalid but NOT circular (semantic correction) 199 | expect(result.circular).toBe(false); 200 | }); 201 | 202 | it('should pass when all dependencies exist', async () => { 203 | const server = new CrashServer(createConfig()); 204 | 205 | // Add steps 1 and 2 206 | await server.processStep(createBaseStep({ step_number: 1 })); 207 | await server.processStep(createBaseStep({ step_number: 2 })); 208 | 209 | const step = createBaseStep({ step_number: 3, dependencies: [1, 2] }); 210 | const result = server.validateDependencies(step); 211 | 212 | expect(result.valid).toBe(true); 213 | expect(result.missing).toEqual([]); 214 | }); 215 | }); 216 | 217 | describe('processStep', () => { 218 | it('should process valid step', async () => { 219 | const server = new CrashServer(createConfig()); 220 | const step = createBaseStep(); 221 | 222 | const result = await server.processStep(step); 223 | 224 | expect(result.isError).toBeUndefined(); 225 | const response = JSON.parse(result.content[0].text); 226 | expect(response.step_number).toBe(1); 227 | expect(response.total_steps).toBe(1); 228 | expect(response.completed).toBe(false); 229 | }); 230 | 231 | it('should reject step missing required fields', async () => { 232 | const server = new CrashServer(createConfig()); 233 | 234 | const result = await server.processStep({ 235 | step_number: 1, 236 | // Missing other required fields 237 | }); 238 | 239 | expect(result.isError).toBe(true); 240 | const response = JSON.parse(result.content[0].text); 241 | // Updated: now includes "or invalid" and specific field names 242 | expect(response.error).toContain('Missing or invalid required fields'); 243 | }); 244 | 245 | it('should reject invalid thought in strict mode', async () => { 246 | const server = new CrashServer(createConfig({ 247 | validation: { 248 | ...DEFAULT_CONFIG.validation, 249 | strictMode: true, 250 | requireThoughtPrefix: true, 251 | } 252 | })); 253 | 254 | const step = createBaseStep({ thought: 'Invalid thought' }); 255 | const result = await server.processStep(step); 256 | 257 | expect(result.isError).toBe(true); 258 | const response = JSON.parse(result.content[0].text); 259 | expect(response.error).toContain('strict mode'); 260 | }); 261 | 262 | it('should mark completion via is_final_step flag', async () => { 263 | const server = new CrashServer(createConfig()); 264 | const step = createBaseStep({ is_final_step: true }); 265 | 266 | await server.processStep(step); 267 | 268 | expect(server.getHistory().completed).toBe(true); 269 | }); 270 | 271 | it('should mark completion via phrase detection', async () => { 272 | const server = new CrashServer(createConfig()); 273 | const step = createBaseStep({ 274 | thought: 'I have completed the analysis and found the solution' 275 | }); 276 | 277 | await server.processStep(step); 278 | 279 | expect(server.getHistory().completed).toBe(true); 280 | }); 281 | 282 | it('should include confidence in response', async () => { 283 | const server = new CrashServer(createConfig()); 284 | const step = createBaseStep({ confidence: 0.85 }); 285 | 286 | const result = await server.processStep(step); 287 | const response = JSON.parse(result.content[0].text); 288 | 289 | expect(response.confidence).toBe(0.85); 290 | }); 291 | 292 | it('should reject confidence above 1', async () => { 293 | const server = new CrashServer(createConfig()); 294 | const step = createBaseStep({ confidence: 1.5 }); 295 | 296 | const result = await server.processStep(step); 297 | 298 | expect(result.isError).toBe(true); 299 | const response = JSON.parse(result.content[0].text); 300 | expect(response.error).toContain('out of bounds'); 301 | }); 302 | 303 | it('should reject confidence below 0', async () => { 304 | const server = new CrashServer(createConfig()); 305 | const step = createBaseStep({ confidence: -0.5 }); 306 | 307 | const result = await server.processStep(step); 308 | 309 | expect(result.isError).toBe(true); 310 | const response = JSON.parse(result.content[0].text); 311 | expect(response.error).toContain('out of bounds'); 312 | }); 313 | 314 | it('should accept confidence at boundaries', async () => { 315 | const server = new CrashServer(createConfig()); 316 | 317 | const result0 = await server.processStep(createBaseStep({ step_number: 1, confidence: 0 })); 318 | expect(result0.isError).toBeUndefined(); 319 | 320 | const result1 = await server.processStep(createBaseStep({ step_number: 2, confidence: 1 })); 321 | expect(result1.isError).toBeUndefined(); 322 | }); 323 | 324 | it('should reject empty string fields', async () => { 325 | const server = new CrashServer(createConfig()); 326 | 327 | const result = await server.processStep({ 328 | step_number: 1, 329 | estimated_total: 3, 330 | purpose: 'analysis', 331 | context: ' ', // Whitespace only 332 | thought: 'Test thought', 333 | outcome: 'Test outcome', 334 | next_action: 'Test action', 335 | rationale: 'Test rationale', 336 | }); 337 | 338 | expect(result.isError).toBe(true); 339 | const response = JSON.parse(result.content[0].text); 340 | expect(response.error).toContain('context'); 341 | }); 342 | 343 | it('should reject step_number of 0', async () => { 344 | const server = new CrashServer(createConfig()); 345 | 346 | const result = await server.processStep(createBaseStep({ step_number: 0 })); 347 | 348 | expect(result.isError).toBe(true); 349 | const response = JSON.parse(result.content[0].text); 350 | expect(response.error).toContain('step_number'); 351 | }); 352 | 353 | it('should trim history when exceeding max size', async () => { 354 | const server = new CrashServer(createConfig({ 355 | system: { ...DEFAULT_CONFIG.system, maxHistorySize: 3 } 356 | })); 357 | 358 | for (let i = 1; i <= 5; i++) { 359 | await server.processStep(createBaseStep({ step_number: i })); 360 | } 361 | 362 | expect(server.getHistory().steps.length).toBe(3); 363 | expect(server.getHistory().steps[0].step_number).toBe(3); 364 | }); 365 | }); 366 | 367 | describe('revision handling', () => { 368 | it('should mark original step as revised', async () => { 369 | const server = new CrashServer(createConfig()); 370 | 371 | // Add original step 372 | await server.processStep(createBaseStep({ step_number: 1 })); 373 | 374 | // Revise it 375 | await server.processStep(createBaseStep({ 376 | step_number: 2, 377 | revises_step: 1, 378 | revision_reason: 'Found error' 379 | })); 380 | 381 | const history = server.getHistory(); 382 | const originalStep = history.steps.find(s => s.step_number === 1); 383 | 384 | expect(originalStep?.revised_by).toBe(2); 385 | expect(history.metadata?.revisions_count).toBe(1); 386 | }); 387 | 388 | it('should handle revision of non-existent step gracefully', async () => { 389 | const server = new CrashServer(createConfig()); 390 | 391 | // First add a step so we can attempt to revise an earlier (non-existent) step 392 | await server.processStep(createBaseStep({ step_number: 1 })); 393 | 394 | // Try to revise step 0 which doesn't exist (but is earlier than step 2) 395 | // Note: This should warn but not throw 396 | await server.processStep(createBaseStep({ 397 | step_number: 2, 398 | revises_step: 0, // Doesn't exist, but is not a "future" step 399 | revision_reason: 'Invalid revision' 400 | })); 401 | 402 | // Should not throw, step should be added 403 | expect(server.getHistory().steps.length).toBe(2); 404 | }); 405 | 406 | it('should reject revision of future step', async () => { 407 | const server = new CrashServer(createConfig()); 408 | 409 | const result = await server.processStep(createBaseStep({ 410 | step_number: 1, 411 | revises_step: 999, // Future step - should fail 412 | revision_reason: 'Invalid revision' 413 | })); 414 | 415 | expect(result.isError).toBe(true); 416 | const response = JSON.parse(result.content[0].text); 417 | expect(response.error).toContain('can only revise earlier steps'); 418 | }); 419 | 420 | it('should not handle revisions when disabled', async () => { 421 | const server = new CrashServer(createConfig({ 422 | features: { ...DEFAULT_CONFIG.features, enableRevisions: false } 423 | })); 424 | 425 | await server.processStep(createBaseStep({ step_number: 1 })); 426 | await server.processStep(createBaseStep({ 427 | step_number: 2, 428 | revises_step: 1 429 | })); 430 | 431 | const originalStep = server.getHistory().steps.find(s => s.step_number === 1); 432 | expect(originalStep?.revised_by).toBeUndefined(); 433 | }); 434 | }); 435 | 436 | describe('branching', () => { 437 | it('should create branch from step', async () => { 438 | const server = new CrashServer(createConfig()); 439 | 440 | await server.processStep(createBaseStep({ step_number: 1 })); 441 | await server.processStep(createBaseStep({ 442 | step_number: 2, 443 | branch_from: 1, 444 | branch_id: 'alt-1', 445 | branch_name: 'Alternative approach' 446 | })); 447 | 448 | const branches = server.getBranches(); 449 | expect(branches.size).toBe(1); 450 | expect(branches.get('alt-1')?.name).toBe('Alternative approach'); 451 | expect(branches.get('alt-1')?.from_step).toBe(1); 452 | }); 453 | 454 | it('should reject branch exceeding max depth', async () => { 455 | const server = new CrashServer(createConfig({ 456 | system: { ...DEFAULT_CONFIG.system, maxBranchDepth: 1 } 457 | })); 458 | 459 | // Create first branch (depth 1 - should work) 460 | await server.processStep(createBaseStep({ step_number: 1 })); 461 | const result1 = await server.processStep(createBaseStep({ 462 | step_number: 2, 463 | branch_from: 1, 464 | branch_id: 'branch-1' 465 | })); 466 | 467 | expect(result1.isError).toBeUndefined(); 468 | 469 | // Try to branch from the branch (depth 2 - should fail) 470 | // Note: The depth calculation depends on the branch structure 471 | // Since we have maxBranchDepth: 1, any branch with depth > 1 should fail 472 | }); 473 | 474 | it('should auto-generate branch id and name', async () => { 475 | const server = new CrashServer(createConfig()); 476 | 477 | await server.processStep(createBaseStep({ step_number: 1 })); 478 | await server.processStep(createBaseStep({ 479 | step_number: 2, 480 | branch_from: 1 481 | })); 482 | 483 | const branches = server.getBranches(); 484 | expect(branches.size).toBe(1); 485 | 486 | const [branchId, branch] = [...branches.entries()][0]; 487 | expect(branchId).toMatch(/^branch-\d+$/); 488 | expect(branch.name).toBe('Alternative 1'); 489 | }); 490 | 491 | it('should not create branch when disabled', async () => { 492 | const server = new CrashServer(createConfig({ 493 | features: { ...DEFAULT_CONFIG.features, enableBranching: false } 494 | })); 495 | 496 | await server.processStep(createBaseStep({ step_number: 1 })); 497 | await server.processStep(createBaseStep({ 498 | step_number: 2, 499 | branch_from: 1 500 | })); 501 | 502 | expect(server.getBranches().size).toBe(0); 503 | }); 504 | 505 | it('should reject branching from non-existent step', async () => { 506 | const server = new CrashServer(createConfig()); 507 | 508 | const result = await server.processStep(createBaseStep({ 509 | step_number: 2, 510 | branch_from: 999 // Doesn't exist 511 | })); 512 | 513 | expect(result.isError).toBe(true); 514 | const response = JSON.parse(result.content[0].text); 515 | expect(response.error).toContain('does not exist'); 516 | }); 517 | }); 518 | 519 | describe('session management', () => { 520 | it('should create new session', async () => { 521 | const server = new CrashServer(createConfig({ 522 | features: { ...DEFAULT_CONFIG.features, enableSessions: true } 523 | })); 524 | 525 | await server.processStep(createBaseStep({ 526 | step_number: 1, 527 | session_id: 'test-session' 528 | })); 529 | 530 | const sessions = server.getSessions(); 531 | expect(sessions.has('test-session')).toBe(true); 532 | }); 533 | 534 | it('should maintain separate history per session', async () => { 535 | const server = new CrashServer(createConfig({ 536 | features: { ...DEFAULT_CONFIG.features, enableSessions: true } 537 | })); 538 | 539 | // Add to session 1 540 | await server.processStep(createBaseStep({ 541 | step_number: 1, 542 | session_id: 'session-1' 543 | })); 544 | 545 | // Add to session 2 546 | await server.processStep(createBaseStep({ 547 | step_number: 1, 548 | session_id: 'session-2' 549 | })); 550 | 551 | const sessions = server.getSessions(); 552 | expect(sessions.get('session-1')?.history.steps.length).toBe(1); 553 | expect(sessions.get('session-2')?.history.steps.length).toBe(1); 554 | }); 555 | 556 | it('should clean up expired sessions', async () => { 557 | const server = new CrashServer(createConfig({ 558 | features: { ...DEFAULT_CONFIG.features, enableSessions: true }, 559 | system: { ...DEFAULT_CONFIG.system, sessionTimeout: 1 } // 1 minute 560 | })); 561 | 562 | // Create a session 563 | await server.processStep(createBaseStep({ 564 | step_number: 1, 565 | session_id: 'old-session' 566 | })); 567 | 568 | // Manually expire the session 569 | const sessions = server.getSessions(); 570 | const oldSession = sessions.get('old-session')!; 571 | oldSession.lastAccessed = Date.now() - 120000; // 2 minutes ago 572 | 573 | // Force cleanup by calling cleanupExpiredSessions with force=true 574 | server.cleanupExpiredSessions(true); 575 | 576 | // Create a new session to verify the server still works 577 | await server.processStep(createBaseStep({ 578 | step_number: 1, 579 | session_id: 'new-session' 580 | })); 581 | 582 | expect(sessions.has('old-session')).toBe(false); 583 | expect(sessions.has('new-session')).toBe(true); 584 | }); 585 | 586 | it('should not create session when disabled', async () => { 587 | const server = new CrashServer(createConfig({ 588 | features: { ...DEFAULT_CONFIG.features, enableSessions: false } 589 | })); 590 | 591 | await server.processStep(createBaseStep({ 592 | step_number: 1, 593 | session_id: 'test-session' 594 | })); 595 | 596 | expect(server.getSessions().size).toBe(0); 597 | }); 598 | }); 599 | 600 | describe('tools tracking', () => { 601 | it('should track tools used in metadata', async () => { 602 | const server = new CrashServer(createConfig()); 603 | 604 | await server.processStep(createBaseStep({ 605 | step_number: 1, 606 | tools_used: ['Read', 'Grep'] 607 | })); 608 | 609 | await server.processStep(createBaseStep({ 610 | step_number: 2, 611 | tools_used: ['Edit', 'Read'] 612 | })); 613 | 614 | const toolsUsed = server.getHistory().metadata?.tools_used; 615 | expect(toolsUsed).toContain('Read'); 616 | expect(toolsUsed).toContain('Grep'); 617 | expect(toolsUsed).toContain('Edit'); 618 | expect(toolsUsed?.length).toBe(3); // Deduplicated 619 | }); 620 | }); 621 | 622 | describe('clearHistory', () => { 623 | it('should reset all state', async () => { 624 | const server = new CrashServer(createConfig()); 625 | 626 | await server.processStep(createBaseStep({ step_number: 1 })); 627 | await server.processStep(createBaseStep({ 628 | step_number: 2, 629 | branch_from: 1, 630 | branch_id: 'branch-1' 631 | })); 632 | 633 | server.clearHistory(); 634 | 635 | expect(server.getHistory().steps.length).toBe(0); 636 | expect(server.getHistory().completed).toBe(false); 637 | expect(server.getBranches().size).toBe(0); 638 | }); 639 | }); 640 | 641 | describe('exportHistory', () => { 642 | it('should export as JSON by default', async () => { 643 | const server = new CrashServer(createConfig()); 644 | await server.processStep(createBaseStep()); 645 | 646 | const exported = server.exportHistory(); 647 | const parsed = JSON.parse(exported); 648 | 649 | expect(parsed.steps.length).toBe(1); 650 | expect(parsed.completed).toBe(false); 651 | }); 652 | 653 | it('should export as markdown', async () => { 654 | const server = new CrashServer(createConfig()); 655 | await server.processStep(createBaseStep()); 656 | 657 | const exported = server.exportHistory('markdown'); 658 | 659 | expect(exported).toContain('### Step 1/3'); 660 | expect(exported).toContain('**Context:**'); 661 | }); 662 | 663 | it('should export as text', async () => { 664 | const server = new CrashServer(createConfig()); 665 | await server.processStep(createBaseStep()); 666 | 667 | const exported = server.exportHistory('text'); 668 | 669 | expect(exported).toContain('[Step 1/3]'); 670 | expect(exported).toContain('Context:'); 671 | }); 672 | }); 673 | 674 | describe('session index synchronization', () => { 675 | it('should rebuild indexes when returning to existing session', async () => { 676 | const server = new CrashServer(createConfig({ 677 | features: { ...DEFAULT_CONFIG.features, enableSessions: true } 678 | })); 679 | 680 | // Create session A with steps 1, 2, 3 681 | await server.processStep(createBaseStep({ step_number: 1, session_id: 'session-a' })); 682 | await server.processStep(createBaseStep({ step_number: 2, session_id: 'session-a' })); 683 | await server.processStep(createBaseStep({ step_number: 3, session_id: 'session-a' })); 684 | 685 | // Switch to session B 686 | await server.processStep(createBaseStep({ step_number: 1, session_id: 'session-b' })); 687 | 688 | // Return to session A and add step 4 with dependency on step 2 689 | // This should work because indexes should be rebuilt from session A's history 690 | const result = await server.processStep(createBaseStep({ 691 | step_number: 4, 692 | session_id: 'session-a', 693 | dependencies: [2] // Should find step 2 from session A 694 | })); 695 | 696 | expect(result.isError).toBeUndefined(); 697 | const sessions = server.getSessions(); 698 | const sessionA = sessions.get('session-a')!; 699 | expect(sessionA.history.steps.length).toBe(4); 700 | }); 701 | }); 702 | 703 | describe('confidence edge cases', () => { 704 | it('should reject Infinity confidence', async () => { 705 | const server = new CrashServer(createConfig()); 706 | 707 | const result = await server.processStep(createBaseStep({ 708 | step_number: 1, 709 | confidence: Infinity 710 | })); 711 | 712 | expect(result.isError).toBe(true); 713 | const response = JSON.parse(result.content[0].text); 714 | expect(response.error).toContain('finite number'); 715 | }); 716 | 717 | it('should reject -Infinity confidence', async () => { 718 | const server = new CrashServer(createConfig()); 719 | 720 | const result = await server.processStep(createBaseStep({ 721 | step_number: 1, 722 | confidence: -Infinity 723 | })); 724 | 725 | expect(result.isError).toBe(true); 726 | const response = JSON.parse(result.content[0].text); 727 | expect(response.error).toContain('finite number'); 728 | }); 729 | 730 | it('should reject NaN confidence', async () => { 731 | const server = new CrashServer(createConfig()); 732 | 733 | const result = await server.processStep(createBaseStep({ 734 | step_number: 1, 735 | confidence: NaN 736 | })); 737 | 738 | expect(result.isError).toBe(true); 739 | const response = JSON.parse(result.content[0].text); 740 | expect(response.error).toContain('finite number'); 741 | }); 742 | }); 743 | 744 | describe('branch self-reference', () => { 745 | it('should reject branching from self', async () => { 746 | const server = new CrashServer(createConfig()); 747 | 748 | // First add a step so step 1 exists 749 | await server.processStep(createBaseStep({ step_number: 1 })); 750 | 751 | // Try to branch from self (step 2 branching from step 2) 752 | const result = await server.processStep(createBaseStep({ 753 | step_number: 2, 754 | branch_from: 2, // Self-reference - should fail 755 | branch_name: 'Self-branch' 756 | })); 757 | 758 | expect(result.isError).toBe(true); 759 | const response = JSON.parse(result.content[0].text); 760 | expect(response.error).toContain('Cannot branch from self'); 761 | }); 762 | }); 763 | 764 | describe('integration - complex multi-feature step', () => { 765 | it('should handle step with revision, branching, confidence, and dependencies', async () => { 766 | const server = new CrashServer(createConfig()); 767 | 768 | // Setup: Create initial steps 769 | await server.processStep(createBaseStep({ step_number: 1 })); 770 | await server.processStep(createBaseStep({ step_number: 2 })); 771 | await server.processStep(createBaseStep({ step_number: 3 })); 772 | 773 | // Complex step: branches from step 2, revises step 1, has confidence, depends on step 3 774 | const result = await server.processStep(createBaseStep({ 775 | step_number: 4, 776 | branch_from: 2, 777 | branch_name: 'Alternative approach', 778 | revises_step: 1, 779 | revision_reason: 'Found better approach', 780 | confidence: 0.85, 781 | dependencies: [3], 782 | tools_used: ['Read', 'Grep', 'Edit'] 783 | })); 784 | 785 | expect(result.isError).toBeUndefined(); 786 | 787 | const response = JSON.parse(result.content[0].text); 788 | expect(response.step_number).toBe(4); 789 | expect(response.confidence).toBe(0.85); 790 | expect(response.revised_step).toBe(1); 791 | expect(response.branch).toBeDefined(); 792 | expect(response.branch.name).toBe('Alternative approach'); 793 | 794 | const history = server.getHistory(); 795 | expect(history.metadata?.revisions_count).toBe(1); 796 | expect(history.metadata?.branches_created).toBe(1); 797 | expect(history.metadata?.tools_used).toContain('Read'); 798 | expect(history.metadata?.tools_used).toContain('Grep'); 799 | expect(history.metadata?.tools_used).toContain('Edit'); 800 | }); 801 | }); 802 | }); 803 | --------------------------------------------------------------------------------