HEX
Server: Apache/2.4.57 (Unix) OpenSSL/1.0.2k-fips
System: Linux panel19.servidoresrapidos.net 3.10.0-1160.el7.x86_64 #1 SMP Mon Oct 19 16:18:59 UTC 2020 x86_64
User: pastorfi (1081)
PHP: 8.1.34
Disabled: NONE
Upload Files
File: //usr/local/softaculous/lib/ai/ai.php
<?php

if(!defined('SOFTACULOUS')){
	die('Hacking Attempt');
}

function ai(){
	global $user, $globals, $l, $theme, $softpanel, $error, $insid, $software, $soft;
	global $edited, $settings, $iscripts, $catwise, $scripts, $noheader;
	global $softpath, $custom_path;

	// The AI page embeds a large inline <script> that drives the live UI
	// (per-session thinking/unread dots, streaming guards, etc.). Make sure the
	// browser always fetches a fresh page + JSON API rather than a stale cached
	// copy, or the UI state machine would run against stale logic/data.
	if(!headers_sent()){
		header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
		header('Pragma: no-cache');
		header('Expires: 0');
	}

	if(version_compare(PHP_VERSION, '7.1', '<')){
		echo __('PHP 7.1 required to use this feature');
		die();
	}
	
	if(empty($globals['lictype']) || !empty($globals['licexpired'])){
		echo __('An active $0 license is required to use this feature!', array(APP));
		die();
	}

	$insid = GET('insid', '');
	$custom_path = GET('path', '');
	$project_id = GET('project_id', '');

	$username = $softpanel->user['name'];
	include_once(__DIR__.'/ai_launcher.php');
	require_once(__DIR__ . '/core/class_ai_file_handler.php');

	// Validate user-supplied identifiers before they reach file paths
	if(!empty($project_id) && !ai_is_valid_id($project_id)) $project_id = '';

	$api_sp = '';
	if(!empty($project_id)){
		ai_php_init_classes();
		require_once(__DIR__ . '/core/class_project.php');
		$_proj = AIProject::load($username, $project_id);
		if($_proj && !empty($_proj['path'])) $api_sp = $_proj['path'];
	}elseif(!empty($insid) && !empty($user['ins'][$insid])){
		$api_sp = $user['ins'][$insid]['softpath'];
	}elseif(!empty($custom_path)){
		$_hd = !empty($softpanel->user['homedir']) ? $softpanel->user['homedir'] : ai_get_homedir($username);
		$_cp = trim($custom_path);
		$_hd = cleanpath($_hd);
		$_cp = cleanpath($_cp);
		// Reject path traversal in the API branch as well (the page-render
		// branch validates at lines below, but the API branch was missing it)
		if(strpos($_cp, './') !== false || strpos($_cp, '../') !== false || strpos($_cp, '/..') !== false || strpos($_cp, '..') === 0){
			$api_sp = '';
		}else{
			if(strpos($_cp, $_hd) === 0) $_cp = substr($_cp, strlen($_hd) + 1);
			$api_sp = cleanpath($_hd . '/' . $_cp);
			// Ensure the resolved path is within the user's home directory
			if($api_sp !== $_hd && strpos($api_sp, $_hd . '/') !== 0){
				$api_sp = '';
			}
		}
	}

	if(optGET('ai_php_api')){
		// Release the PHP session lock before doing any work. The API
		// handlers do their own file-based locking (conversation-level
		// .lock files) so they don't need the PHP session lock held.
		if(session_status() === PHP_SESSION_ACTIVE){
			session_write_close();
		}
		ai_handle_php_api($username, $api_sp);
		die();
	}

	if(optGET('ai_chat_stream')){
		// Release the PHP session lock before entering the long-running
		// SSE stream. Without this, the session file lock blocks ALL other
		// requests from the same user (status polls, new prompts in other
		// conversations, etc.) for the entire duration of the stream.
		if(session_status() === PHP_SESSION_ACTIVE){
			session_write_close();
		}
		ai_php_init_classes();
		$content = !empty($_POST['content']) ? $_POST['content'] : '';
		$options = array();
		if(!empty($_POST['conversation_id']) && ai_is_valid_id($_POST['conversation_id'])) $options['conversation_id'] = $_POST['conversation_id'];
		if(!empty($_POST['attachments'])) $options['attachments'] = json_decode($_POST['attachments'], true) ? json_decode($_POST['attachments'], true) : array();
		ai_php_send_prompt_stream($username, $api_sp, $content, $options);
		die();
	}

	if(empty($insid) && empty($custom_path) && empty($project_id)){
		$theme['init_theme'] = 'ai';
		$theme['init_theme_name'] = 'AI Assistant';
		$theme['init_theme_func'] = array('ai_theme');
		$theme['call_theme_func'] = 'ai_theme';
		return true;
	}
	
	if(!empty($project_id)){
		include_once(__DIR__.'/ai_launcher.php');
		ai_php_init_classes();
		require_once(__DIR__ . '/core/class_project.php');
		$project = AIProject::load($softpanel->user['name'], $project_id);
		if($project && !empty($project['path'])){
			$custom_path = $project['path'];
			$insid = !empty($project['insid']) ? $project['insid'] : '';
		}
	}
	
	if(!empty($insid)){
		if(empty($user['ins'][$insid])){
			reporterror(__('Invalid Installation'), __('The installation ID is invalid or does not exist'));
			return false;
		}

		$data = $user['ins'][$insid];
		$soft = get_sid_by_version($data['ver'], $data['sid']);
		$software = !empty($iscripts[$soft]) ? $iscripts[$soft] : array('name' => 'Software');
		$softpath = $data['softpath'];
	}else{
		$home_dir = $softpanel->user['homedir'];
		$custom_path = trim($custom_path);
		
		$home_dir = cleanpath($home_dir);
		$custom_path = cleanpath($custom_path);
		
		if(empty($custom_path)){
			$custom_path = $home_dir;
		}
		
		if(strpos($custom_path, './') !== false || strpos($custom_path, '../') !== false || strpos($custom_path, '/..') !== false || strpos($custom_path, '..') === 0){
			reporterror(__('Invalid Path'), __('Path traversal is not allowed'));
			return false;
		}
		if(strpos($custom_path, $home_dir) === 0){
			$custom_path = substr($custom_path, strlen($home_dir) + 1);
		}
		$softpath = cleanpath($home_dir . '/' . $custom_path);
		if($softpath !== $home_dir && strpos($softpath, $home_dir . '/') !== 0){
			reporterror(__('Invalid Path'), __('The path must be within your home directory'));
			return false;
		}
		if(empty($softpath) || !@is_dir($softpath)){
			reporterror(__('Invalid Path'), __('The directory path is invalid or does not exist.'));
			return false;
		}
		$software = array('name' => basename($softpath));
		$insid = '';
	}

	$username = $softpanel->user['name'];

	include_once(__DIR__.'/ai_launcher.php');

	if(optGET('ai_chat_stream')){
		// Release the PHP session lock before the long-running SSE stream
		// so other requests from the same user are not blocked.
		if(session_status() === PHP_SESSION_ACTIVE){
			session_write_close();
		}
		ai_php_init_classes();
		$content = !empty($_POST['content']) ? $_POST['content'] : '';
		$options = array();
		if(!empty($_POST['conversation_id']) && ai_is_valid_id($_POST['conversation_id'])) $options['conversation_id'] = $_POST['conversation_id'];
		if(!empty($_POST['attachments'])) $options['attachments'] = json_decode($_POST['attachments'], true) ? json_decode($_POST['attachments'], true) : array();
		ai_php_send_prompt_stream($username, $softpath, $content, $options);
		die();
	}

	$theme['init_theme'] = 'ai';
	$theme['init_theme_name'] = 'AI Assistant';
	$theme['init_theme_func'] = array('ai_theme');
	$theme['call_theme_func'] = 'ai_theme';
}

/**
 * Returns true if the path contains traversal sequences ('./', '../', '/..').
 * Used to reject user-supplied path parameters before they reach file operations.
 */
function ai_has_path_traversal($path){
	$path = (string)$path;
	if(strpos($path, './') !== false) return true;
	if(strpos($path, '../') !== false) return true;
	if(strpos($path, '/..') !== false) return true;
	if(strpos($path, '..') === 0) return true;
	return false;
}

function ai_handle_php_api($username, $softpath){
	global $globals, $user, $softpanel, $iscripts;

	// Ensure the PHP session is not locked by this request. The session
	// data was already read before this function is called, and some API
	// actions (test_connection, ai_compact) can take seconds to complete.
	if(session_status() === PHP_SESSION_ACTIVE){
		session_write_close();
	}

	ai_php_init_classes();
	require_once(__DIR__ . '/core/class_project.php');

	$action = optGET('ai_php_api');
	header('Content-Type: application/json; charset='.$globals['charset']);
	// API responses carry session state (running, last_status, updated_at) that
	// the live UI polls every few seconds, so they must never be cached.
	if(!headers_sent()){
		header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
		header('Pragma: no-cache');
		header('Expires: 0');
	}

	$user_home_dir = !empty($softpanel->user['homedir']) ? $softpanel->user['homedir'] : ai_get_homedir($username);
	
	$needs_path = array('status', 'start', 'stop', 'file_tree', 'read_file', 'write_file', 'search', 'snapshot', 'snapshots', 'restore', 'diff', 'project_info', 'conversations', 'conversation', 'new_session', 'switch_conversation', 'delete_conversation', 'clear', 'set_mode', 'regenerate', 'edit_message', 'auto_title', 'abort', 'check_lock', 'force_unlock', 'undo', 'redo', 'fork', 'compact', 'ai_compact', 'rename_conversation', 'toggle_permission', 'shell', 'bootstrap_get', 'bootstrap_save', 'state_get', 'state_save', 'memory_get', 'memory_save');
	if(empty($softpath) && in_array($action, $needs_path)){
		echo json_encode(array('error' => __('No project path set')));
		die();
	}

	switch($action){
		case 'projects_list':
			$projects = AIProject::list_all($username);
			echo json_encode($projects);
			break;

		case 'projects_get':
			$project_id = isset($_GET['project_id']) ? $_GET['project_id'] : '';
			if(empty($project_id)){
			echo json_encode(array('error' => __('Project ID is required')));
			break;
		}
		if(!ai_is_valid_id($project_id)){
			echo json_encode(array('error' => __('Invalid Project ID')));
			break;
		}
		$project = AIProject::load($username, $project_id);
		if(!$project){
			echo json_encode(array('error' => __('Project not found')));
				break;
			}
			echo json_encode($project);
			break;

		case 'projects_create':
			$path = isset($_POST['path']) ? $_POST['path'] : '';
			$name = isset($_POST['name']) ? $_POST['name'] : '';
			$type = isset($_POST['type']) ? $_POST['type'] : 'custom';
			$insid = isset($_POST['insid']) ? $_POST['insid'] : '';

			$home_dir = $softpanel->user['homedir'];

			if(empty($path)){
				$path = $home_dir;
			}

			$path = trim($path);
			
			$home_dir = cleanpath($home_dir);
			$path = cleanpath($path);
			
			if(strpos($path, './') !== false || strpos($path, '../') !== false || strpos($path, '/..') !== false || strpos($path, '..') === 0){
				echo json_encode(array('error' => __('Path traversal is not allowed')));
				break;
			}
			if(strpos($path, $home_dir) === 0){
				$path = substr($path, strlen($home_dir) + 1);
			}
			$full_path = cleanpath($home_dir . '/' . $path);
			if($full_path !== $home_dir && strpos($full_path, $home_dir . '/') !== 0){
				echo json_encode(array('error' => __('The path must be within your home directory')));
				break;
			}
			if(empty($full_path) || !@is_dir($full_path)){
				echo json_encode(array('error' => __('The directory path is invalid or does not exist')));
				break;
			}

			if(!empty($insid)){
				$project_id = AIProject::create_from_installation($username, $insid, $full_path, $name, $type);
			} else {
				$project_id = AIProject::create_from_path($username, $full_path, $name);
			}
			$project = AIProject::load($username, $project_id);
			echo json_encode($project);
			break;

	case 'projects_update':
		$project_id = isset($_POST['project_id']) ? $_POST['project_id'] : '';
		$name = isset($_POST['name']) ? $_POST['name'] : '';
		if(empty($project_id)){
			echo json_encode(array('error' => __('Project ID is required')));
			break;
		}
		if(!ai_is_valid_id($project_id)){
			echo json_encode(array('error' => __('Invalid Project ID')));
			break;
		}
		$data = array();
			if(!empty($name)) $data['name'] = $name;
			AIProject::update($username, $project_id, $data);
			echo json_encode(AIProject::load($username, $project_id));
			break;

	case 'projects_close':
		$project_id = isset($_POST['project_id']) ? $_POST['project_id'] : '';
		if(empty($project_id)){
			echo json_encode(array('error' => __('Project ID is required')));
			break;
		}
		if(!ai_is_valid_id($project_id)){
			echo json_encode(array('error' => __('Invalid Project ID')));
			break;
		}
		AIProject::delete($username, $project_id);
		echo json_encode(array('success' => true));
		break;

	case 'projects_delete':
		$project_id = isset($_POST['project_id']) ? $_POST['project_id'] : '';
		if(empty($project_id)){
			echo json_encode(array('error' => __('Project ID is required')));
			break;
		}
		if(!ai_is_valid_id($project_id)){
			echo json_encode(array('error' => __('Invalid Project ID')));
			break;
		}
		$project = AIProject::load($username, $project_id);
			AIProject::delete($username, $project_id);
			// Delete all project-related data: session, conversations, context files
			if($project && !empty($project['path'])){
				$project_path = $project['path'];
				// Delete session file
				$session_file = AISession::get_session_file($username, $project_path);
				if(file_exists($session_file)){
					@unlink($session_file);
				}
				// Delete conversations directory and all files within it
				$conv_dir = AISession::get_conversations_dir($username, $project_path);
				if(is_dir($conv_dir)){
					$conv_files = glob($conv_dir . '/*');
					if(is_array($conv_files)){
						foreach($conv_files as $cf){
							if(is_file($cf)){
								@unlink($cf);
							}
						}
					}
					@rmdir($conv_dir);
				}
				// Delete context directory (bootstrap, state, memory files)
				$ctx_dir = ai_php_get_ai_ctx_dir($project_path, $username);
				if(!empty($ctx_dir) && is_dir($ctx_dir)){
					$ctx_files = glob($ctx_dir . '/*');
					if(is_array($ctx_files)){
						foreach($ctx_files as $cf){
							if(is_file($cf)){
								@unlink($cf);
							}
						}
					}
					@rmdir($ctx_dir);
				}
			}
			echo json_encode(array('success' => true));
			break;

		case 'projects_wordpress':
			$installations = array();
			if(!empty($user['ins'])){
				foreach($user['ins'] as $insid => $idata){
					$soft = get_sid_by_version($idata['ver'], $idata['sid']);
					$software = !empty($iscripts[$soft]) ? $iscripts[$soft] : array('name' => 'Software');
					$installations[] = array(
						'insid' => $insid,
						'name' => $software['name'],
						'path' => $idata['softpath'],
						'url' => !empty($idata['softurl']) ? $idata['softurl'] : ''
					);
				}
			}
			echo json_encode($installations);
			break;

		case 'status':
			$session = AISession::load($username, $softpath);
			if(!empty($session['active_conversation'])){
				$conv_dir = AISession::get_conversations_dir($username, $softpath);
				$conv_file = $conv_dir . '/' . $session['active_conversation'] . '.json.php';
				if(!file_exists($conv_file)){
					$found = false;
					require_once(__DIR__ . '/core/class_ai_file_handler.php');
					foreach(AIFileHandler::list_files($conv_dir, 'conv_*.json.php') as $f){
						$d = AIFileHandler::read($f);
						if(!empty($d['id']) && $d['id'] === $session['active_conversation']){
							$found = true;
							break;
						}
					}
					if(!$found){
						unset($session['active_conversation']);
						AISession::save($username, $softpath, $session);
					}
				}
				// Clean up orphaned .abort files: if there is no active lock for
				// the conversation, any remaining .abort file is stale and should
				// be removed. This handles the case where the streaming process
				// exited before it could clean up the abort signal.
				$active_lock = AISession::check_lock($username, $softpath);
				if(empty($active_lock['locked'])){
					$abort_file = $conv_dir . '/' . $session['active_conversation'] . '.abort';
					if(file_exists($abort_file)){
						@unlink($abort_file);
					}
				}
			}
			// Also sweep all .abort files in the conversations directory that
			// do not have a corresponding active lock (covers conversations
			// other than the active one).
			$conv_dir = AISession::get_conversations_dir($username, $softpath);
			$running_conversations = AISession::get_running_conversations($username, $softpath);
			foreach(glob($conv_dir . '/*.abort') as $abort_file){
				$conv_id = basename($abort_file, '.abort');
				if(!in_array($conv_id, $running_conversations)){
					@unlink($abort_file);
				}
			}
			$settings = new AISettings($username);
			$lock_status = AISession::check_lock($username, $softpath);
			$fav_models = $settings->get_favorite_models();
			$permissions = $settings->get_permissions();

			// Auto-detect model metadata from models.dev
			$models_dev = ai_get_models_dev_cache();
			$model_info = array();
			$providers_out = $settings->get_all_providers();
			foreach($providers_out as &$p){
				if(empty($p['models'])) continue;
				foreach($p['models'] as $mid => $mname){
					$key = ($p['id'] ?? '') . '/' . $mid;
					if(isset($models_dev[$key])){
						$model_info[$mid] = array(
							'context' => $models_dev[$key]['context'],
							'output' => $models_dev[$key]['output'],
							'reasoning' => $models_dev[$key]['reasoning'],
							'tool_call' => $models_dev[$key]['tool_call'],
							'caching' => ai_model_supports_caching($p['id'] ?? '', $mid)
						);
					}elseif(isset($models_dev[$mid])){
						$model_info[$mid] = array(
							'context' => $models_dev[$mid]['context'],
							'output' => $models_dev[$mid]['output'],
							'reasoning' => $models_dev[$mid]['reasoning'],
							'tool_call' => $models_dev[$mid]['tool_call'],
							'caching' => ai_model_supports_caching($p['id'] ?? '', $mid)
						);
					}
				}
			}
			unset($p);

			// Get all currently-generating conversation IDs so the frontend
			// can show per-conversation "thinking" indicators independently.
			$running_conversations = AISession::get_running_conversations($username, $softpath);

			echo json_encode(array(
				'session' => $session,
				'providers' => $providers_out,
				'lock' => $lock_status,
				'running_conversations' => $running_conversations,
				'favorite_models' => $fav_models,
				'permissions' => $permissions,
				'model_info' => $model_info
			));
			break;

		case 'start':
			$provider = isset($_POST['provider']) ? $_POST['provider'] : 'anthropic';
			$model = isset($_POST['model']) ? $_POST['model'] : '';
			$mode = isset($_POST['mode']) ? $_POST['mode'] : 'build';
			$variant = isset($_POST['variant']) ? $_POST['variant'] : '';
			$session = AISession::load($username, $softpath);
			if(empty($session)) $session = array();
			$session['provider'] = $provider;
			$session['model'] = $model;
			$session['mode'] = $mode;
			if(!empty($variant) && $variant !== 'default'){
				$session['variant'] = $variant;
			}else{
				unset($session['variant']);
			}
			AISession::save($username, $softpath, $session);
			echo json_encode(array('success' => true));
			break;

		case 'stop':
			$conv_dir = AISession::get_conversations_dir($username, $softpath);
			// Clean up all conversation files and their associated auxiliary files
			if(is_dir($conv_dir)){
				foreach(glob($conv_dir . '/conv_*.json.php') as $conv_file){
					$conv_id = basename($conv_file, '.json.php');
					AIConversation::delete($conv_file);
					@unlink($conv_dir . '/' . $conv_id . '.lock');
					@unlink($conv_dir . '/' . $conv_id . '.abort');
					@unlink($conv_dir . '/' . $conv_id . '.redo.json.php');
				}
			}
			AISession::delete($username, $softpath);
			echo json_encode(array('success' => true));
			break;

		case 'providers':
			$settings = new AISettings($username);
			echo json_encode($settings->get_all_providers());
			break;

		case 'models':
			$settings = new AISettings($username);
			echo json_encode($settings->get_all_models());
			break;

	case 'conversation':
		$conv_id = isset($_GET['conversation_id']) ? $_GET['conversation_id'] : (isset($_POST['conversation_id']) ? $_POST['conversation_id'] : AISession::get_active_conversation_id($username, $softpath));
		if(!ai_is_valid_id((string)$conv_id)){
			echo json_encode(array('error' => __('Invalid conversation ID')));
			break;
		}
		$conv_dir = AISession::get_conversations_dir($username, $softpath);
			$conv_file = $conv_dir . '/' . $conv_id . '.json.php';
			$conv = null;
			if(file_exists($conv_file)){
				$conv = AIConversation::load($conv_file);
			}
			if(!$conv){
				require_once(__DIR__ . '/core/class_ai_file_handler.php');
				foreach(AIFileHandler::list_files($conv_dir, 'conv_*.json.php') as $f){
					$d = AIFileHandler::read($f);
					if(!empty($d['id']) && $d['id'] === $conv_id){
						$conv = AIConversation::load($f);
						break;
					}
				}
			}
			echo json_encode($conv ? $conv->get_all() : array('messages' => array()));
			break;

		case 'conversations':
			$conv_dir = AISession::get_conversations_dir($username, $softpath);
			echo json_encode(AIConversation::list_for_project($conv_dir));
			break;

		case 'new_session':
			$conv_id = 'conv_' . substr(md5(uniqid(mt_rand(), true)), 0, 12);
			AISession::set_active_conversation($username, $softpath, $conv_id);
			$conv_dir = AISession::get_conversations_dir($username, $softpath);
			AIConversation::create($conv_dir . '/' . $conv_id . '.json.php', $softpath, $conv_id);
			echo json_encode(array('success' => true, 'id' => $conv_id));
			break;

	case 'switch_conversation':
		$conv_id = isset($_POST['conversation_id']) ? $_POST['conversation_id'] : '';
		if(!empty($conv_id)){
			if(!ai_is_valid_id($conv_id)){
				echo json_encode(array('error' => __('Invalid conversation ID')));
				break;
			}
			AISession::set_active_conversation($username, $softpath, $conv_id);
		}
		echo json_encode(array('success' => true));
		break;

	case 'delete_conversation':
		$conv_id = isset($_POST['conversation_id']) ? $_POST['conversation_id'] : '';
		if(!empty($conv_id)){
			if(!ai_is_valid_id($conv_id)){
				echo json_encode(array('error' => __('Invalid conversation ID')));
				break;
			}
			$conv_dir = AISession::get_conversations_dir($username, $softpath);
				$conv_file = $conv_dir . '/' . $conv_id . '.json.php';
				if(file_exists($conv_file)){
					AIConversation::delete($conv_file);
				}else{
					foreach(AIFileHandler::list_files($conv_dir, 'conv_*.json.php') as $f){
						$d = AIFileHandler::read($f);
						if(!empty($d['id']) && $d['id'] === $conv_id){
							AIConversation::delete($f);
							break;
						}
					}
				}
				// Signal any in-flight generation for this conversation to halt
				// immediately and drop its lock, so background work (title
				// generation, memory extraction, etc.) does not keep running — or
				// worse, resurrect this conversation via a late save().
				@unlink($conv_dir . '/' . $conv_id . '.lock');
				@unlink($conv_dir . '/' . $conv_id . '.abort');
				@unlink($conv_dir . '/' . $conv_id . '.redo.json.php');
				$session = AISession::load($username, $softpath);
				$session = $session ? $session : array();
				if(!empty($session['active_conversation']) && $session['active_conversation'] === $conv_id){
					unset($session['active_conversation']);
					AISession::save($username, $softpath, $session);
				}
			}
			echo json_encode(array('success' => true));
			break;

		case 'clear':
			$conv_id = AISession::get_active_conversation_id($username, $softpath);
			$conv_dir = AISession::get_conversations_dir($username, $softpath);
			$conv = AIConversation::load($conv_dir . '/' . $conv_id . '.json.php');
			if($conv){
				$conv->clear();
				$conv->save();
			}
			echo json_encode(array('success' => true));
			break;

		case 'set_mode':
			$mode = isset($_POST['mode']) ? $_POST['mode'] : 'build';
			$session = AISession::load($username, $softpath);
			$session = $session ? $session : array();
			$session['mode'] = $mode;
			AISession::save($username, $softpath, $session);
			$conv_id = AISession::get_active_conversation_id($username, $softpath);
			$conv_dir = AISession::get_conversations_dir($username, $softpath);
			$conv = AIConversation::load($conv_dir . '/' . $conv_id . '.json.php');
			if($conv){
				$conv->set_mode($mode);
				$conv->save();
			}
			echo json_encode(array('success' => true));
			break;

	case 'file_tree':
		$path = isset($_GET['path']) ? $_GET['path'] : '/';
		$depth = intval(isset($_GET['depth']) ? $_GET['depth'] : 3);
		if(ai_has_path_traversal($path)){
			echo json_encode(array('error' => __('Path traversal is not allowed')));
			break;
		}
		$fm = new AIFileManager($softpath, $user_home_dir);
		$result = $fm->list_directory($path, $depth);
		echo json_encode($result);
		break;

	case 'read_file':
		$path = isset($_GET['path']) ? $_GET['path'] : '';
		if(ai_has_path_traversal($path)){
			echo json_encode(array('error' => __('Path traversal is not allowed')));
			break;
		}
		$fm = new AIFileManager($softpath, $user_home_dir);
		echo json_encode($fm->read_file($path));
		break;

	case 'write_file':
		$path = isset($_POST['path']) ? $_POST['path'] : '';
		$content = isset($_POST['content']) ? $_POST['content'] : '';
		if(ai_has_path_traversal($path)){
			echo json_encode(array('error' => __('Path traversal is not allowed')));
			break;
		}
		$fm = new AIFileManager($softpath, $user_home_dir);
		echo json_encode($fm->write_file($path, $content, true));
		break;

	case 'search':
		$pattern = isset($_GET['pattern']) ? $_GET['pattern'] : '';
		$path = isset($_GET['path']) ? $_GET['path'] : '/';
		$ext = isset($_GET['include']) ? $_GET['include'] : '';
		if(ai_has_path_traversal($path)){
			echo json_encode(array('error' => __('Path traversal is not allowed')));
			break;
		}
		$fm = new AIFileManager($softpath, $user_home_dir);
		$exts = !empty($ext) ? array($ext) : array();
		echo json_encode($fm->search_in_files($pattern, $path, $exts));
		break;

		case 'snapshot':
			$message = isset($_POST['message']) ? $_POST['message'] : __('Snapshot at $0', array(date('Y-m-d H:i:s')));
			$sm = new AISnapshotManager($softpath, true, $user_home_dir);
			echo json_encode($sm->create_snapshot($message));
			break;

		case 'snapshots':
			$limit = intval(isset($_GET['limit']) ? $_GET['limit'] : 50);
			$sm = new AISnapshotManager($softpath, true, $user_home_dir);
			echo json_encode($sm->list_snapshots($limit));
			break;

		case 'restore':
			$id = isset($_POST['id']) ? $_POST['id'] : '';
			$sm = new AISnapshotManager($softpath, true, $user_home_dir);
			echo json_encode($sm->restore_snapshot($id));
			break;

		case 'diff':
			$sm = new AISnapshotManager($softpath, true, $user_home_dir);
			echo json_encode($sm->get_working_diff());
			break;

		case 'project_info':
			$ctx = new ProjectContext($softpath);
			echo json_encode(array(
				'type' => $ctx->detect_type(),
				'overview' => $ctx->get_overview(),
				'path' => $softpath
			));
			break;

		// ---- Project Context: bootstrap, state, memory ----
		// These files persist across sessions and are injected into the system
		// prompt by ai_php_build_system_prompt(). They are stored outside the
		// project directory (under ~/.softaculous/ai/context/{project_id}/) so
		// they don't pollute the user's codebase. These endpoints expose them
		// for editing in the UI.

		case 'bootstrap_get':
			echo json_encode(array('content' => ai_php_load_ctx_file($softpath, 'project-bootstrap.md', $username)));
			break;

		case 'bootstrap_save':
			$content = isset($_POST['content']) ? (string)$_POST['content'] : '';
			echo json_encode(array('success' => ai_php_save_ctx_file($softpath, 'project-bootstrap.md', $content, $username)));
			break;

		case 'bootstrap_clear':
			echo json_encode(array('success' => ai_php_delete_ctx_file($softpath, 'project-bootstrap.md', $username)));
			break;

		case 'state_get':
			echo json_encode(array('content' => ai_php_load_ctx_file($softpath, 'project-state.md', $username)));
			break;

		case 'state_save':
			$content = isset($_POST['content']) ? (string)$_POST['content'] : '';
			echo json_encode(array('success' => ai_php_save_ctx_file($softpath, 'project-state.md', $content, $username)));
			break;

		case 'state_clear':
			echo json_encode(array('success' => ai_php_delete_ctx_file($softpath, 'project-state.md', $username)));
			break;

		case 'memory_get':
			echo json_encode(array('content' => ai_php_load_ctx_file($softpath, 'project-memory.md', $username)));
			break;

		case 'memory_save':
			$content = isset($_POST['content']) ? (string)$_POST['content'] : '';
			echo json_encode(array('success' => ai_php_save_ctx_file($softpath, 'project-memory.md', $content, $username)));
			break;

		case 'memory_clear':
			echo json_encode(array('success' => ai_php_delete_ctx_file($softpath, 'project-memory.md', $username)));
			break;

		case 'settings_load':
			$settings = new AISettings($username);
			$providers = $settings->get_connected_providers();
			foreach($providers as &$p){
				if(!empty($p['api_key'])){
					$p['api_key_masked'] = substr($p['api_key'], 0, 8) . '...' . substr($p['api_key'], -4);
					unset($p['api_key']);
				}
			}
			$fav_models = $settings->get_favorite_models();
			$permissions = $settings->get_permissions();
			echo json_encode(array('providers' => $providers, 'favorite_models' => $fav_models, 'permissions' => $permissions));
			break;

		case 'settings_save':
			$provider_id = isset($_POST['provider_id']) ? $_POST['provider_id'] : '';
			$api_key = isset($_POST['api_key']) ? $_POST['api_key'] : '';
			$name = isset($_POST['name']) ? $_POST['name'] : '';
			$base_url = isset($_POST['base_url']) ? $_POST['base_url'] : '';
			$models_raw = isset($_POST['models']) ? $_POST['models'] : 'array()';
			$models = @json_decode($models_raw, true) ? @json_decode($models_raw, true) : array();

			$no_key_providers = array('ollama', 'opencode_zen');
			if(strpos($provider_id, 'custom:') === 0){
				$no_key_providers[] = $provider_id;
			}

			if(empty($provider_id) || (empty($api_key) && !in_array($provider_id, $no_key_providers))){
				echo json_encode(array('error' => __('Provider ID and API key are required')));
				break;
			}

			$config = array('api_key' => $api_key, 'connected_at' => time());
			if(!empty($name)) $config['name'] = $name;
			if(!empty($base_url)) $config['base_url'] = $base_url;
			if(!empty($models)) $config['models'] = $models;

			if(strpos($provider_id, 'custom:') === 0){
				if(empty($base_url)){
					echo json_encode(array('error' => __('Base URL is required for custom providers')));
					break;
				}
				if(empty($name)) $config['name'] = str_replace('custom:', '', $provider_id);
				if(empty($models)) $config['models'] = array('default' => __('Default Model'));
			}

			$settings = new AISettings($username);
			$settings->save_provider($provider_id, $config);
			echo json_encode(array('success' => true));
			break;

		case 'settings_delete':
			$provider_id = isset($_POST['provider_id']) ? $_POST['provider_id'] : '';
			$settings = new AISettings($username);
			$settings->delete_provider($provider_id);
			echo json_encode(array('success' => true));
			break;

		case 'test_connection':
			$provider_id = isset($_POST['provider_id']) ? $_POST['provider_id'] : '';
			$api_key = isset($_POST['api_key']) ? $_POST['api_key'] : '';
			$model = isset($_POST['model']) ? $_POST['model'] : '';
			$base_url = isset($_POST['base_url']) ? $_POST['base_url'] : '';
			$models_raw = isset($_POST['models']) ? $_POST['models'] : '';
			$models = $models_raw ? (@json_decode($models_raw, true) ?: array()) : array();

			$settings = new AISettings($username);
			$provider_config = $settings->get_provider_config($provider_id);
			$provider_config = $provider_config ? $provider_config : array();

			// Pull filter-managed api_key/base_url from the filter at runtime.
			// These are NEVER stored in the user's settings file.
			$filter_provider = function_exists('ai_php_get_filter_provider_config') ? ai_php_get_filter_provider_config($provider_id) : null;
			if(is_array($filter_provider)){
				$filter_auth_type = !empty($filter_provider['auth_type']) ? $filter_provider['auth_type'] : 'api_key';
				$filter_managed = ($filter_auth_type === 'none') || !empty($filter_provider['api_key']);
				if($filter_managed){
					if(!empty($filter_provider['api_key'])) $provider_config['api_key'] = (string)$filter_provider['api_key'];
					if(!empty($filter_provider['base_url'])) $provider_config['base_url'] = (string)$filter_provider['base_url'];
					if(!empty($filter_provider['models'])) $provider_config['models'] = $filter_provider['models'];
				}
			}

			// User-supplied values from the popup still win over filter values,
			// unless the provider is filter-managed (in which case the filter is authoritative).
			if(empty($filter_provider) || empty($filter_managed)){
				if(!empty($api_key)) $provider_config['api_key'] = $api_key;
				if(!empty($base_url)) $provider_config['base_url'] = $base_url;
			}
			if(!empty($models)) $provider_config['models'] = $models;

			$provider_instance = ai_php_get_provider_instance($provider_id, $provider_config);
			$test_model = !empty($model) ? $model : $provider_instance->get_default_model();
			if(empty($test_model)){
				if(!empty($provider_config['models'])){
					$test_model = array_key_first($provider_config['models']);
				}
				if(empty($test_model)){
					echo json_encode(array('error' => __('No model specified. Please add at least one model.')));
					break;
				}
			}
			$client = new AIClient($provider_instance, isset($provider_config['api_key']) ? $provider_config['api_key'] : '', $test_model, $provider_config);
			echo json_encode($client->test_connection());
			break;

		case 'edit_message':
			$msg_id = isset($_POST['message_id']) ? $_POST['message_id'] : '';
			$new_content = isset($_POST['content']) ? $_POST['content'] : '';
			if(empty($msg_id) || empty($new_content)){
				echo json_encode(array('error' => __('message_id and content are required')));
				break;
			}
			$conv_id = AISession::get_active_conversation_id($username, $softpath);
			$conv_dir = AISession::get_conversations_dir($username, $softpath);
			$conv = AIConversation::load($conv_dir . '/' . $conv_id . '.json.php');
			if($conv){
				echo json_encode(array('success' => true));
			}else{
				echo json_encode(array('error' => __('Conversation not found')));
			}
			break;

		case 'regenerate':
			$conv_id = AISession::get_active_conversation_id($username, $softpath);
			$conv_dir = AISession::get_conversations_dir($username, $softpath);
			$conv = AIConversation::load($conv_dir . '/' . $conv_id . '.json.php');
			if($conv){
				$last_asst = $conv->get_last_assistant_message_id();
				if($last_asst){
					$conv->truncate_after_message($last_asst);
					$conv->save();
				}
				echo json_encode(array('success' => true));
			}else{
				echo json_encode(array('error' => __('Conversation not found')));
			}
			break;

		case 'changes':
			$sm = new AISnapshotManager($softpath, true, $user_home_dir);
			$diff = $sm->get_working_diff();
			$snapshots = $sm->list_snapshots(20);
			echo json_encode(array('diff' => $diff, 'snapshots' => $snapshots));
			break;

	case 'resolve_file':
		$path = isset($_GET['path']) ? $_GET['path'] : '';
		if(ai_has_path_traversal($path)){
			echo json_encode(array('error' => __('Path traversal is not allowed')));
			break;
		}
		$fm = new AIFileManager($softpath, $user_home_dir);
		$result = $fm->read_file($path);
			if(!empty($result['error'])){
				echo json_encode(array('error' => $result['error']));
			}else{
				echo json_encode(array('content' => $result['content'], 'path' => $path));
			}
			break;

		case 'auto_title':
			$conv_id = AISession::get_active_conversation_id($username, $softpath);
			$conv_dir = AISession::get_conversations_dir($username, $softpath);
			$conv = AIConversation::load($conv_dir . '/' . $conv_id . '.json.php');
			if($conv){
				$msgs = $conv->get_messages();
				$title = '';
				$user_msg = '';
				$asst_msg = '';
				foreach($msgs as $m){
					if(($m['role'] ?? '') === 'user' && !empty($m['content']) && empty($user_msg)){
						$user_msg = $m['content'];
					}
					if(($m['role'] ?? '') === 'assistant' && !empty($m['content'])){
						foreach($m['parts'] ?? array() as $p){
							if(($p['type'] ?? '') === 'text' && !empty($p['text'])){
								$asst_msg .= $p['text'];
							}
						}
						if(empty($asst_msg)){
							$asst_msg = $m['content'] ?? '';
						}
						if(!empty($asst_msg)) break;
					}
				}

				// Try AI-generated title first
				if(!empty($user_msg)){
					$session = AISession::load($username, $softpath);
					if(!empty($session['provider'])){
						require_once(__DIR__ . '/ai_launcher.php');
						ai_php_init_classes();
						$settings = new AISettings($username);
						$provider_config = $settings->get_provider_config($session['provider']) ?: array();
						$provider_instance = ai_php_get_provider_instance($session['provider'], $provider_config);
						$api_key = $settings->get_api_key($session['provider']);
						if($provider_instance && $api_key){
							$model = !empty($session['model']) ? $session['model'] : $provider_instance->get_default_model();
							$client = new AIClient($provider_instance, $api_key, $model, $provider_config);
							$title_prompt = array(
								array('role' => 'system', 'content' => 'Generate a very short title (max 6 words) for a coding conversation. Respond with ONLY the title, nothing else. No quotes.'),
								array('role' => 'user', 'content' => 'User asked: ' . mb_substr($user_msg, 0, 300))
							);
							if(!empty($asst_msg)){
								$title_prompt[1]['content'] .= "\nAssistant responded about: " . mb_substr($asst_msg, 0, 200);
							}
							try{
								$result = $client->chat($title_prompt, array(), array('max_tokens' => 30, 'timeout' => 15));
								if(!empty($result['content'])){
									$title = trim($result['content']);
									$title = preg_replace('/^["\']|["\']$/', '', $title);
									$title = mb_substr($title, 0, 80);
								}
							}catch(\Exception $e){}
						}
					}
				}

				// Fallback to first message snippet
				if(empty($title)){
					foreach($msgs as $m){
						if(($m['role'] ?? '') === 'user' && !empty($m['content'])){
							$title = mb_substr($m['content'], 0, 60);
							break;
						}
					}
				}

			if($title && $conv->count_user_messages() <= 1 && !$conv->is_title_manual()){
				$conv->set_title($title, false);
				$conv->save();
			}
			echo json_encode(array('success' => true, 'title' => $title));
			}else{
				echo json_encode(array('error' => __('Conversation not found')));
			}
			break;

		case 'abort':
			$conv_id = AISession::get_active_conversation_id($username, $softpath);
			$conv_dir = AISession::get_conversations_dir($username, $softpath);
			$abort_file = $conv_dir . '/' . $conv_id . '.abort';
			@file_put_contents($abort_file, time());
			@chmod($abort_file, 0600);
			echo json_encode(array('success' => true));
			break;

		case 'check_lock':
			$lock_status = AISession::check_lock($username, $softpath);
			echo json_encode($lock_status);
			break;

		case 'force_unlock':
			$unlock_result = AISession::force_unlock($username, $softpath);
			echo json_encode($unlock_result);
			break;

		case 'fix_permissions':
			$result = AIFileHandler::fix_permissions($username);
			echo json_encode(array('success' => $result));
			break;

		case 'undo':
			$msg_id = isset($_POST['message_id']) ? $_POST['message_id'] : '';
			if(empty($msg_id)){
				echo json_encode(array('error' => __('message_id is required')));
				break;
			}
			$conv_id = AISession::get_active_conversation_id($username, $softpath);
			$conv_dir = AISession::get_conversations_dir($username, $softpath);
			$conv = AIConversation::load($conv_dir . '/' . $conv_id . '.json.php');
			if($conv){
				// Remove the target message and everything after it; save removed for redo
				$messages = $conv->get_messages();
				$removed = array();
				$new_messages = array();
				$found = false;
				foreach($messages as $m){
					if(!$found){
						if(($m['id'] ?? '') === $msg_id){
							$found = true;
							$removed[] = $m;
							continue;
						}
						$new_messages[] = $m;
					}else{
						$removed[] = $m;
					}
				}
				if(!$found){
					echo json_encode(array('error' => __('Message not found')));
					break;
				}
				$conv->set_messages($new_messages);
				$conv->save();
				// Store redo data using AIFileHandler
				$redo_file = $conv_dir . '/' . $conv_id . '.redo.json.php';
				AIFileHandler::write($redo_file, array('message_id' => $msg_id, 'removed' => $removed));
				echo json_encode(array('success' => true, 'messages' => $conv->get_messages(), 'can_redo' => !empty($removed)));
			}else{
				echo json_encode(array('error' => __('Conversation not found')));
			}
		break;

	case 'redo':
		$conv_id = AISession::get_active_conversation_id($username, $softpath);
		$conv_dir = AISession::get_conversations_dir($username, $softpath);
		$redo_file = $conv_dir . '/' . $conv_id . '.redo.json.php';
		$redo = AIFileHandler::read($redo_file);
		if(!$redo || empty($redo['removed'])){
			echo json_encode(array('error' => __('Nothing to redo')));
			break;
		}
		$conv = AIConversation::load($conv_dir . '/' . $conv_id . '.json.php');
		if($conv){
			// Restore the removed messages using the appropriate method
			foreach($redo['removed'] as $msg){
				$role = $msg['role'] ?? '';
				if($role === 'user'){
					$conv->add_user_message($msg['content'] ?? '', $msg['attachments'] ?? array());
				}elseif($role === 'assistant'){
					$conv->add_assistant_content($msg['parts'] ?? array(), $msg['model'] ?? '', $msg['usage'] ?? array(), $msg['provider'] ?? '');
				}elseif($role === 'tool_result'){
					$conv->add_tool_result($msg['tool_call_id'] ?? '', $msg['content'] ?? '', !empty($msg['is_error']), $msg['diff'] ?? '');
				}
			}
			$conv->save();
				// Delete redo file
				AIFileHandler::delete($redo_file);
				echo json_encode(array('success' => true, 'messages' => $conv->get_messages()));
			}else{
				echo json_encode(array('error' => __('Conversation not found')));
			}
			break;

		case 'fork':
			$msg_id = isset($_POST['message_id']) ? $_POST['message_id'] : '';
			if(empty($msg_id)){
				echo json_encode(array('error' => __('message_id is required')));
				break;
			}
			$conv_id = AISession::get_active_conversation_id($username, $softpath);
			$conv_dir = AISession::get_conversations_dir($username, $softpath);
			$conv = AIConversation::load($conv_dir . '/' . $conv_id . '.json.php');
			if($conv){
				$messages = $conv->get_messages();
				$fork_messages = array();
				$forked_content = '';
				$found = false;
				foreach($messages as $m){
					if(($m['id'] ?? '') === $msg_id){
						$found = true;
						$forked_content = $m['content'] ?? '';
						break; // Stop BEFORE this message - it goes to input field
					}
					$fork_messages[] = $m;
				}
				if(!$found){
					echo json_encode(array('error' => __('Message not found')));
					break;
				}
				$new_conv_id = 'conv_' . substr(md5(uniqid(mt_rand(), true)), 0, 12);
				$new_conv = AIConversation::create($conv_dir . '/' . $new_conv_id . '.json.php', $softpath, $new_conv_id);
				$new_conv->set_title(($conv->get_title() ?: __('Untitled')) . ' ('.__('fork').')');
				$new_conv->set_mode($conv->get_mode());
				foreach($fork_messages as $m){
					if(($m['role'] ?? '') === 'user'){
						$new_conv->add_user_message($m['content'] ?? '', $m['attachments'] ?? array());
					}elseif(($m['role'] ?? '') === 'assistant'){
						$new_conv->add_assistant_content($m['parts'] ?? array(), $m['model'] ?? '', $m['usage'] ?? array(), $m['provider'] ?? '');
					}elseif(($m['role'] ?? '') === 'tool_result'){
						$new_conv->add_tool_result($m['tool_call_id'] ?? '', $m['content'] ?? '', !empty($m['is_error']), $m['diff'] ?? '');
					}
				}
				$new_conv->save();
				AISession::set_active_conversation($username, $softpath, $new_conv->get_id());
				echo json_encode(array('success' => true, 'conversation_id' => $new_conv->get_id(), 'title' => $new_conv->get_title(), 'forked_content' => $forked_content));
			}else{
				echo json_encode(array('error' => __('Conversation not found')));
			}
			break;

		case 'compact':
			$keep_last_n = isset($_POST['keep_last_n']) ? intval($_POST['keep_last_n']) : 3;
			if($keep_last_n < 1) $keep_last_n = 3;
			$conv_id = AISession::get_active_conversation_id($username, $softpath);
			$conv_dir = AISession::get_conversations_dir($username, $softpath);
			$conv = AIConversation::load($conv_dir . '/' . $conv_id . '.json.php');
			if($conv){
				$result = $conv->compact($keep_last_n);
				if($result === false){
					echo json_encode(array('success' => true, 'messages' => $conv->get_messages(), 'summary' => __('Nothing to compact')));
				}else{
					$conv->save();
					echo json_encode(array('success' => true, 'messages' => $conv->get_messages()));
				}
			}else{
				echo json_encode(array('error' => __('Conversation not found')));
			}
			break;

		case 'ai_compact':
			$keep_last_n_ai = isset($_POST['keep_last_n']) ? intval($_POST['keep_last_n']) : 4;
			if($keep_last_n_ai < 1) $keep_last_n_ai = 4;
			$conv_id_ai = AISession::get_active_conversation_id($username, $softpath);
			$conv_dir_ai = AISession::get_conversations_dir($username, $softpath);
			$conv_ai = AIConversation::load($conv_dir_ai . '/' . $conv_id_ai . '.json.php');
			if(!$conv_ai){
				echo json_encode(array('error' => __('Conversation not found')));
				break;
			}
			$conv_text = $conv_ai->get_messages_for_compaction($keep_last_n_ai);
			if(empty($conv_text)){
				echo json_encode(array('success' => true, 'messages' => $conv_ai->get_messages(), 'summary' => __('Nothing to compact')));
				break;
			}
			$session_ai = AISession::load($username, $softpath);
			$settings_ai = new AISettings($username);
			$provider_id_ai = $session_ai['provider'] ?? '';
			$provider_config_ai = $settings_ai->get_provider_config($provider_id_ai) ?: array();
			$filter_provider_ai = ai_php_get_filter_provider_config($provider_id_ai);
			if(is_array($filter_provider_ai)){
				$filter_managed_ai = !empty($filter_provider_ai['api_key']) || (($filter_provider_ai['auth_type'] ?? '') === 'none');
				if($filter_managed_ai){
					$provider_config_ai = array(
						'api_key' => (string)($filter_provider_ai['api_key'] ?? ''),
						'base_url' => (string)($filter_provider_ai['base_url'] ?? ''),
						'auth_type' => (string)($filter_provider_ai['auth_type'] ?? 'api_key'),
						'models' => !empty($filter_provider_ai['models']) ? $filter_provider_ai['models'] : (!empty($provider_config_ai['models']) ? $provider_config_ai['models'] : array())
					);
				}
			}
			$api_key_ai = !empty($provider_config_ai['api_key']) ? $provider_config_ai['api_key'] : '';
			$model_ai = $session_ai['model'] ?? '';
			if(empty($api_key_ai) && !in_array($provider_id_ai, array('opencode_zen')) && strpos($provider_id_ai, 'custom:') !== 0){
				echo json_encode(array('error' => __('No API key configured for compaction')));
				break;
			}
			$provider_inst_ai = ai_php_get_provider_instance($provider_id_ai, $provider_config_ai ?: array());
			$client_ai = new AIClient($provider_inst_ai, $api_key_ai, $model_ai, $provider_config_ai ?: array());
			$compact_prompt = "Summarize the following conversation concisely, preserving all key decisions, code changes, file paths, and technical details. Focus on what was accomplished and any important context:\n\n" . mb_substr($conv_text, 0, 12000);
			$compact_msgs = array(
				array('role' => 'system', 'content' => 'You are a helpful assistant that creates concise summaries of coding conversations. Preserve all file paths, code snippets, and technical decisions.'),
				array('role' => 'user', 'content' => $compact_prompt)
			);
			$compact_result = $client_ai->chat($compact_msgs, array(), array('max_tokens' => 2048, 'timeout' => 60));
			$summary_text = '';
			if(!empty($compact_result['parts'])){
				foreach($compact_result['parts'] as $part){
					if(($part['type'] ?? '') === 'text' && !empty($part['text'])){
						$summary_text .= $part['text'];
					}
				}
			}
			if(empty($summary_text)){
				$summary_text = $compact_result['content'] ?? '';
			}
			if(empty($summary_text)){
				$conv_ai->compact($keep_last_n_ai);
				$conv_ai->save();
				echo json_encode(array('success' => true, 'messages' => $conv_ai->get_messages(), 'summary' => __('Compacted with basic method (AI summary failed)')));
				break;
			}
			$conv_ai->compact_with_summary($summary_text, $keep_last_n_ai);
			$conv_ai->save();
			echo json_encode(array('success' => true, 'messages' => $conv_ai->get_messages(), 'summary' => mb_substr($summary_text, 0, 500)));
			break;

	case 'rename_conversation':
		$conv_id_rename = isset($_POST['conversation_id']) ? $_POST['conversation_id'] : '';
		$new_title = isset($_POST['title']) ? $_POST['title'] : '';
		if(empty($conv_id_rename) || empty($new_title)){
			echo json_encode(array('error' => __('conversation_id and title are required')));
			break;
		}
		if(!ai_is_valid_id($conv_id_rename)){
			echo json_encode(array('error' => __('Invalid conversation ID')));
			break;
		}
		$conv_dir_rename = AISession::get_conversations_dir($username, $softpath);
			$conv_rename = AIConversation::load($conv_dir_rename . '/' . $conv_id_rename . '.json.php');
			if($conv_rename){
				$conv_rename->set_title($new_title);
				$conv_rename->save();
				echo json_encode(array('success' => true, 'title' => $new_title));
			}else{
				echo json_encode(array('error' => __('Conversation not found')));
			}
			break;

		case 'favorite_model':
			$model_id = isset($_POST['model_id']) ? $_POST['model_id'] : '';
			if(empty($model_id)){
				echo json_encode(array('error' => __('model_id is required')));
				break;
			}
			$settings = new AISettings($username);
			$settings->add_favorite_model($model_id);
			echo json_encode(array('success' => true, 'favorite_models' => $settings->get_favorite_models()));
			break;

		case 'unfavorite_model':
			$model_id_rm = isset($_POST['model_id']) ? $_POST['model_id'] : '';
			if(empty($model_id_rm)){
				echo json_encode(array('error' => __('model_id is required')));
				break;
			}
			$settings = new AISettings($username);
			$settings->remove_favorite_model($model_id_rm);
			echo json_encode(array('success' => true, 'favorite_models' => $settings->get_favorite_models()));
			break;

		case 'toggle_permission':
			$perm_key = isset($_POST['permission']) ? $_POST['permission'] : '';
			$perm_val = isset($_POST['value']) ? $_POST['value'] : '';
			if(empty($perm_key)){
				echo json_encode(array('error' => __('permission is required')));
				break;
			}
			$settings = new AISettings($username);
			$perm_val_norm = ($perm_val === 'allow_always') ? 'allow_always' : (($perm_val === 'deny') ? 'deny' : 'ask');
			$settings->set_permission($perm_key, $perm_val_norm);
			echo json_encode(array('success' => true, 'permissions' => $settings->get_permissions()));
			break;

	case 'shell':
		$command = isset($_POST['command']) ? $_POST['command'] : '';
		if(empty($command)){
			echo json_encode(array('error' => __('Command is required')));
			break;
		}
		// Respect the OS / control-panel login-shell policy. If the account
		// is a "nologin" / "noshell" / "false" account (how cPanel/WHM and
		// Webuzo deny shell access), the AI shell feature is disabled for
		// that user too — otherwise it would be a trivial bypass of the
		// account's shell restriction.
		if(!ai_user_has_shell_access($username)){
			echo json_encode(array('error' => __('Command execution is disabled because shell access is not enabled for this account.'), 'output' => ''));
			break;
		}
		// Route the command through ToolExecutor so it goes through the
		// jail/cagefs isolation layer (ai_jail.sh / cagefs_enter) the same
		// way the AI agent's bash tool does. This replaces the previous
		// raw proc_open() which bypassed all isolation.
		$_fm = new AIFileManager($softpath, $user_home_dir);
		$_te = new ToolExecutor($_fm, $softpath, $user_home_dir, 'build', $username);
		$_te->set_abort_file('');
		// Honor the user's AI permission settings (Settings > Permissions)
		// the same way the agent's own bash tool does. 'ask' cannot be
		// interactive here, so it is treated as denied for this endpoint.
		$_settings = new AISettings($username);
		$_perms = $_settings->get_permissions();
		if(isset($_perms['bash']) && $_perms['bash'] !== 'allow_always'){
			echo json_encode(array('error' => __('Command execution is not allowed. Enable the bash permission in Settings > Permissions.'), 'output' => ''));
			break;
		}
		$_te->set_permissions($_perms);
		$_result = $_te->execute('bash', array('command' => $command, 'timeout' => 30));
		echo json_encode(array(
			'output' => isset($_result['output']) ? $_result['output'] : '',
			'returncode' => isset($_result['is_error']) && $_result['is_error'] ? 1 : 0,
			'error' => (!empty($_result['is_error']) && empty($_result['output'])) ? __('Command execution failed') : ''
		));
		break;

		default:
			echo json_encode(array('error' => __('Unknown action: $0', array($action))));
	}

	die();
}