From 62d2ff3946c75676526e1773bb2d84f6ae556123 Mon Sep 17 00:00:00 2001 From: Sanchit Monga Date: Fri, 13 Mar 2026 00:42:17 -0700 Subject: [PATCH 01/16] adding VLM support --- CMakeLists.txt | 14 +- src/api/rcli_api.cpp | 144 +++++++++++++++++ src/api/rcli_api.h | 29 ++++ src/audio/camera_capture.h | 14 ++ src/audio/camera_capture.mm | 142 +++++++++++++++++ src/cli/help.h | 8 + src/cli/main.cpp | 138 +++++++++++++++++ src/cli/model_pickers.h | 134 +++++++++++++++- src/cli/tui_app.h | 119 +++++++++++++- src/engines/vlm_engine.cpp | 264 ++++++++++++++++++++++++++++++++ src/engines/vlm_engine.h | 88 +++++++++++ src/models/vlm_model_registry.h | 82 ++++++++++ src/pipeline/orchestrator.h | 3 + test_image.jpg | Bin 0 -> 46103 bytes 14 files changed, 1171 insertions(+), 8 deletions(-) create mode 100644 src/audio/camera_capture.h create mode 100644 src/audio/camera_capture.mm create mode 100644 src/engines/vlm_engine.cpp create mode 100644 src/engines/vlm_engine.h create mode 100644 src/models/vlm_model_registry.h create mode 100644 test_image.jpg diff --git a/CMakeLists.txt b/CMakeLists.txt index 00f5224..7a5dac5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -46,6 +46,10 @@ set(LLAMA_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(LLAMA_BUILD_SERVER OFF CACHE BOOL "" FORCE) add_subdirectory(deps/llama.cpp ${CMAKE_BINARY_DIR}/llama.cpp EXCLUDE_FROM_ALL) +# --- libmtmd (multimodal/vision support from llama.cpp) --- +set(LLAMA_INSTALL_VERSION "0.0.0" CACHE STRING "" FORCE) +add_subdirectory(deps/llama.cpp/tools/mtmd ${CMAKE_BINARY_DIR}/mtmd EXCLUDE_FROM_ALL) + # --- sherpa-onnx (STT + TTS + VAD) --- set(SHERPA_ONNX_ENABLE_C_API ON CACHE BOOL "Enable C API" FORCE) set(SHERPA_ONNX_ENABLE_BINARY OFF CACHE BOOL "" FORCE) @@ -99,8 +103,10 @@ add_library(rcli STATIC src/engines/metalrt_engine.cpp src/engines/metalrt_stt_engine.cpp src/engines/metalrt_tts_engine.cpp + src/engines/vlm_engine.cpp src/audio/audio_io.cpp src/audio/mic_permission.mm + src/audio/camera_capture.mm src/pipeline/orchestrator.cpp src/pipeline/sentence_detector.cpp src/tools/tool_engine.cpp @@ -133,13 +139,14 @@ add_library(rcli STATIC src/api/rcli_api.cpp ) -set_source_files_properties(src/audio/mic_permission.mm +set_source_files_properties(src/audio/mic_permission.mm src/audio/camera_capture.mm PROPERTIES LANGUAGE CXX) target_include_directories(rcli PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src ${CMAKE_CURRENT_SOURCE_DIR}/deps/llama.cpp/include ${CMAKE_CURRENT_SOURCE_DIR}/deps/llama.cpp/ggml/include + ${CMAKE_CURRENT_SOURCE_DIR}/deps/llama.cpp/tools/mtmd ${CMAKE_CURRENT_SOURCE_DIR}/deps/sherpa-onnx/sherpa-onnx/c-api ${usearch_SOURCE_DIR}/include ) @@ -147,12 +154,17 @@ target_include_directories(rcli PUBLIC target_link_libraries(rcli PUBLIC llama ggml + mtmd sherpa-onnx-c-api "-framework CoreAudio" "-framework AudioToolbox" "-framework AudioUnit" "-framework Foundation" "-framework AVFoundation" + "-framework AppKit" + "-framework CoreImage" + "-framework CoreMedia" + "-framework CoreVideo" "-framework IOKit" ) diff --git a/src/api/rcli_api.cpp b/src/api/rcli_api.cpp index 8baa3ef..18cfadf 100644 --- a/src/api/rcli_api.cpp +++ b/src/api/rcli_api.cpp @@ -35,6 +35,8 @@ #include "actions/action_registry.h" #include "actions/macos_actions.h" +#include "engines/vlm_engine.h" +#include "models/vlm_model_registry.h" using namespace rastack; @@ -109,6 +111,11 @@ struct RCLIEngine { // so the context gauge shows stable, meaningful usage. int ctx_main_prompt_tokens = 0; + // VLM (Vision Language Model) subsystem + VlmEngine vlm_engine; + bool vlm_initialized = false; + std::string last_vlm_response; + std::mutex mutex; bool initialized = false; }; @@ -2745,6 +2752,143 @@ void rcli_get_context_info(RCLIHandle handle, int* out_prompt_tokens, int* out_c } } +// ============================================================================= +// VLM (Vision Language Model) +// ============================================================================= + +static bool download_vlm_model(const std::string& url, const std::string& dest) { + // Check if already exists + if (access(dest.c_str(), R_OK) == 0) return true; + + LOG_INFO("VLM", "Downloading: %s", dest.c_str()); + + // Ensure parent directory exists + std::string dir = dest.substr(0, dest.rfind('/')); + std::string mkdir_cmd = "mkdir -p '" + dir + "'"; + (void)system(mkdir_cmd.c_str()); + + // Download with curl (progress bar) + std::string cmd = "curl -L --progress-bar -o '" + dest + "' '" + url + "'"; + int rc = system(cmd.c_str()); + if (rc != 0) { + LOG_ERROR("VLM", "Download failed (exit=%d)", rc); + unlink(dest.c_str()); + return false; + } + return true; +} + +int rcli_vlm_init(RCLIHandle handle) { + if (!handle) return -1; + auto* engine = static_cast(handle); + + if (engine->vlm_initialized) return 0; + + // Fallback to default models dir if not set + if (engine->models_dir.empty()) { + if (const char* home = getenv("HOME")) + engine->models_dir = std::string(home) + "/Library/RCLI/models"; + else + engine->models_dir = "./models"; + } + + // Find or download VLM model + auto vlm_models = rcli::all_vlm_models(); + rcli::VlmModelDef model_def; + bool found = false; + + // Check if any VLM model is installed + for (auto& m : vlm_models) { + if (rcli::is_vlm_model_installed(engine->models_dir, m)) { + model_def = m; + found = true; + break; + } + } + + // If no model installed, download default + if (!found) { + auto [has_default, def] = rcli::get_default_vlm_model(); + if (!has_default) { + LOG_ERROR("VLM", "No VLM model defined in registry"); + return -1; + } + model_def = def; + + std::string model_path = engine->models_dir + "/" + model_def.model_filename; + std::string mmproj_path = engine->models_dir + "/" + model_def.mmproj_filename; + + if (!download_vlm_model(model_def.model_url, model_path)) return -1; + if (!download_vlm_model(model_def.mmproj_url, mmproj_path)) return -1; + } + + // Initialize VLM engine + VlmConfig config; + config.model_path = engine->models_dir + "/" + model_def.model_filename; + config.mmproj_path = engine->models_dir + "/" + model_def.mmproj_filename; + config.n_gpu_layers = 99; + config.n_ctx = 4096; + config.n_batch = 512; + config.n_threads = 1; + config.n_threads_batch = 8; + config.flash_attn = true; + + if (!engine->vlm_engine.init(config)) { + LOG_ERROR("VLM", "Failed to initialize VLM engine"); + return -1; + } + + engine->vlm_initialized = true; + LOG_INFO("VLM", "VLM engine ready (%s)", model_def.name.c_str()); + return 0; +} + +const char* rcli_vlm_analyze(RCLIHandle handle, const char* image_path, const char* prompt) { + if (!handle || !image_path) return ""; + auto* engine = static_cast(handle); + + if (!engine->vlm_initialized) { + if (rcli_vlm_init(handle) != 0) { + engine->last_vlm_response = "Error: VLM engine failed to initialize."; + return engine->last_vlm_response.c_str(); + } + } + + std::string text_prompt = prompt && prompt[0] + ? std::string(prompt) + : "Describe this image in detail."; + + std::string result = engine->vlm_engine.analyze_image( + std::string(image_path), text_prompt, nullptr); + + if (result.empty()) { + engine->last_vlm_response = "Error: Failed to analyze image."; + } else { + engine->last_vlm_response = result; + } + return engine->last_vlm_response.c_str(); +} + +int rcli_vlm_is_ready(RCLIHandle handle) { + if (!handle) return 0; + auto* engine = static_cast(handle); + return engine->vlm_initialized ? 1 : 0; +} + +int rcli_vlm_get_stats(RCLIHandle handle, RCLIVlmStats* out_stats) { + if (!handle || !out_stats) return -1; + auto* engine = static_cast(handle); + if (!engine->vlm_initialized) return -1; + + auto& s = engine->vlm_engine.last_stats(); + out_stats->gen_tok_per_sec = s.gen_tps(); + out_stats->generated_tokens = static_cast(s.generated_tokens); + out_stats->total_time_sec = (s.image_encode_us + s.generation_us) / 1e6; + out_stats->image_encode_ms = s.image_encode_us / 1000.0; + out_stats->first_token_ms = s.first_token_us / 1000.0; + return 0; +} + } // extern "C" std::vector rcli_get_all_action_defs(RCLIHandle handle) { diff --git a/src/api/rcli_api.h b/src/api/rcli_api.h index 5a0e2d3..e058945 100644 --- a/src/api/rcli_api.h +++ b/src/api/rcli_api.h @@ -262,6 +262,35 @@ const char* rcli_get_stt_model(RCLIHandle handle); // Both output pointers are optional (pass NULL to skip). void rcli_get_context_info(RCLIHandle handle, int* out_prompt_tokens, int* out_ctx_size); +// --- VLM (Vision Language Model) --- + +// Initialize the VLM engine with the default VLM model. +// Lazily downloads the model if not present. Thread-safe. +// Returns 0 on success, -1 on failure. +int rcli_vlm_init(RCLIHandle handle); + +// Analyze an image with an optional text prompt. +// image_path: absolute path to an image file (jpg, png, bmp, gif, webp, tga). +// prompt: text prompt (e.g. "Describe this image"). NULL defaults to "Describe this image in detail." +// Returns the analysis text. Caller must NOT free the returned pointer. +const char* rcli_vlm_analyze(RCLIHandle handle, const char* image_path, const char* prompt); + +// Check if the VLM engine is initialized and ready for image analysis. +// Returns 1 if ready, 0 if not. +int rcli_vlm_is_ready(RCLIHandle handle); + +// VLM performance stats from the last analysis call. +typedef struct { + double gen_tok_per_sec; // Generation tokens/second + int generated_tokens; // Total tokens generated + double total_time_sec; // Total wall time (image encode + prompt eval + generation) + double image_encode_ms; // Time to encode image through vision projector + double first_token_ms; // Time-to-first-token (prompt eval + image encode) +} RCLIVlmStats; + +// Get stats from the last VLM analysis. Returns 0 on success. +int rcli_vlm_get_stats(RCLIHandle handle, RCLIVlmStats* out_stats); + #ifdef __cplusplus } #endif diff --git a/src/audio/camera_capture.h b/src/audio/camera_capture.h new file mode 100644 index 0000000..1d5ade4 --- /dev/null +++ b/src/audio/camera_capture.h @@ -0,0 +1,14 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +// Capture a single frame from the default camera and save as JPEG. +// output_path: where to save the JPEG (e.g. "/tmp/rcli_camera.jpg"). +// Returns 0 on success, -1 on failure. +int camera_capture_photo(const char* output_path); + +#ifdef __cplusplus +} +#endif diff --git a/src/audio/camera_capture.mm b/src/audio/camera_capture.mm new file mode 100644 index 0000000..a4cdf8b --- /dev/null +++ b/src/audio/camera_capture.mm @@ -0,0 +1,142 @@ +#import +#import +#import +#import +#include "camera_capture.h" +#include + +// Delegate that skips warmup frames then captures one properly-exposed frame +@interface RCLISingleFrameCapture : NSObject +@property (nonatomic, strong) NSString *outputPath; +@property (nonatomic, assign) BOOL captured; +@property (nonatomic, strong) dispatch_semaphore_t semaphore; +@property (nonatomic, assign) int frameCount; +@property (nonatomic, assign) int framesToSkip; +@end + +@implementation RCLISingleFrameCapture + +- (void)captureOutput:(AVCaptureOutput *)output +didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer + fromConnection:(AVCaptureConnection *)connection { + if (self.captured) return; + + // Skip initial frames to let auto-exposure/white-balance stabilize + self.frameCount++; + if (self.frameCount < self.framesToSkip) return; + + self.captured = YES; + + CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer); + if (!imageBuffer) { + dispatch_semaphore_signal(self.semaphore); + return; + } + + CIImage *ciImage = [CIImage imageWithCVImageBuffer:imageBuffer]; + NSCIImageRep *rep = [NSCIImageRep imageRepWithCIImage:ciImage]; + NSImage *nsImage = [[NSImage alloc] initWithSize:rep.size]; + [nsImage addRepresentation:rep]; + + // Convert to JPEG at high quality + NSData *tiffData = [nsImage TIFFRepresentation]; + NSBitmapImageRep *bitmapRep = [NSBitmapImageRep imageRepWithData:tiffData]; + NSData *jpegData = [bitmapRep representationUsingType:NSBitmapImageFileTypeJPEG + properties:@{NSImageCompressionFactor: @0.92}]; + [jpegData writeToFile:self.outputPath atomically:YES]; + + dispatch_semaphore_signal(self.semaphore); +} + +@end + +int camera_capture_photo(const char* output_path) { + @autoreleasepool { + // Check camera permission + AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo]; + if (status == AVAuthorizationStatusDenied || status == AVAuthorizationStatusRestricted) { + return -1; + } + if (status == AVAuthorizationStatusNotDetermined) { + dispatch_semaphore_t perm_sem = dispatch_semaphore_create(0); + __block BOOL granted = NO; + [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL g) { + granted = g; + dispatch_semaphore_signal(perm_sem); + }]; + dispatch_semaphore_wait(perm_sem, DISPATCH_TIME_FOREVER); + if (!granted) return -1; + } + + // Find default camera + AVCaptureDevice *device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeVideo]; + if (!device) return -1; + + // Configure camera for best quality and let auto-exposure do its thing + NSError *error = nil; + if ([device lockForConfiguration:&error]) { + // Enable continuous auto-exposure and white balance + if ([device isExposureModeSupported:AVCaptureExposureModeContinuousAutoExposure]) { + device.exposureMode = AVCaptureExposureModeContinuousAutoExposure; + } + if ([device isWhiteBalanceModeSupported:AVCaptureWhiteBalanceModeContinuousAutoWhiteBalance]) { + device.whiteBalanceMode = AVCaptureWhiteBalanceModeContinuousAutoWhiteBalance; + } + if ([device isFocusModeSupported:AVCaptureFocusModeContinuousAutoFocus]) { + device.focusMode = AVCaptureFocusModeContinuousAutoFocus; + } + [device unlockForConfiguration]; + } + + AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:device error:&error]; + if (!input) return -1; + + AVCaptureSession *session = [[AVCaptureSession alloc] init]; + // Use Photo preset for highest quality + if ([session canSetSessionPreset:AVCaptureSessionPresetPhoto]) { + session.sessionPreset = AVCaptureSessionPresetPhoto; + } else if ([session canSetSessionPreset:AVCaptureSessionPresetHigh]) { + session.sessionPreset = AVCaptureSessionPresetHigh; + } else { + session.sessionPreset = AVCaptureSessionPresetMedium; + } + + if (![session canAddInput:input]) return -1; + [session addInput:input]; + + AVCaptureVideoDataOutput *videoOutput = [[AVCaptureVideoDataOutput alloc] init]; + videoOutput.videoSettings = @{(NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA)}; + videoOutput.alwaysDiscardsLateVideoFrames = YES; + + RCLISingleFrameCapture *delegate = [[RCLISingleFrameCapture alloc] init]; + delegate.outputPath = [NSString stringWithUTF8String:output_path]; + delegate.captured = NO; + delegate.semaphore = dispatch_semaphore_create(0); + delegate.frameCount = 0; + // Skip ~60 frames (~2 seconds at 30fps) to let auto-exposure fully stabilize + delegate.framesToSkip = 60; + + dispatch_queue_t queue = dispatch_queue_create("com.rcli.camera", DISPATCH_QUEUE_SERIAL); + [videoOutput setSampleBufferDelegate:delegate queue:queue]; + + if (![session canAddOutput:videoOutput]) return -1; + [session addOutput:videoOutput]; + + // Start capture — delegate will skip first 60 frames for AE stabilization + [session startRunning]; + + // Wait for frame capture (timeout 10 seconds — allows for warmup + capture) + long result = dispatch_semaphore_wait(delegate.semaphore, + dispatch_time(DISPATCH_TIME_NOW, 10 * NSEC_PER_SEC)); + + [session stopRunning]; + + if (result != 0) return -1; // timeout + + // Verify the file was written + NSFileManager *fm = [NSFileManager defaultManager]; + if (![fm fileExistsAtPath:delegate.outputPath]) return -1; + + return 0; + } +} diff --git a/src/cli/help.h b/src/cli/help.h index bb9b37a..ccceaea 100644 --- a/src/cli/help.h +++ b/src/cli/help.h @@ -19,6 +19,7 @@ inline void print_usage(const char* argv0) { " %sask%s One-shot text command\n" " %sactions%s [name] List all actions, or show detail for one\n" " %saction%s [json] Execute a named action directly\n" + " %svlm%s [prompt] Analyze image with Vision Language Model\n" " %srag%s RAG: ingest docs, query, status\n" " %ssetup%s Download AI models (~1GB)\n" " %smodels%s Manage all AI models (LLM, STT, TTS)\n" @@ -45,6 +46,8 @@ inline void print_usage(const char* argv0) { " rcli ask \"open Safari\" # one-shot command\n" " rcli ask \"create a note called Ideas\" # triggers action\n" " rcli actions # see all actions\n" + " rcli vlm photo.jpg # analyze an image\n" + " rcli vlm photo.jpg \"What is this?\" # image with custom prompt\n" " rcli actions create_note # action detail\n" " rcli setup # download models\n\n", color::bold, color::orange, color::reset, @@ -69,6 +72,7 @@ inline void print_usage(const char* argv0) { color::green, color::reset, color::green, color::reset, color::green, color::reset, + color::green, color::reset, color::dim, color::reset, color::dim, color::reset); } @@ -130,7 +134,11 @@ inline void print_help_interactive() { fprintf(stderr, " %sdo [text]%s execute action directly (no JSON needed)\n", color::bold, color::reset); fprintf(stderr, " %srag status%s show indexed documents\n", color::bold, color::reset); fprintf(stderr, " %srag ingest %s index docs for Q&A\n", color::bold, color::reset); + fprintf(stderr, " %scamera%s capture photo from webcam & analyze\n", color::bold, color::reset); fprintf(stderr, " %squit%s exit\n\n", color::bold, color::reset); + fprintf(stderr, " %s%s Vision:%s\n", color::bold, color::orange, color::reset); + fprintf(stderr, " Drag & drop an image file to analyze it with the VLM.\n"); + fprintf(stderr, " Type %scamera%s to capture a photo from your webcam.\n\n", color::bold, color::reset); fprintf(stderr, " %s%s Try:%s\n", color::bold, color::orange, color::reset); fprintf(stderr, " %s\"Open Safari\" \"What's on my calendar?\" \"Set volume to 50\"%s\n\n", color::dim, color::reset); diff --git a/src/cli/main.cpp b/src/cli/main.cpp index 4f49472..2207117 100644 --- a/src/cli/main.cpp +++ b/src/cli/main.cpp @@ -27,6 +27,9 @@ #include "audio/mic_permission.h" #include "core/personality.h" #include "llama.h" +#include "mtmd.h" +#include "mtmd-helper.h" +#include "audio/camera_capture.h" // Defined in cli_common.h as a forward declaration; implemented here because // it depends on the Objective-C mic_permission bridge compiled into this TU. @@ -427,6 +430,138 @@ static int cmd_ask(const Args& args) { return 0; } +// ============================================================================= +// VLM subcommand +// ============================================================================= + +static int cmd_vlm(const Args& args) { + if (args.arg1.empty() || args.help) { + fprintf(stderr, "\n Usage: rcli vlm [prompt]\n\n"); + fprintf(stderr, " Analyze an image using a Vision Language Model.\n\n"); + fprintf(stderr, " Examples:\n"); + fprintf(stderr, " rcli vlm photo.jpg\n"); + fprintf(stderr, " rcli vlm screenshot.png \"What text do you see?\"\n"); + fprintf(stderr, " rcli vlm diagram.jpg \"Explain this diagram\"\n\n"); + return args.help ? 0 : 1; + } + + // Resolve image path + std::string image_path = args.arg1; + if (!image_path.empty() && image_path[0] == '~') { + if (const char* home = getenv("HOME")) + image_path = std::string(home) + image_path.substr(1); + } + // Make relative paths absolute + if (!image_path.empty() && image_path[0] != '/') { + char cwd[4096]; + if (getcwd(cwd, sizeof(cwd))) + image_path = std::string(cwd) + "/" + image_path; + } + + struct stat st; + if (stat(image_path.c_str(), &st) != 0) { + fprintf(stderr, "%s%sError: Image not found: %s%s\n", + color::bold, color::red, image_path.c_str(), color::reset); + return 1; + } + + if (!rastack::VlmEngine::is_supported_image(image_path)) { + fprintf(stderr, "%s%sError: Unsupported image format. Supported: jpg, png, bmp, gif, webp, tga%s\n", + color::bold, color::red, color::reset); + return 1; + } + + std::string prompt = args.arg2.empty() ? "Describe this image in detail." : args.arg2; + + // Create engine with models_dir set (we only need VLM, not the full pipeline) + std::string config_json = "{\"models_dir\": \"" + args.models_dir + "\"}"; + g_engine = rcli_create(config_json.c_str()); + if (!g_engine) return 1; + + // Initialize VLM + fprintf(stderr, "%sInitializing VLM...%s\n", color::dim, color::reset); + if (rcli_vlm_init(g_engine) != 0) { + fprintf(stderr, "%s%sError: Failed to initialize VLM engine%s\n", + color::bold, color::red, color::reset); + rcli_destroy(g_engine); + return 1; + } + + fprintf(stderr, "%sAnalyzing image: %s%s\n", color::dim, image_path.c_str(), color::reset); + + const char* response = rcli_vlm_analyze(g_engine, image_path.c_str(), prompt.c_str()); + if (response && response[0]) { + fprintf(stdout, "%s\n", response); + // Print performance stats + RCLIVlmStats stats; + if (rcli_vlm_get_stats(g_engine, &stats) == 0) { + fprintf(stderr, "\n%s⚡ %.1f tok/s (%d tokens, %.1fs total, first token %.0fms)%s\n", + color::dim, stats.gen_tok_per_sec, stats.generated_tokens, + stats.total_time_sec, stats.first_token_ms, color::reset); + } + } else { + fprintf(stderr, "%s%sError: VLM analysis failed%s\n", + color::bold, color::red, color::reset); + rcli_destroy(g_engine); + return 1; + } + + rcli_destroy(g_engine); + return 0; +} + +// ============================================================================= +// Camera subcommand — capture + analyze +// ============================================================================= + +static int cmd_camera(const Args& args) { + std::string prompt = args.arg1.empty() ? "Describe what you see in this photo in detail." : args.arg1; + + fprintf(stderr, "%sCapturing photo from camera...%s\n", color::dim, color::reset); + std::string photo_path = "/tmp/rcli_camera.jpg"; + + int rc = camera_capture_photo(photo_path.c_str()); + if (rc != 0) { + fprintf(stderr, "%s%sError: Camera capture failed. Check camera permissions.%s\n", + color::bold, color::red, color::reset); + return 1; + } + fprintf(stderr, "%sPhoto captured! Analyzing with VLM...%s\n", color::dim, color::reset); + + std::string config_json = "{\"models_dir\": \"" + args.models_dir + "\"}"; + g_engine = rcli_create(config_json.c_str()); + if (!g_engine) return 1; + + if (rcli_vlm_init(g_engine) != 0) { + fprintf(stderr, "%s%sError: Failed to initialize VLM engine%s\n", + color::bold, color::red, color::reset); + rcli_destroy(g_engine); + return 1; + } + + const char* response = rcli_vlm_analyze(g_engine, photo_path.c_str(), prompt.c_str()); + if (response && response[0]) { + fprintf(stdout, "%s\n", response); + // Print performance stats + RCLIVlmStats stats; + if (rcli_vlm_get_stats(g_engine, &stats) == 0) { + fprintf(stderr, "\n%s⚡ %.1f tok/s (%d tokens, %.1fs total, first token %.0fms)%s\n", + color::dim, stats.gen_tok_per_sec, stats.generated_tokens, + stats.total_time_sec, stats.first_token_ms, color::reset); + } + // Open the captured photo in Preview so user can see what was captured + system(("open '" + photo_path + "'").c_str()); + } else { + fprintf(stderr, "%s%sError: VLM analysis failed%s\n", + color::bold, color::red, color::reset); + rcli_destroy(g_engine); + return 1; + } + + rcli_destroy(g_engine); + return 0; +} + // ============================================================================= // RAG subcommands // ============================================================================= @@ -917,6 +1052,7 @@ int main(int argc, char** argv) { if (!args.verbose) { llama_log_set([](enum ggml_log_level, const char*, void*) {}, nullptr); + mtmd_helper_log_set([](enum ggml_log_level, const char*, void*) {}, nullptr); } if (args.command.empty()) { @@ -930,6 +1066,8 @@ int main(int argc, char** argv) { if (args.command == "actions") return cmd_actions(args); if (args.command == "action") return cmd_action(args); if (args.command == "rag") return cmd_rag(args); + if (args.command == "vlm") return cmd_vlm(args); + if (args.command == "camera") return cmd_camera(args); if (args.command == "setup") return cmd_setup(args); if (args.command == "models") return cmd_models(args); if (args.command == "voices") return cmd_voices(args); diff --git a/src/cli/model_pickers.h b/src/cli/model_pickers.h index 949e25b..a12e1b5 100644 --- a/src/cli/model_pickers.h +++ b/src/cli/model_pickers.h @@ -12,6 +12,7 @@ #include "models/model_registry.h" #include "models/tts_model_registry.h" #include "models/stt_model_registry.h" +#include "models/vlm_model_registry.h" #include "engines/metalrt_loader.h" // ============================================================================= @@ -407,6 +408,83 @@ inline int pick_metalrt_stt() { return 0; } +// ============================================================================= +// VLM picker +// ============================================================================= + +inline int pick_vlm(const std::string& models_dir) { + auto all = rcli::all_vlm_models(); + + fprintf(stderr, "\n %s%s VLM Models (Vision)%s\n\n", color::bold, color::orange, color::reset); + + fprintf(stderr, " %s# %-30s %-12s %s%s\n", + color::bold, "Model", "Size", "Status", color::reset); + fprintf(stderr, " %s── %-30s %-12s %s%s\n", + color::dim, "──────────────────────────────", "────────────", "──────────", color::reset); + + for (size_t i = 0; i < all.size(); i++) { + auto& m = all[i]; + bool installed = rcli::is_vlm_model_installed(models_dir, m); + std::string status; + if (installed) status = "\033[32minstalled\033[0m"; + else status = "\033[2mnot installed\033[0m"; + std::string label = m.name; + if (m.is_default) label += " (default)"; + char size_str[32]; + int total_mb = m.model_size_mb + m.mmproj_size_mb; + if (total_mb >= 1024) + snprintf(size_str, sizeof(size_str), "%.1f GB", total_mb / 1024.0); + else + snprintf(size_str, sizeof(size_str), "%d MB", total_mb); + fprintf(stderr, " %s%-2zu%s %-30s %-12s %s\n", + installed ? "\033[32m" : "", i + 1, installed ? "\033[0m" : "", + label.c_str(), size_str, status.c_str()); + } + fprintf(stderr, "\n %sCommands:%s [1-%zu] download/select | q cancel\n Choice: ", + color::bold, color::reset, all.size()); + fflush(stderr); + + int choice = read_picker_choice(); + if (choice == 0 || choice == -1) { picker_no_changes(); return 0; } + if (choice < 1 || choice > (int)all.size()) { fprintf(stderr, "\n Invalid choice.\n\n"); return 1; } + + auto& sel = all[choice - 1]; + bool installed = rcli::is_vlm_model_installed(models_dir, sel); + if (installed) { + fprintf(stderr, "\n %s%s%s is already installed.%s\n\n", + color::bold, color::green, sel.name.c_str(), color::reset); + return 0; + } + + int total_mb = sel.model_size_mb + sel.mmproj_size_mb; + char size_str[32]; + if (total_mb >= 1024) + snprintf(size_str, sizeof(size_str), "%.1f GB", total_mb / 1024.0); + else + snprintf(size_str, sizeof(size_str), "%d MB", total_mb); + fprintf(stderr, "\n %s%s%s%s is not installed (%s). Download? [Y/n]: ", + color::bold, color::yellow, sel.name.c_str(), color::reset, size_str); + fflush(stderr); + if (!confirm_download()) { picker_cancelled(); return 0; } + + std::string model_path = models_dir + "/" + sel.model_filename; + std::string mmproj_path = models_dir + "/" + sel.mmproj_filename; + std::string cmd = "bash -c '" + "set -e; echo \" Downloading " + sel.name + " model...\"; echo \"\"; " + "curl -L -# -o \"" + model_path + "\" \"" + sel.model_url + "\"; " + "echo \"\"; echo \" Downloading vision projector...\"; echo \"\"; " + "curl -L -# -o \"" + mmproj_path + "\" \"" + sel.mmproj_url + "\"; " + "echo \"\"; echo \" Done!\"; '"; + fprintf(stderr, "\n"); + if (system(cmd.c_str()) != 0) { + fprintf(stderr, "\n %s%sDownload failed.%s\n\n", color::bold, color::red, color::reset); + return 1; + } + fprintf(stderr, "\n %s%sInstalled: %s%s\n Use: rcli vlm [prompt]\n\n", + color::bold, color::green, sel.name.c_str(), color::reset); + return 0; +} + // ============================================================================= // Unified models dashboard // ============================================================================= @@ -417,6 +495,7 @@ inline int cmd_models(const Args& args) { if (args.arg1 == "llm") return pick_llm(models_dir); if (args.arg1 == "stt") return pick_stt(models_dir); if (args.arg1 == "tts") return pick_tts(models_dir); + if (args.arg1 == "vlm") return pick_vlm(models_dir); if (args.arg1 == "metalrt-stt" || args.arg1 == "whisper") return pick_metalrt_stt(); if (args.help) { @@ -426,12 +505,14 @@ inline int cmd_models(const Args& args) { " models Unified model dashboard\n" " models llm LLM model picker\n" " models stt STT model picker\n" - " models tts TTS voice picker\n\n" + " models tts TTS voice picker\n" + " models vlm VLM (vision) model picker\n\n" " %sEXAMPLES%s\n" " rcli models # dashboard — pick a modality\n" " rcli models llm # switch LLM directly\n" " rcli models stt # switch offline STT directly\n" - " rcli models tts # switch TTS voice directly\n\n", + " rcli models tts # switch TTS voice directly\n" + " rcli models vlm # manage VLM models for image analysis\n\n", color::bold, color::orange, color::reset, color::bold, color::reset, color::bold, color::reset); @@ -483,6 +564,21 @@ inline int cmd_models(const Args& args) { color::green, tts_name.c_str(), color::reset, tts_inst, tts_all.size()); + // VLM row + auto vlm_all = rcli::all_vlm_models(); + int vlm_inst = 0; + std::string vlm_name = "not installed"; + for (auto& m : vlm_all) { + if (rcli::is_vlm_model_installed(models_dir, m)) { + vlm_inst++; + if (vlm_name == "not installed") vlm_name = m.name; + } + } + fprintf(stderr, " %s4%s %sVLM (vision)%s %s%-28s%s %d / %zu\n", + color::green, color::reset, color::bold, color::reset, + vlm_inst > 0 ? color::green : color::dim, vlm_name.c_str(), color::reset, + vlm_inst, vlm_all.size()); + // MetalRT Whisper row auto mrt_comps = rcli::metalrt_component_models(); std::string mrt_stt_pref = rcli::read_selected_metalrt_stt_id(); @@ -498,7 +594,7 @@ inline int cmd_models(const Args& args) { } } if (mrt_stt_pref.empty() && mrt_stt_inst > 0) mrt_stt_name = "auto (first installed)"; - fprintf(stderr, " %s4%s %sMetalRT STT%s %s%-28s%s %d / %d\n", + fprintf(stderr, " %s5%s %sMetalRT STT%s %s%-28s%s %d / %d\n", color::green, color::reset, color::bold, color::reset, color::green, mrt_stt_name.c_str(), color::reset, mrt_stt_inst, mrt_stt_total); @@ -521,7 +617,7 @@ inline int cmd_models(const Args& args) { } fprintf(stderr, " %sNote: STT streaming (Zipformer) is always active for live mic.%s\n\n", color::dim, color::reset); - fprintf(stderr, " %sSelect modality:%s 1 LLM | 2 STT | 3 TTS | 4 MetalRT STT | q cancel\n Choice: ", + fprintf(stderr, " %sSelect modality:%s 1 LLM | 2 STT | 3 TTS | 4 VLM | 5 MetalRT STT | q cancel\n Choice: ", color::bold, color::reset); fflush(stderr); @@ -530,7 +626,8 @@ inline int cmd_models(const Args& args) { if (choice == 1 || choice == -2) return pick_llm(models_dir); // -2 (a) → LLM as first if (choice == 2) return pick_stt(models_dir); if (choice == 3) return pick_tts(models_dir); - if (choice == 4) return pick_metalrt_stt(); + if (choice == 4) return pick_vlm(models_dir); + if (choice == 5) return pick_metalrt_stt(); fprintf(stderr, "\n Invalid choice.\n\n"); return 1; @@ -595,10 +692,17 @@ inline int cmd_info() { ? "MetalRT (Metal GPU — LLM, STT, TTS on-device)" : "llama.cpp + sherpa-onnx (ONNX Runtime)"; + auto vlm_all_info = rcli::all_vlm_models(); + auto [vlm_found, vlm_def] = rcli::find_installed_vlm(models_dir); + std::string vlm_info = vlm_found + ? (vlm_def.name + " (llama.cpp + mtmd)") + : "not installed — run: rcli models vlm"; + fprintf(stdout, "\n%s%s RCLI%s %s%s%s\n\n" " %sEngine:%s %s\n" " %sLLM:%s %s\n" + " %sVLM:%s %s\n" " %sSTT:%s %s\n" " %sTTS:%s %s\n" " %sVAD:%s Silero VAD\n" @@ -610,6 +714,7 @@ inline int cmd_info() { color::dim, RA_VERSION, color::reset, color::bold, color::reset, engine_info.c_str(), color::bold, color::reset, llm_info.c_str(), + color::bold, color::reset, vlm_info.c_str(), color::bold, color::reset, stt_info.c_str(), color::bold, color::reset, tts_info.c_str(), color::bold, color::reset, @@ -677,5 +782,24 @@ inline int cmd_info() { if (!any_tts) fprintf(stdout, " (none — run: rcli setup)\n"); fprintf(stdout, "\n"); + // Installed VLM + fprintf(stdout, " %sInstalled VLM:%s\n", color::bold, color::reset); + bool any_vlm = false; + for (auto& m : vlm_all_info) { + if (rcli::is_vlm_model_installed(models_dir, m)) { + char size_str[32]; + int total_mb = m.model_size_mb + m.mmproj_size_mb; + if (total_mb >= 1024) + snprintf(size_str, sizeof(size_str), "%.1f GB", total_mb / 1024.0); + else + snprintf(size_str, sizeof(size_str), "%d MB", total_mb); + fprintf(stdout, " %-28s %-7s installed\n", + m.name.c_str(), size_str); + any_vlm = true; + } + } + if (!any_vlm) fprintf(stdout, " (none — run: rcli models vlm)\n"); + fprintf(stdout, "\n"); + return 0; } diff --git a/src/cli/tui_app.h b/src/cli/tui_app.h index 6ec4ed1..57fc7b6 100644 --- a/src/cli/tui_app.h +++ b/src/cli/tui_app.h @@ -12,6 +12,9 @@ #include "models/stt_model_registry.h" #include "actions/action_registry.h" #include "engines/metalrt_loader.h" +#include "engines/vlm_engine.h" +#include "audio/camera_capture.h" +#include "models/vlm_model_registry.h" #include "core/log.h" #include "core/personality.h" @@ -432,7 +435,11 @@ class TuiApp { if (c == "r" || c == "R") { enter_rag_mode(); return true; } if (c == "d" || c == "D") { close_all_panels(); enter_cleanup_mode(); return true; } if (c == "p" || c == "P") { enter_personality_mode(); return true; } - // V key: voice mode removed — push-to-talk via SPACE is always active + // V key: capture photo from camera and analyze with VLM + if (c == "v" || c == "V") { + run_camera_vlm("Describe what you see in this photo in detail."); + return true; + } if (c == "t" || c == "T") { tool_trace_enabled_ = !tool_trace_enabled_.load(std::memory_order_relaxed); add_system_message(tool_trace_enabled_ ? "Tool call trace: ON" : "Tool call trace: OFF"); @@ -1069,6 +1076,7 @@ class TuiApp { else right.push_back(text("[A] actions ") | dim); right.push_back(text("[C] convo ") | dim); + right.push_back(text("[V] camera ") | dim); right.push_back(text("[R] RAG ") | dim); right.push_back(text("[P] personality ") | dim); right.push_back(text("[D] cleanup ") | dim); @@ -1501,6 +1509,21 @@ class TuiApp { e.archive_dir = v.archive_dir; models_entries_.push_back(e); } + + // VLM models (vision) + auto vlm_all = rcli::all_vlm_models(); + { ModelEntry h; h.name = "VLM Models (Vision)"; h.is_header = true; models_entries_.push_back(h); } + for (auto& m : vlm_all) { + ModelEntry e; + e.name = m.name; e.id = m.id; e.modality = "VLM"; + e.size_mb = m.model_size_mb + m.mmproj_size_mb; + e.installed = rcli::is_vlm_model_installed(dir, m); + e.is_active = false; // VLM is lazy-loaded, no "active" concept + e.is_default = m.is_default; e.is_recommended = m.is_default; + e.description = m.description; + e.url = m.model_url; e.filename = m.model_filename; e.is_archive = false; + models_entries_.push_back(e); + } } for (int i = 0; i < (int)models_entries_.size(); i++) { @@ -1666,7 +1689,20 @@ class TuiApp { bool archive = e.is_archive; std::string archive_dir_name = e.archive_dir; - std::thread([this, idx, dir, url, fname, mod, id, nm, archive, archive_dir_name]() { + // For VLM, also capture the mmproj URL + std::string vlm_mmproj_url, vlm_mmproj_fname; + if (mod == "VLM") { + auto vlm_models = rcli::all_vlm_models(); + for (auto& vm : vlm_models) { + if (vm.id == id) { + vlm_mmproj_url = vm.mmproj_url; + vlm_mmproj_fname = vm.mmproj_filename; + break; + } + } + } + std::thread([this, idx, dir, url, fname, mod, id, nm, archive, archive_dir_name, + vlm_mmproj_url, vlm_mmproj_fname]() { int rc; if (archive) { rc = system(("curl -sL '" + url + "' | tar xj -C '" + dir + "' 2>/dev/null").c_str()); @@ -1677,6 +1713,12 @@ class TuiApp { if (stat(src.c_str(), &st) == 0 && stat(dst.c_str(), &st) != 0) rename(src.c_str(), dst.c_str()); } + } else if (mod == "VLM" && !vlm_mmproj_url.empty()) { + // VLM needs two files: language model + mmproj + rc = system(("curl -sL -o '" + dir + "/" + fname + "' '" + url + "' 2>/dev/null").c_str()); + if (rc == 0) { + rc = system(("curl -sL -o '" + dir + "/" + vlm_mmproj_fname + "' '" + vlm_mmproj_url + "' 2>/dev/null").c_str()); + } } else { rc = system(("curl -sL -o '" + dir + "/" + fname + "' '" + url + "' 2>/dev/null").c_str()); } @@ -1698,6 +1740,9 @@ class TuiApp { } else { if (mod == "STT") rcli::write_selected_stt_id(id); else if (mod == "TTS") rcli::write_selected_tts_id(id); + else if (mod == "VLM") { + // VLM doesn't need selection — just mark installed + } models_message_ = "Downloaded & selected: " + nm + ". Restart RCLI to apply."; models_msg_color_ = theme_.success; } @@ -2143,6 +2188,44 @@ class TuiApp { // process_input // ==================================================================== + void run_camera_vlm(const std::string& prompt) { + add_system_message("Capturing photo from camera..."); + voice_state_ = VoiceState::THINKING; + std::string prompt_copy = prompt; + std::thread([this, prompt_copy]() { + std::string photo_path = "/tmp/rcli_camera_" + + std::to_string(std::chrono::system_clock::now().time_since_epoch().count()) + ".jpg"; + int rc = camera_capture_photo(photo_path.c_str()); + if (rc != 0) { + add_response("(Camera capture failed. Check camera permissions in System Settings > Privacy & Security > Camera.)", ""); + voice_state_ = VoiceState::IDLE; + screen_->Post(Event::Custom); + return; + } + add_system_message("Photo captured! Analyzing with VLM..."); + screen_->Post(Event::Custom); + const char* response = rcli_vlm_analyze( + engine_, photo_path.c_str(), prompt_copy.c_str()); + if (response && response[0]) { + add_response(response, "VLM"); + // Show performance stats + RCLIVlmStats stats; + if (rcli_vlm_get_stats(engine_, &stats) == 0) { + char buf[128]; + snprintf(buf, sizeof(buf), "⚡ %.1f tok/s | %d tokens | %.1fs total", + stats.gen_tok_per_sec, stats.generated_tokens, stats.total_time_sec); + add_system_message(buf); + } + } else { + add_response("(VLM analysis failed. Install a VLM model: rcli models vlm)", ""); + } + voice_state_ = VoiceState::IDLE; + // Open the captured photo in Preview + system(("open '" + photo_path + "' &").c_str()); + screen_->Post(Event::Custom); + }).detach(); + } + void process_input(const std::string& input) { if (input.empty()) return; @@ -2202,6 +2285,10 @@ class TuiApp { return; } + if (cmd == "camera" || cmd == "photo" || cmd == "webcam") { + run_camera_vlm("Describe what you see in this photo in detail."); + return; + } if (!engine_) { add_response("Engine not initialized.", ""); @@ -2340,6 +2427,34 @@ class TuiApp { struct stat path_st; if (!resolved.empty() && resolved[0] == '/' && stat(resolved.c_str(), &path_st) == 0) { + // Check if this is an image file → route to VLM analysis + if (S_ISREG(path_st.st_mode) && rastack::VlmEngine::is_supported_image(resolved)) { + add_system_message("Image detected: " + resolved); + add_system_message("Analyzing image with VLM..."); + voice_state_ = VoiceState::THINKING; + std::string path_copy = resolved; + std::thread([this, path_copy]() { + const char* response = rcli_vlm_analyze( + engine_, path_copy.c_str(), "Describe this image in detail."); + if (response && response[0]) { + add_response(response, "VLM"); + RCLIVlmStats stats; + if (rcli_vlm_get_stats(engine_, &stats) == 0) { + char buf[128]; + snprintf(buf, sizeof(buf), "⚡ %.1f tok/s | %d tokens | %.1fs total", + stats.gen_tok_per_sec, stats.generated_tokens, stats.total_time_sec); + add_system_message(buf); + } + } else { + add_response("(VLM analysis failed)", ""); + } + voice_state_ = VoiceState::IDLE; + screen_->Post(Event::Custom); + }).detach(); + return; + } + + // Non-image path → RAG ingest add_system_message("Detected path: " + resolved); add_system_message("Indexing for RAG... this may take a moment."); std::string path_copy = resolved; diff --git a/src/engines/vlm_engine.cpp b/src/engines/vlm_engine.cpp new file mode 100644 index 0000000..0238063 --- /dev/null +++ b/src/engines/vlm_engine.cpp @@ -0,0 +1,264 @@ +#include "engines/vlm_engine.h" +#include "core/log.h" +#include "llama.h" +#include "ggml.h" +#include "ggml-backend.h" +#include "mtmd.h" +#include "mtmd-helper.h" +#include +#include + +namespace rastack { + +VlmEngine::VlmEngine() = default; + +VlmEngine::~VlmEngine() { + shutdown(); +} + +void VlmEngine::shutdown() { + if (ctx_mtmd_) { mtmd_free(ctx_mtmd_); ctx_mtmd_ = nullptr; } + if (sampler_) { llama_sampler_free(sampler_); sampler_ = nullptr; } + if (ctx_) { llama_free(ctx_); ctx_ = nullptr; } + if (model_) { llama_model_free(model_); model_ = nullptr; } + vocab_ = nullptr; + initialized_ = false; + stats_ = VlmStats{}; + LOG_DEBUG("VLM", "Shutdown complete"); +} + +bool VlmEngine::init(const VlmConfig& config) { + if (initialized_) shutdown(); + + config_ = config; + + // Initialize backend (loads Metal, etc.) + ggml_backend_load_all(); + + // Load language model + llama_model_params model_params = llama_model_default_params(); + model_params.n_gpu_layers = config.n_gpu_layers; + model_params.use_mmap = config.use_mmap; + model_params.use_mlock = config.use_mlock; + + LOG_DEBUG("VLM", "Loading VLM model: %s", config.model_path.c_str()); + model_ = llama_model_load_from_file(config.model_path.c_str(), model_params); + if (!model_) { + LOG_ERROR("VLM", "Failed to load VLM model"); + return false; + } + + vocab_ = llama_model_get_vocab(model_); + + // Create inference context + llama_context_params ctx_params = llama_context_default_params(); + ctx_params.n_ctx = config.n_ctx; + ctx_params.n_batch = config.n_batch; + ctx_params.n_threads = config.n_threads; + ctx_params.n_threads_batch = config.n_threads_batch; + ctx_params.no_perf = false; + ctx_params.flash_attn_type = config.flash_attn ? LLAMA_FLASH_ATTN_TYPE_AUTO : LLAMA_FLASH_ATTN_TYPE_DISABLED; + + ctx_ = llama_init_from_model(model_, ctx_params); + if (!ctx_) { + LOG_ERROR("VLM", "Failed to create VLM context"); + llama_model_free(model_); + model_ = nullptr; + return false; + } + + // Initialize mtmd (vision projector) + LOG_DEBUG("VLM", "Loading vision projector: %s", config.mmproj_path.c_str()); + mtmd_context_params mtmd_params = mtmd_context_params_default(); + mtmd_params.use_gpu = (config.n_gpu_layers > 0); + mtmd_params.n_threads = config.n_threads_batch; + mtmd_params.flash_attn_type = config.flash_attn ? LLAMA_FLASH_ATTN_TYPE_AUTO : LLAMA_FLASH_ATTN_TYPE_DISABLED; + + ctx_mtmd_ = mtmd_init_from_file(config.mmproj_path.c_str(), model_, mtmd_params); + if (!ctx_mtmd_) { + LOG_ERROR("VLM", "Failed to load vision projector (mmproj)"); + llama_free(ctx_); + llama_model_free(model_); + ctx_ = nullptr; + model_ = nullptr; + return false; + } + + if (!mtmd_support_vision(ctx_mtmd_)) { + LOG_ERROR("VLM", "Model does not support vision input"); + mtmd_free(ctx_mtmd_); + llama_free(ctx_); + llama_model_free(model_); + ctx_mtmd_ = nullptr; + ctx_ = nullptr; + model_ = nullptr; + return false; + } + + // Setup sampler chain + auto sparams = llama_sampler_chain_default_params(); + sampler_ = llama_sampler_chain_init(sparams); + if (config.temperature > 0.0f) { + llama_sampler_chain_add(sampler_, llama_sampler_init_temp(config.temperature)); + llama_sampler_chain_add(sampler_, llama_sampler_init_top_k(config.top_k)); + llama_sampler_chain_add(sampler_, llama_sampler_init_top_p(config.top_p, 1)); + llama_sampler_chain_add(sampler_, llama_sampler_init_dist(LLAMA_DEFAULT_SEED)); + } else { + llama_sampler_chain_add(sampler_, llama_sampler_init_greedy()); + } + + initialized_ = true; + LOG_INFO("VLM", "Initialized (vision support: yes)"); + return true; +} + +std::string VlmEngine::analyze_image(const std::string& image_path, + const std::string& prompt, + TokenCallback on_token) { + if (!initialized_) return ""; + + cancelled_.store(false, std::memory_order_relaxed); + stats_ = VlmStats{}; + + // Clear KV cache + llama_memory_clear(llama_get_memory(ctx_), true); + if (sampler_) llama_sampler_reset(sampler_); + + // 1. Load image + LOG_DEBUG("VLM", "Loading image: %s", image_path.c_str()); + mtmd_bitmap* bitmap = mtmd_helper_bitmap_init_from_file(ctx_mtmd_, image_path.c_str()); + if (!bitmap) { + LOG_ERROR("VLM", "Failed to load image: %s", image_path.c_str()); + return ""; + } + + // 2. Build prompt with media marker using ChatML template (Qwen3-VL format) + // The model expects: <|im_start|>system\n...<|im_end|>\n<|im_start|>user\n\nprompt<|im_end|>\n<|im_start|>assistant\n + std::string marker = mtmd_default_marker(); + std::string full_prompt = + "<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n" + "<|im_start|>user\n" + marker + "\n" + prompt + "<|im_end|>\n" + "<|im_start|>assistant\n"; + + mtmd_input_text input_text; + input_text.text = full_prompt.c_str(); + input_text.add_special = true; + input_text.parse_special = true; + + // 3. Tokenize (combines text tokens + image tokens) + mtmd_input_chunks* chunks = mtmd_input_chunks_init(); + const mtmd_bitmap* bitmap_ptr = bitmap; + + int64_t t_encode_start = now_us(); + int32_t tokenize_result = mtmd_tokenize(ctx_mtmd_, chunks, &input_text, &bitmap_ptr, 1); + if (tokenize_result != 0) { + LOG_ERROR("VLM", "Failed to tokenize image+text (error=%d)", tokenize_result); + mtmd_input_chunks_free(chunks); + mtmd_bitmap_free(bitmap); + return ""; + } + + size_t n_tokens = mtmd_helper_get_n_tokens(chunks); + stats_.prompt_tokens = n_tokens; + LOG_DEBUG("VLM", "Tokenized: %zu total tokens (text + image)", n_tokens); + + // 4. Evaluate all chunks (text + image encoding + decoding) + int64_t t_prompt_start = now_us(); + llama_pos n_past = 0; + int32_t eval_result = mtmd_helper_eval_chunks( + ctx_mtmd_, ctx_, chunks, + n_past, // n_past + 0, // seq_id + config_.n_batch, // n_batch + true, // logits_last + &n_past // updated n_past + ); + + stats_.image_encode_us = now_us() - t_encode_start; + stats_.prompt_eval_us = now_us() - t_prompt_start; + + // Clean up image resources + mtmd_input_chunks_free(chunks); + mtmd_bitmap_free(bitmap); + + if (eval_result != 0) { + LOG_ERROR("VLM", "Failed to evaluate image+text chunks (error=%d)", eval_result); + return ""; + } + + LOG_DEBUG("VLM", "Image encoded in %.1fms, prompt eval in %.1fms", + stats_.image_encode_us / 1000.0, stats_.prompt_eval_us / 1000.0); + + // 5. Generate tokens (same pattern as LlmEngine::generate) + std::string result; + int64_t t_gen_start = now_us(); + bool first_token = true; + + for (int i = 0; i < config_.max_tokens; i++) { + if (cancelled_.load(std::memory_order_relaxed)) { + LOG_DEBUG("VLM", "Generation cancelled"); + break; + } + + int32_t new_token = llama_sampler_sample(sampler_, ctx_, -1); + + if (first_token) { + stats_.first_token_us = now_us() - t_prompt_start; + first_token = false; + } + + if (llama_vocab_is_eog(vocab_, new_token)) { + break; + } + + // Decode token to text + char buf[256]; + int n = llama_token_to_piece(vocab_, new_token, buf, sizeof(buf), 0, true); + if (n < 0) continue; + std::string piece(buf, n); + + result += piece; + stats_.generated_tokens++; + + if (on_token) { + TokenOutput tok; + tok.text = piece; + tok.token_id = new_token; + tok.is_eos = false; + tok.is_tool_call = false; + on_token(tok); + } + + // Feed token back for next iteration + llama_batch batch = llama_batch_get_one(&new_token, 1); + if (llama_decode(ctx_, batch) != 0) { + LOG_ERROR("VLM", "Failed to decode token"); + break; + } + } + + stats_.generation_us = now_us() - t_gen_start; + + LOG_DEBUG("VLM", "Generated %lld tokens (%.1f tok/s), first token: %.1fms", + stats_.generated_tokens, stats_.gen_tps(), + stats_.first_token_us / 1000.0); + + return result; +} + +bool VlmEngine::is_supported_image(const std::string& path) { + // Get extension (case-insensitive) + auto dot = path.rfind('.'); + if (dot == std::string::npos) return false; + + std::string ext = path.substr(dot); + // Convert to lowercase + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + + return ext == ".jpg" || ext == ".jpeg" || + ext == ".png" || ext == ".bmp" || + ext == ".gif" || ext == ".webp" || + ext == ".tga"; +} + +} // namespace rastack diff --git a/src/engines/vlm_engine.h b/src/engines/vlm_engine.h new file mode 100644 index 0000000..57739a2 --- /dev/null +++ b/src/engines/vlm_engine.h @@ -0,0 +1,88 @@ +#pragma once + +#include "core/types.h" +#include +#include +#include + +// Forward declare llama types +struct llama_model; +struct llama_context; +struct llama_sampler; +struct llama_vocab; + +// Forward declare mtmd types +struct mtmd_context; + +namespace rastack { + +struct VlmConfig { + std::string model_path; // Path to VLM language model GGUF + std::string mmproj_path; // Path to vision projector (mmproj) GGUF + int n_gpu_layers = 99; + int n_ctx = 4096; // VLM needs larger context for image tokens + int n_batch = 512; + int n_threads = 1; + int n_threads_batch = 8; + float temperature = 0.7f; + float top_p = 0.9f; + int top_k = 40; + int max_tokens = 512; + bool use_mmap = true; + bool use_mlock = false; + bool flash_attn = true; +}; + +struct VlmStats { + int64_t prompt_tokens = 0; + int64_t generated_tokens = 0; + int64_t prompt_eval_us = 0; + int64_t generation_us = 0; + int64_t image_encode_us = 0; // Time spent encoding the image + double prompt_tps() const { return prompt_tokens > 0 ? prompt_tokens * 1e6 / prompt_eval_us : 0; } + double gen_tps() const { return generated_tokens > 0 ? generated_tokens * 1e6 / generation_us : 0; } + int64_t first_token_us = 0; +}; + +class VlmEngine { +public: + VlmEngine(); + ~VlmEngine(); + + // Initialize model + vision projector + bool init(const VlmConfig& config); + + // Release all resources + void shutdown(); + + // Analyze an image with a text prompt + // Returns the generated description/analysis text + std::string analyze_image(const std::string& image_path, + const std::string& prompt, + TokenCallback on_token = nullptr); + + // Cancel ongoing generation + void cancel() { cancelled_.store(true, std::memory_order_release); } + + // Get stats from last generation + const VlmStats& last_stats() const { return stats_; } + + bool is_initialized() const { return initialized_; } + + // Check if an image file is a supported format + static bool is_supported_image(const std::string& path); + +private: + llama_model* model_ = nullptr; + llama_context* ctx_ = nullptr; + llama_sampler* sampler_ = nullptr; + const llama_vocab* vocab_ = nullptr; + mtmd_context* ctx_mtmd_ = nullptr; + + VlmConfig config_; + VlmStats stats_; + bool initialized_ = false; + std::atomic cancelled_{false}; +}; + +} // namespace rastack diff --git a/src/models/vlm_model_registry.h b/src/models/vlm_model_registry.h new file mode 100644 index 0000000..3846ae2 --- /dev/null +++ b/src/models/vlm_model_registry.h @@ -0,0 +1,82 @@ +#pragma once +// ============================================================================= +// RCLI VLM Model Registry +// ============================================================================= +// +// Registry of supported VLM (Vision Language Model) models. +// Each model consists of a language model GGUF + an mmproj (vision projector) GGUF. +// +// ============================================================================= + +#include +#include +#include + +namespace rcli { + +struct VlmModelDef { + std::string id; // Unique slug: "smolvlm-500m" + std::string name; // Display name: "SmolVLM 500M Instruct" + std::string model_filename; // Language model GGUF filename + std::string mmproj_filename; // Vision projector GGUF filename + std::string model_url; // HuggingFace download URL for language model + std::string mmproj_url; // HuggingFace download URL for mmproj + int model_size_mb; // Approximate model download size + int mmproj_size_mb; // Approximate mmproj download size + std::string description; // One-line description + bool is_default; // Default model for `rcli vlm` +}; + +inline std::vector all_vlm_models() { + return { + { + /* id */ "qwen3-vl-2b", + /* name */ "Qwen3 VL 2B Instruct", + /* model_filename */ "Qwen3-VL-2B-Instruct-Q8_0.gguf", + /* mmproj_filename */ "mmproj-Qwen3-VL-2B-Instruct-Q8_0.gguf", + /* model_url */ "https://huggingface.co/ggml-org/Qwen3-VL-2B-Instruct-GGUF/resolve/main/Qwen3-VL-2B-Instruct-Q8_0.gguf", + /* mmproj_url */ "https://huggingface.co/ggml-org/Qwen3-VL-2B-Instruct-GGUF/resolve/main/mmproj-Qwen3-VL-2B-Instruct-Q8_0.gguf", + /* model_size_mb */ 1830, + /* mmproj_size_mb */ 445, + /* description */ "Qwen3 Vision-Language model. High quality image analysis.", + /* is_default */ true, + }, + { + /* id */ "smolvlm-500m", + /* name */ "SmolVLM 500M Instruct", + /* model_filename */ "SmolVLM-500M-Instruct-Q8_0.gguf", + /* mmproj_filename */ "mmproj-SmolVLM-500M-Instruct-Q8_0.gguf", + /* model_url */ "https://huggingface.co/ggml-org/SmolVLM-500M-Instruct-GGUF/resolve/main/SmolVLM-500M-Instruct-Q8_0.gguf", + /* mmproj_url */ "https://huggingface.co/ggml-org/SmolVLM-500M-Instruct-GGUF/resolve/main/mmproj-SmolVLM-500M-Instruct-Q8_0.gguf", + /* model_size_mb */ 437, + /* mmproj_size_mb */ 109, + /* description */ "Smallest VLM. Fast image analysis, lower quality.", + /* is_default */ false, + }, + }; +} + +inline std::pair get_default_vlm_model() { + auto models = all_vlm_models(); + for (auto& m : models) { + if (m.is_default) return {true, m}; + } + return {false, {}}; +} + +inline bool is_vlm_model_installed(const std::string& models_dir, const VlmModelDef& m) { + std::string model_path = models_dir + "/" + m.model_filename; + std::string mmproj_path = models_dir + "/" + m.mmproj_filename; + return access(model_path.c_str(), R_OK) == 0 && + access(mmproj_path.c_str(), R_OK) == 0; +} + +inline std::pair find_installed_vlm(const std::string& models_dir) { + auto models = all_vlm_models(); + for (auto& m : models) { + if (is_vlm_model_installed(models_dir, m)) return {true, m}; + } + return {false, {}}; +} + +} // namespace rcli diff --git a/src/pipeline/orchestrator.h b/src/pipeline/orchestrator.h index 8648374..6122815 100644 --- a/src/pipeline/orchestrator.h +++ b/src/pipeline/orchestrator.h @@ -6,6 +6,7 @@ #include "core/ring_buffer.h" #include "engines/stt_engine.h" #include "engines/llm_engine.h" +#include "engines/vlm_engine.h" #include "engines/metalrt_engine.h" #include "engines/metalrt_stt_engine.h" #include "engines/metalrt_tts_engine.h" @@ -93,6 +94,7 @@ class Orchestrator { VadEngine& vad() { return vad_; } ToolEngine& tools() { return tools_; } AudioIO& audio() { return audio_; } + VlmEngine& vlm() { return vlm_; } RingBuffer* playback_ring_buffer() { return playback_rb_.get(); } // Active LLM backend @@ -168,6 +170,7 @@ class Orchestrator { SttEngine stt_; OfflineSttEngine offline_stt_; // Whisper for file pipeline LlmEngine llm_; + VlmEngine vlm_; MetalRTEngine metalrt_; MetalRTSttEngine metalrt_stt_; MetalRTTtsEngine metalrt_tts_; diff --git a/test_image.jpg b/test_image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..776c16fcc99d53df12653a8d0bfea44868d07139 GIT binary patch literal 46103 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 Date: Fri, 13 Mar 2026 18:07:33 +0530 Subject: [PATCH 02/16] add screen capture module with visual overlay support Implements screencapture CLI wrapper, active window detection, terminal PID skip logic, app tracking via polling, and visual overlay mode via a subprocess helper (rcli_overlay) communicating over stdin/stdout pipes. --- src/audio/screen_capture.h | 42 ++++ src/audio/screen_capture.mm | 425 ++++++++++++++++++++++++++++++++++++ test_image.jpg | Bin 46103 -> 0 bytes 3 files changed, 467 insertions(+) create mode 100644 src/audio/screen_capture.h create mode 100644 src/audio/screen_capture.mm delete mode 100644 test_image.jpg diff --git a/src/audio/screen_capture.h b/src/audio/screen_capture.h new file mode 100644 index 0000000..0cc5421 --- /dev/null +++ b/src/audio/screen_capture.h @@ -0,0 +1,42 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +// --- Visual Mode (overlay frame) --- + +// Show the visual overlay window. User can drag/resize it over content. +// x, y, w, h: initial position and size in screen coordinates (0 = defaults). +void screen_capture_show_overlay(int x, int y, int w, int h); + +// Hide the visual overlay window. +void screen_capture_hide_overlay(void); + +// Returns 1 if the overlay is currently visible. +int screen_capture_overlay_active(void); + +// Capture the screen region behind the overlay (hides overlay briefly). +// Returns 0 on success, -1 on failure. +int screen_capture_overlay_region(const char* output_path); + +// --- Legacy capture functions --- + +// Capture the frontmost/active window and save as JPEG. +int screen_capture_active_window(const char* output_path); + +// Capture the window behind our own terminal (for voice triggers). +int screen_capture_behind_terminal(const char* output_path); + +// Capture the entire main display and save as JPEG (fallback). +int screen_capture_full_screen(const char* output_path); + +// Convenience: tries overlay if active, then active window, then full screen. +int screen_capture_screenshot(const char* output_path); + +// Get the name of the app targeted by screen_capture_behind_terminal. +const char* screen_capture_target_app_name(char* buf, int buf_size); + +#ifdef __cplusplus +} +#endif diff --git a/src/audio/screen_capture.mm b/src/audio/screen_capture.mm new file mode 100644 index 0000000..e2f3ea8 --- /dev/null +++ b/src/audio/screen_capture.mm @@ -0,0 +1,425 @@ +#import +#import +#include "screen_capture.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char** environ; + +// --------------------------------------------------------------------------- +// Helper: downscale a JPEG on disk if it exceeds max dimension (for VLM) +// --------------------------------------------------------------------------- +static void downscale_jpeg_if_needed(const char* path, int max_dim) { + @autoreleasepool { + NSString *nsPath = [NSString stringWithUTF8String:path]; + NSData *data = [NSData dataWithContentsOfFile:nsPath]; + if (!data) return; + + NSBitmapImageRep *srcRep = [NSBitmapImageRep imageRepWithData:data]; + if (!srcRep) return; + + NSInteger w = srcRep.pixelsWide; + NSInteger h = srcRep.pixelsHigh; + if (w <= max_dim && h <= max_dim) return; + + CGFloat scale = (CGFloat)max_dim / fmax((CGFloat)w, (CGFloat)h); + NSInteger nw = (NSInteger)floor(w * scale); + NSInteger nh = (NSInteger)floor(h * scale); + + NSBitmapImageRep *dstRep = [[NSBitmapImageRep alloc] + initWithBitmapDataPlanes:NULL + pixelsWide:nw + pixelsHigh:nh + bitsPerSample:8 + samplesPerPixel:4 + hasAlpha:YES + isPlanar:NO + colorSpaceName:NSCalibratedRGBColorSpace + bytesPerRow:0 + bitsPerPixel:0]; + + [NSGraphicsContext saveGraphicsState]; + NSGraphicsContext *ctx = [NSGraphicsContext graphicsContextWithBitmapImageRep:dstRep]; + [NSGraphicsContext setCurrentContext:ctx]; + [ctx setImageInterpolation:NSImageInterpolationHigh]; + + NSImage *nsImage = [[NSImage alloc] initWithSize:NSMakeSize((CGFloat)w, (CGFloat)h)]; + [nsImage addRepresentation:srcRep]; + [nsImage drawInRect:NSMakeRect(0, 0, (CGFloat)nw, (CGFloat)nh) + fromRect:NSZeroRect + operation:NSCompositingOperationCopy + fraction:1.0]; + + [NSGraphicsContext restoreGraphicsState]; + + NSData *jpegData = [dstRep representationUsingType:NSBitmapImageFileTypeJPEG + properties:@{NSImageCompressionFactor: @0.85}]; + if (jpegData && jpegData.length > 0) { + [jpegData writeToFile:nsPath atomically:YES]; + } + } +} + +// --------------------------------------------------------------------------- +// Helper: run screencapture with given args, verify output +// --------------------------------------------------------------------------- +static int run_screencapture(const char* const argv[], const char* output_path) { + pid_t pid; + int status = 0; + if (posix_spawnp(&pid, "screencapture", nullptr, nullptr, + const_cast(argv), environ) != 0) { + return -1; + } + waitpid(pid, &status, 0); + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return -1; + + struct stat st; + if (stat(output_path, &st) != 0 || st.st_size == 0) return -1; + + downscale_jpeg_if_needed(output_path, 2048); + return 0; +} + +// =========================================================================== +// Visual overlay — spawns rcli_overlay helper process (separate Cocoa app) +// because AppKit window management requires the main thread, which FTXUI owns. +// Communication via stdin/stdout pipes. +// =========================================================================== + +static pid_t g_overlay_pid = 0; +static FILE *g_overlay_stdin = nullptr; // we write commands here +static FILE *g_overlay_stdout = nullptr; // we read responses here +static std::atomic g_overlay_visible{false}; + +// Find rcli_overlay binary next to the rcli binary +static std::string find_overlay_binary() { + // Try next to our own executable + char path[1024]; + uint32_t size = sizeof(path); + if (_NSGetExecutablePath(path, &size) == 0) { + std::string dir(path); + auto slash = dir.rfind('/'); + if (slash != std::string::npos) { + std::string candidate = dir.substr(0, slash + 1) + "rcli_overlay"; + if (access(candidate.c_str(), X_OK) == 0) return candidate; + } + } + // Fallback: try PATH + return "rcli_overlay"; +} + +// Send a command to the overlay process and read the response line +static std::string overlay_cmd(const char* cmd) { + if (!g_overlay_stdin || !g_overlay_stdout) return ""; + fprintf(g_overlay_stdin, "%s\n", cmd); + fflush(g_overlay_stdin); + char buf[256] = {0}; + if (fgets(buf, sizeof(buf), g_overlay_stdout)) { + // Strip trailing newline + size_t len = strlen(buf); + if (len > 0 && buf[len-1] == '\n') buf[len-1] = '\0'; + return std::string(buf); + } + return ""; +} + +void screen_capture_show_overlay(int x, int y, int w, int h) { + (void)x; (void)y; (void)w; (void)h; // TODO: pass initial rect to helper + + if (g_overlay_pid > 0) { + // Already running — just return + return; + } + + std::string binary = find_overlay_binary(); + + // Create pipes: parent→child stdin, child→parent stdout + int pipe_in[2], pipe_out[2]; + if (pipe(pipe_in) != 0 || pipe(pipe_out) != 0) return; + + pid_t pid = fork(); + if (pid == 0) { + // Child: wire up pipes + close(pipe_in[1]); // close write end of stdin pipe + close(pipe_out[0]); // close read end of stdout pipe + dup2(pipe_in[0], STDIN_FILENO); + dup2(pipe_out[1], STDOUT_FILENO); + close(pipe_in[0]); + close(pipe_out[1]); + // Redirect stderr to /dev/null to keep terminal clean + int devnull = open("/dev/null", O_WRONLY); + if (devnull >= 0) { dup2(devnull, STDERR_FILENO); close(devnull); } + execl(binary.c_str(), "rcli_overlay", nullptr); + _exit(1); + } + + // Parent + close(pipe_in[0]); + close(pipe_out[1]); + g_overlay_pid = pid; + g_overlay_stdin = fdopen(pipe_in[1], "w"); + g_overlay_stdout = fdopen(pipe_out[0], "r"); + + // Wait for "ready" from child + char buf[64] = {0}; + if (g_overlay_stdout && fgets(buf, sizeof(buf), g_overlay_stdout)) { + g_overlay_visible.store(true); + } +} + +void screen_capture_hide_overlay(void) { + if (g_overlay_pid <= 0) return; + + overlay_cmd("quit"); + + // Clean up + if (g_overlay_stdin) { fclose(g_overlay_stdin); g_overlay_stdin = nullptr; } + if (g_overlay_stdout) { fclose(g_overlay_stdout); g_overlay_stdout = nullptr; } + int status; + waitpid(g_overlay_pid, &status, 0); + g_overlay_pid = 0; + g_overlay_visible.store(false); +} + +int screen_capture_overlay_active(void) { + return g_overlay_visible.load() ? 1 : 0; +} + +int screen_capture_overlay_region(const char* output_path) { + if (!g_overlay_visible.load() || g_overlay_pid <= 0) return -1; + + // Get frame coordinates (top-left origin) + std::string frame_str = overlay_cmd("frame"); + if (frame_str.empty()) return -1; + + // Hide overlay for capture + overlay_cmd("hide"); + + // Capture the region + char region[128]; + strlcpy(region, frame_str.c_str(), sizeof(region)); + const char* argv[] = { + "screencapture", "-x", "-t", "jpg", "-R", region, output_path, nullptr + }; + int result = run_screencapture(argv, output_path); + + // Show overlay again + overlay_cmd("show"); + + return result; +} + +// --------------------------------------------------------------------------- +// Track the previously active app (before our terminal got focus) +// Polls frontmostApplication every 200ms on a background thread. +// NSWorkspace notifications don't work in CLI apps (no NSApplication run loop). +// --------------------------------------------------------------------------- + +static std::atomic g_prev_active_pid{0}; +static pid_t g_our_terminal_pid = 0; +static char g_prev_app_name[256] = {0}; +static std::mutex g_name_mutex; + +// Walk up process tree to find which ancestor owns a window (our terminal) +static pid_t find_terminal_pid() { + @autoreleasepool { + pid_t cur = getpid(); + pid_t ancestors[8]; + int n = 0; + while (cur > 1 && n < 8) { + ancestors[n++] = cur; + struct kinfo_proc kp; + size_t length = sizeof(kp); + int mib[4] = { CTL_KERN, KERN_PROC, KERN_PROC_PID, cur }; + if (sysctl(mib, 4, &kp, &length, NULL, 0) != 0) break; + pid_t ppid = kp.kp_eproc.e_ppid; + if (ppid == cur) break; + cur = ppid; + } + + // Check which ancestor owns on-screen windows — that's the terminal + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wdeprecated-declarations" + CFArrayRef windowList = CGWindowListCopyWindowInfo( + kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, + kCGNullWindowID); + #pragma clang diagnostic pop + if (windowList) { + NSArray *windows = CFBridgingRelease(windowList); + for (int i = n - 1; i >= 0; i--) { + for (NSDictionary *info in windows) { + pid_t ownerPid = [[info objectForKey:(NSString *)kCGWindowOwnerPID] intValue]; + if (ownerPid == ancestors[i]) { + return ancestors[i]; + } + } + } + } + return (n >= 3) ? ancestors[2] : getppid(); + } +} + +// Background poller — tracks which non-terminal app is frontmost +__attribute__((constructor)) +static void start_app_tracking() { + @autoreleasepool { + g_our_terminal_pid = find_terminal_pid(); + + // Seed with current frontmost app if it's not our terminal + NSRunningApplication *front = [[NSWorkspace sharedWorkspace] frontmostApplication]; + if (front && front.processIdentifier != g_our_terminal_pid) { + g_prev_active_pid.store(front.processIdentifier, std::memory_order_relaxed); + NSString *name = front.localizedName ?: @"unknown"; + std::lock_guard lock(g_name_mutex); + strlcpy(g_prev_app_name, [name UTF8String], sizeof(g_prev_app_name)); + } + + // Poll frontmostApplication every 200ms on a background thread + std::thread([]() { + pthread_setname_np("rcli.app_tracker"); + pid_t last_seen_pid = 0; + while (true) { + @autoreleasepool { + NSRunningApplication *front = + [[NSWorkspace sharedWorkspace] frontmostApplication]; + if (front) { + pid_t pid = front.processIdentifier; + // If a non-terminal app is frontmost and it changed, record it + if (pid != g_our_terminal_pid && pid != last_seen_pid) { + last_seen_pid = pid; + g_prev_active_pid.store(pid, std::memory_order_relaxed); + NSString *name = front.localizedName ?: @"unknown"; + std::lock_guard lock(g_name_mutex); + strlcpy(g_prev_app_name, [name UTF8String], + sizeof(g_prev_app_name)); + } + } + } + usleep(200000); // 200ms + } + }).detach(); + } +} + +// --------------------------------------------------------------------------- +// Window lookup helpers +// --------------------------------------------------------------------------- +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + +static bool is_normal_window(NSDictionary *info) { + NSDictionary *bounds = [info objectForKey:(NSString *)kCGWindowBounds]; + if (!bounds) return false; + CGFloat w = [[bounds objectForKey:@"Width"] floatValue]; + CGFloat h = [[bounds objectForKey:@"Height"] floatValue]; + return (w >= 100 && h >= 100); +} + +// Find a normal window belonging to a specific PID +static CGWindowID find_window_for_pid(pid_t target_pid) { + CFArrayRef windowList = CGWindowListCopyWindowInfo( + kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, + kCGNullWindowID); + if (!windowList) return kCGNullWindowID; + + NSArray *windows = CFBridgingRelease(windowList); + for (NSDictionary *info in windows) { + pid_t ownerPid = [[info objectForKey:(NSString *)kCGWindowOwnerPID] intValue]; + if (ownerPid != target_pid) continue; + if (!is_normal_window(info)) continue; + return [[info objectForKey:(NSString *)kCGWindowNumber] unsignedIntValue]; + } + return kCGNullWindowID; +} + +// Find the frontmost normal window of the frontmost app +static CGWindowID get_frontmost_window_id() { + @autoreleasepool { + NSRunningApplication *frontApp = [[NSWorkspace sharedWorkspace] frontmostApplication]; + if (!frontApp) return kCGNullWindowID; + return find_window_for_pid(frontApp.processIdentifier); + } +} + +// Find the window of the previously active app (before terminal got focus) +static CGWindowID get_previous_app_window_id() { + @autoreleasepool { + pid_t prev_pid = g_prev_active_pid.load(std::memory_order_relaxed); + if (prev_pid <= 0) return kCGNullWindowID; + return find_window_for_pid(prev_pid); + } +} + +#pragma clang diagnostic pop + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +static int capture_window_id(CGWindowID wid, const char* output_path) { + if (wid == kCGNullWindowID) return -1; + char wid_str[32]; + snprintf(wid_str, sizeof(wid_str), "%u", wid); + const char* argv[] = { + "screencapture", "-x", "-t", "jpg", "-l", wid_str, output_path, nullptr + }; + return run_screencapture(argv, output_path); +} + +int screen_capture_active_window(const char* output_path) { + CGWindowID wid = get_frontmost_window_id(); + if (wid == kCGNullWindowID) { + return screen_capture_full_screen(output_path); + } + return capture_window_id(wid, output_path); +} + +int screen_capture_behind_terminal(const char* output_path) { + // Use the tracked previously-active app (before terminal got focus) + { + std::lock_guard lock(g_name_mutex); + pid_t prev = g_prev_active_pid.load(std::memory_order_relaxed); + fprintf(stderr, "[Screen] Targeting: %s (PID %d)\n", + g_prev_app_name[0] ? g_prev_app_name : "none", prev); + } + CGWindowID wid = get_previous_app_window_id(); + if (wid == kCGNullWindowID) { + fprintf(stderr, "[Screen] No previous app window found, falling back to full screen\n"); + return screen_capture_full_screen(output_path); + } + return capture_window_id(wid, output_path); +} + +int screen_capture_full_screen(const char* output_path) { + const char* argv[] = { + "screencapture", "-x", "-t", "jpg", output_path, nullptr + }; + return run_screencapture(argv, output_path); +} + +int screen_capture_screenshot(const char* output_path) { + // Prefer overlay if active, then active window, then full screen + if (screen_capture_overlay_active()) { + return screen_capture_overlay_region(output_path); + } + return screen_capture_active_window(output_path); +} + +const char* screen_capture_target_app_name(char* buf, int buf_size) { + std::lock_guard lock(g_name_mutex); + if (g_prev_app_name[0]) { + strlcpy(buf, g_prev_app_name, buf_size); + } else { + strlcpy(buf, "unknown", buf_size); + } + return buf; +} diff --git a/test_image.jpg b/test_image.jpg deleted file mode 100644 index 776c16fcc99d53df12653a8d0bfea44868d07139..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46103 zcmb5UWmp`+5;nTH!@}aexU;ysySux)ySu~U4nY$_kRS;VWO0`eG{G&9pa~M-lAQD1 zbD!_ut!K8ny1VwBnd+{Y?y7lSdEN$ml?!yT2LP0mSOBO1000?)2ZsQFdy)SBNa2wF zmHIC-*?(p87n$k5I`A(t2>yTiP6>$r^j`q~k>U&gi}Qa<6aXL-1%UX{0^#O@z-9eM zhI>JX&qe_dzO?th`~VgI)!~9b;41!u{`?F8z*qbSJ@VzW2>+G0{=&bIl#Yy&(o3o7 zWaH!KMy(_(OfAIE`@9a20iYryqaY)pqM)Flp`oH<;$vZAU|^EqfpG9CNU10(NXf~m z={OjvY1nAV$(aP0*&tlJyu4J5LSlm4q8vQD+Gnl}P$mX8LRKzw~#60RYToIL!e};J*X(QvZ+i zmjeLtsFsm`b^Z|ukx%kvv@~BT+n9e_-u-Jvgvk6o{>lGX0rgAqukHWt0lb_B!VVw# zPwlT30G#+w{>Kmpu>My;wT%CFdD*F!$AKqYO7@s5QkG1AXV_n&{>T5z{*Sp(Nv2z@ zuW(c^y~R2Y?h)uyC1J_VHm0ptE{i)~66gKh?czmmj%~`7u5xZnJG_qkRgPc&ifaA; z!IFf%U|S>8X@Qjx7(o^Bkg@qUHuR%M^V8oj2Ie;vDA#a{7Vz447G9IC`h4DI=qc@P z7Q3D^u(?=awh-q zqegCAsf|{b7P1sMkJ_N=G-|lIs+f2l;W4GTVEc1X#tRKB-H2Y@hbYC#~V@ZGXQc%h>vx*BTyvI!!ulC%nGWh|72 zREuB5Q7!t#U94yk`;nwo0hR!tA9TVpqn4?`S=sGArMlMFnffruzVLh*g;m;$mf>W6 z+P;{Eg?nGLn<+jzfQt+A*RF`L7mNOn5G4O2$5UIz|Fz4@BPH?gv_z0rY_OX8P&oGC zv(K!d=WLbIL0NLM=ud2f9?|{dl)YwC4I+tP4cz+`a;w9#vT~}y@Dz!%qVSx$17U?@ zue)CfM<{~<3d^H0EoWsB-t*K3oa(CbFB1TBrXQ{XfJ|)-fUAWkE`WE0OSFtngOgnR zH#jeY0_5ew1V&224dhqE)lv07Wn7@d)cWRS$fbH75C@I?pcuGLPl(@KtMLyWh2c(5O(oEKtfOxz&^zUS_h?P-^qp~rT3!qvoZ?&-0k|s$T zYFjQrpANfv8fW<<^-YuqP?hkGgT7WJYjsK(@HYBFvyzJui-7YibM4cl51)kIgnjA0 z6R>`&dIpGHYc!do)R)=-BsUPO+53%(*&;hJSI{&E4gWbQL!%uum+l*5NpQGC#-9F@ zbF1t?5dy;~^{e-&EX4$|?QWfn4aIv&iH3pR8_6jKqAYbW^ym6BUs#^Ooj9Sji08y z)wv2Pfabzlu%K3~FF%lclvD0>O*;|5g4u$l94}eAiZrdwMW()%`=@t*_yP=aGzL-I#{P^meI|h=_Uw)z`h5zJP>J zQ>`tk=*(hor#>G@)z<_M z*psJ*Dr=*)ss;Oy#=s@TPbf+-o`g9YY|Kshm%Y zN?<0qVD@$Q=(4$Ai2*pHaB;g-+?G(&OwP$E%ik)j)Nh!w7H!{dopqMXR=8CA#7yiQ zyM&R)Mn%xG$4=`DZqXN(-k_t?-44r=0q=ddRcaUmqc*&6uHvF(J)^9G#(Zc^^EZzR z@n!R0B_f=9K1Y5*q4{qv{QRy}X5Xe88YGnqc;Jp-8T-6y*YTc^(`~F`slh+ecL^49 z-l20Z>qb$PZqtk|v+Wg9Wl);>Bs07|71Uj0>@n+LcX7Qslp(L(zl0^E-8xQrGvm7_ zO~SES)JTo1!Ru8e6Si~wsB~2X z#XIyiO1!GNT5{~n!ejIDRUD{QF)T3rtRF5z%Ym!Myc^Xa2llr*5M!oX9d^`~3f=?X%dyghPj)7{f`KoJojM6$ZJ4so1E)36=*GvWOn{|W8c|;aH~Tmm zB-=jjl%@yV*IP17gU5;?=r|-ji4o3hENorldOh9ZRI>bPLG0a!b@C-Ayc8aEB^f;y zY~{@QPHw|wKhkCY=w`qddWO~E5j*MBg45sO5$mEQ!dbsM$+84xpXD6G-WZfc6xPoLwG~-8n#yx&G_9nrz9&;}-?lWdLq5jU zlremnnHKs1#;>8Pwy3=OR4XW2ZZf9&U8FLdR3Tz{kV!oqkMsR8+K zdoQOxE|%3CAD2jc4v4HR`d&VmcugU*`F{ z9Bu|2&VbTy;nQgRipY;%w@2#nP!M;a)f1%oERYK+HY@;(2>EJHYE?Mp8X4pg zWtD2<>%_Vk(O)a4u*)Qr4g@Xa3-_ea*+e3~QPsy!cC6e$TDE$tM5Lu8t-Gzu25T0X zDtfautGZJr%-lg%_|-QlI>xc~v+}YWX;z_nwvvZ1jb7|pb}d`5{hO*+g$>D``La^S z$!wL}yl?g$Wp%1~9c3h!@^mthb?UJQv{;p78EW#aN+nfjnLe1#b7?P8Po-G-NVl&k z&ufEtle3P~=F3*I+Y6v2AMH@xR*NU58Ho2Pm|soW8}BQ#K{zSY#oi#OZcu5vwhA>(Di%LxR)!^0uM!yzCb!oRF>UkDB!7XgR| z=0cRBMgq~`L%3-rr8NoYcz7){UlzisFAHZlB=~2*#qt7u9xh>^0A^NM)2px*p5Xas zz`3s0Z?1;0Q+o^ARKbAe_QO3ZC^qPPO7>s;Q^O!yth7}Yv@9z4PVURp>5s+(nT(9|l^@Gx6ob@_zj1BW}YW!bs13)%^9jYpkH)b^c; z+(klW+~`nEa8<);g@J{DTCKDKqdvyqopoliHtX4nL z?8%eDocyEvnv|Z!Gp%zx=icLy;5b-|&NWAw<#k-}ds{2~lsjX&Lhcc1t>mUz25BNA z{8yaE^_xP&imt9VRr{SEIX(-ia0p6t-~A9utg3!|u=}pi`$0hQ`*sAC()rqF?~i#3 zT^Sh}hSwO-#JvUdGwCdu(jp zbXOmNYA;{5w293K-P9y+_iREHsH>L(W-2X3M5EYvm2RR+Mg)m6g#K_#Er`V4AdB|8 zpakk%5H5MDZ;UjSRU
      z8s5D-#`txf{ZKaOYYMw#t1{z>57paC&AytwDk}ZVzNXlWrBGRg>5Erp$JWn)mAjuN z&w%wi!DZEObDYHkP_Ksyd)`gzrDR6wzGTKS4?WK7-L5c&;X?A;!%`VsO*nxDDELB`zl|kZ-t!#M9ZfA-(`AHnwQ|ssRvORH)r3fV>tYD2Mb9X_SNN+#GG_7EbW5b$d#>$CWbkGxEKE1#8$$a zN_|qb)HosEgI<1oY`m4BNsV}?<9Iq;&d)8OcS$4jowxF)M4Kz9i#u5}>wR@GXIcK> zj0#)pGoaP|eVJpmL?K0C&#|)U^tHHolEL678-lQJ3?dGX`5Sgp*|Ax>GsRauiNt+P zMA<@1`8(4+T_5YJ=UJYn`-E&hA1EEn+G}tz+h5w{YPr8x*^PKtDnV}=@vb>KM`4#n zTp>3$VM#P-h-m%Q>Cwi?=}KCaZhfN=OL<>}|BZ)pelEW4729}rbgcm4jplMu`^VA=*(+B5_9IKWwc}ya{l1{$@@N5;*4V zP^f+c$9ULOy3vJI5ce#r{-h>Z>l140&DN)K>!kL*3-)FPz8vFu=U4oz?cXhezYJAT z>brVSeWtUS+P~;9`d|Wm)93N4n_V_f5jMVG&umv_!{&8+dv(Wi$s0?bwI~_w-;tk2&la z&{cBw#=_r)a_o+h!?pT6k6Ux3<~L*8RxVUbyM<2d{nvIa)kzr;C)Vkbx!KUc?GNI)%4 zd78qGX3g81K-Gj!ayyp+(n27%7Uxa0v}(p2*yW7JsRz_^kkqU8T7wQZXKDx}ka^k5 zT<)&=z%4$N(Gdn!7)k7jpri@MS1nV>&=9&W85$QNyiWh9<;H$+7RtL@6g=+i9c9+zJ#2hj()+rrKN!C+GqJUg9X2AimIFLdhj;nHZg86uB{afvzm*Is|@tB&-PI76}r8t$TsU#L0G?p(EZiJ?Ronz{lu2mv^O_(4hft?6TVHy+8hSnYWA52 zBiF(AirZcJqff^LWfK?(Jp$uA>juYQas#IbKUkccyDKT3K9-DlPf0 zVBDfvaJsAFd;eA)`Mq&q`HZ)TgFtVqvA;Oq>FL_4LKCMk1#MhSaoU3XudpGn?p6}Y zW0hPM{U4h`%H5o@!2(_%V<&C3WwMpEa}D>n$SF!J?5@76tIr^Yd~|Y}yKL{<+Wbff z4Hga3-CK`0m~j0lgl8wbHapJ8Oum*zMRiAS&Wak4x>Q8wCQa8Ii#+v26SkDwOU#n-pG(+ErpU%Qc zDd8l3(L8?w|MXC6S5=Xqm{WJn;>PspLpGs)M$h^;`w>{TOZnK^t6vPRyDnck=1*pL zgReiZ`D@<)o**(kUcVj*5$06C2^QBd!;bsng7padvNn^9c6Ws>!?3sP$DHRKuud6A`ob||Vmeqvmao_Y|D17ozm#$;&+*pUX+ zOFnL*JULq(INIs0{%rgVz`L$oDLwA3-Dh0~H@NLHu7@5+KV7D1&Zfz!(fL&KUz`Ti zDIgKt_U$eIJil2zYRh9PDQQnfs=2(^Gx3$f)aqDu7`l?mJ}D(v>qcOb>$S!bZha#pP!NH0{9F#68li!Br{X59kATlK zivSlZ$7FTwk(n?RN`t9<*XzXqy#2RGgtNP>s`=Pv_xls?uk-hRt{?rLDG?)A*88ZE zS5lgKa7V%HttHp{u}hl@&DYH}n4|QVo`Qe-J}@`xN^ zDkkR?$Ih^krnVNDgb&}O+w!Je?itARt zy9mjp<&O$~;ia2VS!|-G6f^#0)7l;EP4Cs7#HVs0EJ9Frl8==uSK6X=%XPE5_^X@9 zPdix8N%fWgtJ0AD54##+6!`NtTiUsLY2@imPxRFnz>(7&*Y2U{h0|z zQx?7V=`4>AA|!GxCD>!XWs*NOXq^cd)U_YAr>7)71JX(}a$>bqp8;gu^%G}i^Cv|a z3fPRsx-JTOXB@8S`LmeY^h=1!kY_J`)yRM6baKlN zHl9~rbHdyyME3mh>eqWlZT!HsW|xg_X!ZNPTmq;0GjX!cAGhmjm+qiHj=ww7N5m&V zAYm#bBl$0W+6U>#ArZsp`5qTJn@YNO8(%2XljLe?D|s&Jm>xwb^;{3^ylCa@^cLco+9MEsxNtMD=lIXY8^HvgwK_ycO_r_@H{Ep95lh z*?d#t0$w)WNdJk}|Ko7Dn(&sMKrloy^50(jB~v8N@a^Q0I>edI#n*G5@ zNc;BQOr)WJhW&atUhZ9eI}U1WQVENIr2(QS4I#aAEZCZA_tVR1#DIX8_y8OdG8{S* z(ti?XaJXQ2AeR&#HI0@P0)*QuDyx!~=Os>{?cFjk58}1X-bR$MjLykC|8K|uS>h#R zu=`f2ak8yuU-g^CEyK)$;8=a5=Z9e1wI-5_DYr8Gv14rksk-S{Jq6`H6z9YS6bjbQ z23dc2*X-tBt(}GF)wMaTG4C{J={Z=mmy8`f)eFA{4=8{BB9+soR-HmVRgGukkCla2 z?v%WzWE_|5$74rt8MTI6T_+13(eIqZ(K8pFYM`aP=SMvMMcVtd1@p!Sf^)*a8 zRZFLgCUka&va1t+Ft?_FRgF6HPv?p2dJG2AcFuFPrO>x^0jAW&_#eKT8RMVPe5eC8 zsOg~LUlRIj+V(e0#p30=TWiN87hOIu=c*QQA$&G&Rz*uBg@);kIep2-*D?SB#e2ru zP6T(w+m_S{&5JO28XM5tdm0v1_y`Shb&87h97;Prhh|S2a22?R#)htf^zdbUXJQtW z#Vl-nPgq$wd7Amg$BOOe(l%Xd!k5=xuf%DXDd_QAEEeMT`Z;>pdNfU}tDweQjqKDz zJGeQ)jSavxcgh&;W-T?Q#72$LQ$>~S0H5A*Ov%~fppHRjdd2UUiEhkuYqlCv{$azP z-Il)FSvcS|#+mVM_??Nv&nx%JeCEulY)@iXM&q_p4Z!+TyyvI)z`mYR`hil0;UFMT zd)}4#2?sC0qvCke=Bz$QJc zNcW7Wvk?BA&aqGw0Z*n!v)prHKtg-w+LFr?BtvmXAwO%g%C53>r#>%2x38|yK-6yT z$Fx+%nE!{pG)}M1rI8?lFjwJzBOQAo_vRUpFhP^Es&y*IzE?NnpY#l{IedMKyY{dU zcy#kB?lcVg3~<dpr3sTTuNBkhyz#xz9c6MfBqAvp(%fK+iPx zMQlggA8VbT77xSGLrrT3tu1L)$WV^ z#T$a7vUn0&?)EsQ_g;m;{I(OzxvXxcfuiw&lEq*qXP;R^TKko_m(DcmSv`%1#kTsc zgGC2kQP#Y(loqZ(?@vJ&-AkRr#v8%)MojwC*3KaN_zWSt%&NZS5rwbtYn1u}&#oKY@?YX|c;zQf;e^Cbb*nD3tzjS0{y70KBJ4fFSx7?1$fX6=mO4K*$kNFx@gZ7ZN zij2&qWn!_r_Ot~dW?fP2t z+D~0#vsmzBfKPWroM5BumChk>q5=Pp8hZ@UGXN3G$>eSF3X2 z56kIW&CkI&8C*mxv0T}@+j8b_#3%0Rm(p85cJp2IIVLW-$EjS$-l@XYZW?8I#Qd)&Lid&HkMK1C1N>Asyjd!uJ8dV)?02R0!| zqp#O}$hx@?*emH6y%@CxxPrBbnCP(W?{hZ8H=oo@V+_3Vi0XG~XadN1)l3(WKP!Ll z{o;cx(&bh5J@khI0cJVYMs|y%Wy7w+t*H{fe^S|m7CdL5rPKDCBBXm`ymad4UdfN?@0ZLw zdJ-~&(&lLG-)d^_egqj~xv%#z)08Knw^ipDcgk=c`en<@AWwwLBfAVN&u=Kjp!p_p zA+w!zr|3;rC^yKCG_puVosH$UIhLfUy4!dq>1cCg+kRH6nLBUl!OuBweHY_sYli7= z`Ncp| zK`Q&zm8eO&3v$3^t$OwJjfvdKKt7NA$5zWdwGUlS3VGB6UM@YOO73rAt#APk@Bt~+v4}v8-9FJ90%&rQmU<0C;B%uRn8Zu zbX9k_jwaTdZbZdFUr~GreTd}DM%sVw=}1t(D9(`CWAbHrkd@jh#g~um+A}SOzY1eC z6p~IHG}liJ;;S1lIt`PrPG~L8BeqM$2>4W{^u?o^6+4QxZ;aCtgJNCfviOJ!zj&1` zjNlK|@lbZ;3TlnZAX>`}-Zqt3Q_|X0 zjc!@;-M8JQsqCvvF`ML3bV|W=(L&kB*-%mv`ZveoHdT#y)s^jiA3*L3r_ApwTCIXf zxK8*Lh;+eA4nndw#3|O}QEewm(YQUCSrj^6N#jlBQoLFI+IqZHcKLdSbkXV#I=LT+ zHFPp3I&+X4S%k|oPZsD}?$_KHY57eeQbZCH;#BQ1F&o+5M!fcevV}1?^{?x}6-gY)94M z{ufH6Ut}Q>N>4~;h)_x)+(~R}5RW6>a9j6O|1$u$)OAv^ErkX$=;T;GenBzK{>3XR z%p)bx?Uk`+4DB@-O~Xc(J3LP@WVl@lgc7G7lT?$3WG3&SV<4PvJ!3nbQ^;Jg+c--j zOZ~`pQtOe!;FdshBy7|R^R=qfy~6(-?+WeEXKSPVA^0VTTZ-SRL$xxeslsJ~3gay> z(c9KH*E|m@oLaD~ajrN%Gsk^T0>(_X0?ucM>F(RM>0Y?8^A~eUJB%Ig+v+f=sb)%% z>`9lAU(qA!ktdvhkxg?*HdS*@?cK6+FXxC*5AIi53^<>6OlxV~i#}kC#Jp(Gv z{eZ5SCw}cT-H2_gqWXqHy=}x=?}?=Cf;PV(nq0SqBEhac6q2ytk!i3wpwPZWwU@f4 zzh`<~MSg~ey=OFLvNfJUD|tY{{)D39n70<*%2olZCIQ{>l<7P2IMAhGN&&fINYTn^ zQAg<*beEhp=At#t>?bR+$LouJ^mY{HtH}|3Aw`4Y$*IuJ=63thS9)M_mj(*GD9n5>37-MIxpc-F9QN{F zT5Pomyn>#Bw}e{h4pL{^UItq0D@zl;R5}omBFpFil4;<1?Llb9@>&pj(*>TbIb#X= zt_((uhK-ICjsA4#dga@L{PFUrxoFp9j;z|+g^Za?Ut#Q-pptIP`7mUX;<*T^&kN;X zRm)XraRy{UId}J0LKh%gDA)42ABj9J^}^ARy8P0SR1!lTh?w@WKbJe0Ts$84C9w0v z#&ch?H&v8sJ?TU`ZR?F6btAMXd%z3%l=<4N{DW}c+*}XLPGJzF;w?<9PjKZZjHL@H zqRNbZn*pD`$Lhm<5RR;RqEsGv`76FH%v>g;zD(cUHpN~3mF>&sjO}j*_a%q>lED4@ zUE|*bE-nDfC8cHM1$^0+wG2?tZ=XXnFC_n$zLfyKq;HoF0kPQ`=p>QKWZE!Bd;IVy zV!&R%6~d7wd4*A7f#x&76K1*lhn*0w7K|BfGm9#1MYXggX_SZZ4A24q5omN}B`e?z zLI=EseC;HgSt2oJzZ#In32ga5^56lnrLgAc7!PvEp5Fv1l?TE?sc>0}a4F9KTd7oy z)htxpt@!9Y$iyu#7%QIicd}Gia#D6BwVEWyF9w7ql+mUcC&xlJEM)bHIKVX27PxWz zxWiJMC~j8i&_nW)B5DC^MMq%t#|lzoOU_+!&=F!aOrd=RSj_L}MpES@Ji@m3Z*hRw zCR6{*zd@B}{C)>gdD&%#w z`|}}v*g#gv@sSMALfRh8UOWv4%888qtU=TX18k)$m9gyN1J+fu${!=!;IAtB+Se?9@!N z`;$YGJ_j3T7xVL#sWcPd1kVvC3VqnF0BP)#Q>zDXu#<*n3oy^8$kffsf}}}_A<^Es z1<-wmT{gu~p*}8H5|;z3mpHS5(V(nE)v_XCCfO){j{0VgYuZPftXpV>Qk<%*D4NW@ z5r+a7sb3)~#f^+C)#cCB=I*B8@Dhp1xE9>K@ftnaO(OoC$SvzTd>c8dgQA+ZP0WEz ze1StK$i$r{84N~XYblfhr^?IVx*n% ztTaw0O2?Kanl&LhK)-BkOAs@X3mX97t}KTVom$LB!SOb|5#+VYgC_=C%^k3e903sl z!xu1ycd9*yiPcuthWTKbXj(at>jW4Bj+^iP09teF-20U2^-8e1|dn`#gUcL>uv3hqiI zjQazd>nUubL@kE1Msy725kJojjyJ@pmv=Fffg_>wtK2}fN2QjZ&8sA%(ruJuqrFS2 zQb0t{$gy8SH)@-R9+}ic^u6PvFE4QJH?~5TG{h%IURwggz?q-8qX4_eq`YH(U^sgK zP7fQDAQ`dW%1LhCd9XexNhz9xm2eHbL5~Kj?-U9vH<2 zv%dl$!l434WBskjtc?w0)xCIGA|U`lIkYa0nHC}#5Wvt#6LZJZpOPr}gKQKix!4M$ zc1W+?X+yIag=iuCh(Vn#w2`_(B;_fyW+IM6(hX+oqm4kMnWR*P$EP?Dr_ES&3${3- z<|&It+Yt+HUdHe`J306H8ZsMHIvZh<&ms6o?sacboi4Z${;9-k^uz;(tJ7b$@U2}u z@;ApA;$&pBW~>z4A?`@8MlLEb+!Y!xN>Nx%#M_{xr<$#P*>!SgIC$*0Wd9X0F$fXx zfIE8fwRc_)-`rug!dd|c^ujs*bj}=6DMwVDE}@<5T_c;!&ch@b`$jPnJ#y_wwd8nC6V`>IM&pXY<&%&g!pH$P-j(h7H?~F;M8S6S}wwrW~_@2 zDXCF&EfKB)6kjib1hKx42SNp-ix55zngHS29;JxOCV0eYCG7?0B2*35Ol}~GYJWev zg~xexxzUof<|6I|kViz#g1*vnFlN$Riz96V=l}wj^f#zS#DWr#5D8`6@LF&>veK`3 zTm`Cr6}(=c#Y7`LQB^X|AGxQE8oYBBDzWaZ2ql$c@s~>lMqD_D=^_+%p2Y}X!Gvid zcfWbS6t+~=arPg#u?@;Mt`B{~FH5t@o&l9hIDfvaMfg3)o}E**OHJ;O;8^B7STjl> z>P7z$8ol5_-mZM;Hc}o*9Af4&Q3mf}{00&W+t&-(e zxqU>%%jQftUfud~FCUnCK_1>nC(XI|&)g|7J7G4+VP$mmHUg^j*%)8;fGNsF1m#kJ zv4gBhdRwWId=c6Eko;JIaOT?>!2HQ4$hY@Wi4dhu&*b03lJQYvTz9vT*ara`->yHI zy=9L$vA8pMjeE~^(tt`^jg5=j{UE6?2FH=w(m{=oI*DEFh5MxfBeC$t0#QQtPHsrF zDBc96NWxGLE1MGpMa&!&r*$QM2MjpAwz$AUj5vmx=d-L;Pw#mwwfKtXy5Wpw}gjm%QQ ziIgA{VE?f~v|JB?rL-JXPUS@65nJ$4JjWPXzpB^w!@rqp^Bpt zb?etmy{hsy88@8gpXPtE7}Orx*F>!Ja1B-q>ZoLJuAx}1etNe=#-;%93`*Y?IKjOJ zT7uYoH^3M)OpQ#vOg}i4JrjK(AFl{vLz7V)yuUfV+B)nNMY zh|hT|lyT95zRgoDe}WyDCmcOu9rX+&A{9XrJ|#*X*uejUZ0Y(x0|pKEb})$C4=k~D zfCZ{u9`n_ZUKutFS5F)9yvMLd5X`)h7>%ggGiGJPVnS0;WQQvyS&$G_1n75d3qQ;P zr3K{GK9tECaL<74eq{nni(%dk9M!eY*4Fj#@!V_F?7*v~ zW8MbFXf3i38CY_*6YHEsPH1z2(VggqMKx` zMqRO}psFBr$h^Klr2JS=jb)8U2S8LflDmHxCXlSUOWvkz#69YNw_%MA;ZoUA(N34& z))vgfd4n7|_o;n3(fV;twju_C76>r}j_?Me`OR7OJPd3P8E2hjyHml#)4UN~V zUT>|$BV-y1ucN7CJmZFOs^1@X7gTY{vFmq(#t$eeE+p_GSq+WByN2y` zQJSm4JRrGIx?WSZ1DH9mAJl+9$}H9tHB?iQF)_u7w8LEkWi3diOV+E@LP%H{%GX{a zVtz*(QbsQJ%mi1+L0q(ngupKHEO}IxBymjAbCJj5xJ>>SIl8_ z&bj+h8&}!CVBjo(SW3yEDyNE5hug_S4jK(4;3ARk9Y9P7cOv6(fcrmGPC|x3 ziIJlR9ZUOJH!Rp@^%V-OJdz=lE`Qp@M1#zN>gAqPDg92+mszZ?-pY{ za5WyGh%#pAAVR4;mQhO*g#l2&2}2T=87PbaXe27cpVKFOh}3{%SxToh&shA<)zdE; zEKxCtXjG=zXBtrN@$xQ6Kqx%p*6}ic@K%DQqYYCvi4n=a+5pJBeiLt?22gq85hdOU zz?l?A$vL7NOG&a8qyentDi1`WVQ}1qb)FfrY%u9qU3{M(P#SA^MB)f0e}EvWD?wuO zL;tP}K_a~rVe@KQn|MRX1`*&?^0m6IJxD~_@Bp#mNTY{6vbNSaj5@j1Qg8}fnKarK z=Rw7VD=noPVjrObl-<($a)G6;^A*B(lgNzLB5jmalYD`8A%O2CHlWaG7&#Yd zxk#!Ha-tiMM#98lfxm$38k?b|n@=WU6W9SVRRXPWm*5=E2)S_YWT~skNHfTi$6;O5 z;oy$6ICRDHsd>mk#g9sDGzy6`NzoCJ9eDZen6GXKKgpCl!M?5B+#oxEDHP7}=zp`vuvj98&%`Q@li19#pC0NukUmgfmv?u^|KeRgg}s@|8!` zc<43;8{wU^xa_j&TTAO!YCusvS{MB97{lceTiD^;_L1~dfdbw)O-e-C7N9selb>5) zY%(f_%dbcRwKa02TO242ki69r`Z<1A6S?>&6LpBfk2lpR`yfha7` zfE{dUZmUZPt`3}KkY~>bc3_MwsvJ)Hc%gBfCzhc)L8WM9$Y6YG%^-?PLm>z85Lh-a zw}NIpMx zkkFZ^b1KOhr*VdMcuswrPj;C7%MXl>@z0Cj0^gLkRp2QG8fsq!R3RB-|AV5h-jg zShphNz!vi*)XFx9C<1|Cz%vv`%UxoI#U!aEz&LHlaon*x$fmcrQkm%skhDK?sc=Gy>BzEGo!B7wN;ZV^ zZJQWg%WPlT5TYdn{6W-pQP>twn6h09N((|G80l+)jx-8vtPOzVZS%f>n6qJ1J)Iie z9))M086R*>NaACK1F^|~2$hk>WK!B=QU$I}Mp8S|NsgIWBa&AFP#htAE0U{SVkn%3 zc_eVNKsA#tVm6NLff#owA502vczUj$EG;8Ap~aL5MDEB~`24~O_8idqD-HlcWX54b zplttUC-MP&!dam_6$7hs5Cpe^Vo8yr0H~J^7=HKrUA9AdrEtA(iWd2ORI!E8O(oM& zs4U)lCWq}3KwxAumIn0=rXV&r2W17!D5Q?r?}T-O9Y6aO1P1Lu#`I9qcQ8;6M5t_p z12(9ydt_{*;o}zRMldX<&^Ipc5;yGcibg?MVNECkZ2BB{Li@U6;Ep zOk}qyLDKA0J+0r=ohqnBQIg~mW54TsTjQ36WjatnQLI8`T~`ixN`GL>@R?E|6VTLz za9H69e$m8L<;c5(y*L1p431R$YQB$fG%BWw`dpM%V}_;K;Tf4N-=JlYmuCDGXi1QY z7GR26Z&dU;k!=+QerK?N>&NB=kK(2^K_9iL$KZ@YW{}@!%DTcrA7!CmuTNjwfo!2#gWXWH5QSMqh>J50^ze{F6PDpp z`xx>Erb>h$N-u5zV0@=(*0}^gCpplL!A9%|K#Iy?KENHKt%%AjPlA5JE4)jc)LeR^ zNAcp)Rlu#R5{0q8*AeckN#d|7Zx72Vv?Qn2|AR~c71C_ik0Q-yVYP$T^vD8bT>&L7 zg<-&pHXsM*ec{F%H4@jlpP1cnFa!e6`Guba8mN;*N&DfcTm{P{RE{c!(4oLQreE85 zGy9-{B z)EF{^V{VgE1%L-@0d7NrqWbD&><0mO+|XUkI8U!onLivl`>>9fDKVsw1LO9sI<*@B zUAEM%_q9f1jtgue=}1~~MckxF8R3mIom#Zw6*n%uCqqboavOP}L-?L$ z<-~qq7B@ZIs-gFfd<#rXrPXRRsj;Ch-wQwb$sVA1uuYdv+inM{fg-?c7_n*{QSujNfX!BokzL^Ke_`T#06-B=AaRf zmC-3$Q!CPufjoO`@ps6^M-$e4ZNB)lWPKI1{i%b}Y}om5t%bkhFA@aDRezX9^VNG= zRs+Hp2BK0Y0rb(G8s7;B&I?zkY*0toiAnCF5Sjyv5jnsiI-D?>sBO)b zOp_>GEiU+WyA=RA5@sKbHjp0>q)tvuAx$HJE*LtDs^>F246#v?R?cXRE#MazgI#MK zdYVv~_DdRwqN00P5k%rz>MAUI+AZ-e4K7=12>e9t|FuJSdL_1j4<8vKOPwu)Imsx{ z&;8yH4sWc$F(`HOHoZt0vU&jn;|oLqlB@l9aiW| zWQZJ$=ceDJ|EO*1!xXG`y%4evNZ-NJfxBY$k0XH?^&s7gE_OrDEq+wkYPoMPp(a41 z6G8)tIuVjg0Z%IwpxGumyN_sra_uz+ft*& zZFn7S{>pD5K4pa=TEf>C&{D`c^=I1k$Fg4d6l&liQO2tEBQ(BzY$dG4h4?cL zNrfoRvq3Q=j*fOAejum6;WT&MlV9e~$mjouthbJf>Urb7x9D0zSbFJ(r8^c75Rp{r zPNhp47FfEwOC(ezL zRX{<`ntqd}VN4KT;4~sttA|1Uk5F=>W$HSu+osAu#xWrs5CAFq^sD8y#mu0Ewj_u# zIe)e(o)7J(`w}K4&P!$BnJ*5kw?}&4ds{_^mB()(dI@NS3X<|;MtrB7wc-oc9#31U zS`+qN#$_7Lx|wTJpQ>oJJV|AP(w5ZX!9FW!@*jQEW3>5131U1S3w=eb%FqUd(tMfs)O<8j^~>) zGpH4G*U;*<8E{5)rw-*s=j%WShb8_2L=#?9XOQRluVn`)cbAoA-C5x-$DNU_s>)_yC`WX z!Ak%L$$3f&S$_VN24WTOgSaTvh!`y(+5d6YDafbO?=fF&`w{9 z`;YZ8W3fVN!yss^v4u8SP6iCJsR|TfcLN-;Q~(LWG*;SRUKpZue8?n7LWS!U*WkI= zDIFC2PKk}0642_Etrg}gW<8%D$WBrvjhLZ6yis+5ifl2C`;iioHXuAy#N>MWRAlKT zB+1`<$4Bj8+(gZ>gvuFjAZIjPozM2;pN@*MDU|fu=+K3UCc&r)SH5~bg0g6vF12u@2KjX zS_PEr5)%%s}V}^;ahVA^&zf zin&RGi-JTP!1*)?W%5a4qvgx}THStiM0HQWQ|2B6HhdY`*lq=N_|Zu@v4$J(6sW8* z2T8eCFkI5q)-zZk$HJE9P^@Npky@>-M5F#V$?yXbwyihT2gSFLX(@>p1b|0TbZ~kW z{H>m;$2@S^N(G3eG)U`KhmJG~Ji-e_`jb)oMz<2i*bw9&5WIIvKZ+%UuxxBtc-axK z-&@h6nsolqDB=Vr5hS;jI!r^B~!Dt5%fKedvO1jNZ(#d zE1yjj{tpmj?1UsFl+?wpiy4C}j2#1~*nz|mZ`y*jM z`}0i$fKedo?>6;xxTp=~$-2oBP7=f8u5s z23@0pEx#Gz>j1uKe|**a7KW7R1^uXaHaAiY{fyC9HZjFc=CDASB7QnGuXhXUeIFrE z4YfZg_$~20?xF840Kma?j$45KukuB?p&a0 zyeNFRoy+NsH%-vrAud8flaL_AUyi^(@U31Owb2~H+^6yCDm+qn)Y6Ej+%bhKNSoLR zvRZ3ZX=?MC3(!wbN#?|Wrqi9#rZ_T+<7 z#J6niN;O&7h|=pK*k4MI+1d-EJ+?S5%8q4nd@+`xlhxq*y$9 zGpo()#cvw`W+5tJDnNLN#ODwICz|`XX{^p}4XutRQz49Nv&BfL2Y>{cKx1kW`cE{M ztemsGfU~v^Klhj!3+M1msP!K_XH_UcFJ6)|0+c4CTsVcj>yR9asY+RHaU<$AWE zs2&Sh8Zfx?I6~`>hgf_NDls{_BjaT04F3hnB2ls5fbd~Pp*eF zG9ty<^pzaRGFR+eN~^CIM`9&_mex(DigfS`cU#Xc1d zRK)r+_L-#YvD)MJtM^rkzvn-)Djd-C}(0$;~mbqOvP zB`Y7Pk8lU3E%E76-)Mm7lT^F&bM@-%2AL8gOHPOrsxQ%-#F5WJKBWLy8f|IB%I@*$ zpej@-o0{X*9|JA;LE9j#fy)1v$pv>GV(Hje@!vuuwq4-EY<^ za@!-(bd-ZiY!)$#i$VobS8DYE+Or2ftiyB|UkG|iH%I$r5qM6S5lgJ{01-&uAj(8; zBioSO(#STdHI;8lph~3bY05u98M8yX=$)qje&&hTS{oG2q8Sz~>xvwG2cZvS|J;gksY@ zDqR zLQ9$6yl2+d&uaz%yhK5r90aJIAhZpM7kQ>(E8#tG#CwpAE+kbSsQOR}3)~cY?v8O1 z5)&n`9|1wf?xxFyENdTAJr0TIkORItsc~?3*p|?twS8$ciI}oU_Fe-?Regn~51O1u zauFznuTxLy& zEE2?bA|PWVidOfqul@Bv_1MTn*cuzVbiTwPh}}d4>H=bBB7NZgKtq3+E!hviLKrVR zC5c5qp+Hpv+n47pW1z+8C|U^qYVF^6WbL0a7GWAQb-!LbB#f%SVe)Ev{bj>q0yu zSI?!O%p`mNH@TSeSaIpa3W)ksL;#-28 z$PscEdZG7jiaEg#qEzr8ojUXQezNG@B#yRNIWK^eXx~8elDM<5I*jwW+nxgV5*We- z0awq-;Unm`Y`w{EWY#iL(l^|$P#@Gz3no-F6cZ<1w$E!b@IjzyxP zAEYuxt^{5re_@IdTXDXOjB%_r4gEYA4}@jR{ec1w+w$43RSch`vWtNFygBv3OYBr* zD_AlwM*?;VG$wtpEYJ$_-PUYKl>}<=br*mc!zYmdu>QGZi8f#4`(Jhz3uEpH zz=|$aGPqiF6ves!!vx6zTsRdEakT3ZB5>5OC4~=4fDBeWA z_E@H2%L*+XBRJ}ubI%g-a2H_g*#@$|hSAE?42^*sVd3Pf5Rnqa^3J3cHcM5x>rnve z;2xohABaPO5sry`xNoBhiC{;?Qt7; zhOiK-MX=2dOD>;Xu8(L(Jx$vbg;>sU+c0<{96;og7l`+c9l)yR5_mB``h4vKUH&lr z_>|0$(alc^F;s1jOdox3L%wM2O+i7_n%C~2o>dE$MtSf`xP5|3;assFAax=|Z&)`G zx&6>NA(PdfFkv)-ndlfjkTwvNf-(|jBTMS_N?B0(O`rl5H`tZn%(Vr@5rPoRPew?6 zDACz;WHc&JpC^g4$jXhvXIlVjvPIZ1tB5E-Xc7GSLFPLS(pvm^PzbE3Dwf*HR$ld4 z66+fDSU;RpcX)#*A7HZ3``U{$1reSO%lXS6t>LGoZ-K0U5Yvg>Wu&YswV`A1A4Wp* zjIt2itZttHm1`u%kLcTs+AaN^#NA|OkjgR%cpp)-EGNOya^-Jk?M*3t2HK+TfA@H7 z^4YtHa~V;<&jOcu*s+VjiyMl(&!a+k$yA{eGfiPyQ2o@qjS-*~`eQ`}g<)|SeXTA4 zofK`1@Fsgt!z4Q9{GK~=R90urMF1U?m(j9C?$M{_O{Ka@%SLVkfH2eA#Y1Tct;d`h zc7DcqvAu-X39r6TODC3#$t+*3 zAR?Jhl_{l`Z+<`41Ky7d znP7Eu>rqE=Z~-orMG2_rO^edgUPC&OgD#1HQZkH7^B^Q* z3(HD)N;GbOehEl=__uby+cN&;kqz6&7C@$2mRGm9ZpuA{J1 zAXQa?7k-X|YKCG~pzctJ`D+*YHfZ1`hKPg5UkrdUUt@B>CsQE;U?eU3N(X4S;U)~? z2p(i@lk_jNz70%=k(F(6Jl=+-OgBNNtk3i`Vr~Kl)=UET|jv-`EQmwehp>}_( zTnhA#dJGQv@x9MZl9mG6%f&4UaY%zAQE{L?gqG79ULwI59p}0iWui|Xh=|o74#2G; zt4+h|s1e3diknDJbf{9TXg(1x727oaDF#5kz*5mxn z&^<=cfeGOrx_Sf z|DpP1x{n3Z@F~Q;BvA0~h!BXU>HU;lf{x>|G_|dgX}@m&{S4)c=ebhG`@)t9rJQOX zJ!|-mw+dGIJCEI2OVw|TDGCpQQMCwMV~ zkEZ+m!iL0ou6Ld}$Z&x8U(@gR47Nrk%7GzNvYZ*VwIscV-$eLdO-S(Cs&2>$G1!wq z-o0y8>!W2DjxBgc-y)32B<5e3mS~R2Ws9!7X6yc7{{~HFdVWhtDc_b6H9ork@#DQ8 zBjh8dqdU7bw#0&+%9`>i)^9!kq)$olf3AFk!O!_YYNajwLc3Ce4)2ZWCb?+JY1GCQ zhht)`*v zaJNMMtruj?M3R)$Ls%4rO}`{}jVqV+$0DYtJ4ak&b)UVIcPtj`iP9Ta(vDK_QFSu0 zUo;Q+t;}ZpW;IheR0giRzR;O{()eq@BNCN=~Ti1TEB)W;jloygViv;hwv$r0J%n1u$^pzfTf4sxH=4J^LiS;wg6Kd_D zjVoBQOgNi}nY;cf!_O#8)AnV|%xbcDtOr_v{v9%2&&`wJ_iih_GO&uzziP(q^b1YZ zoD;N!g6?75ml^76SIxYyL{lHoGk%KBYCBc?MT#CrVa6{nbS8;sNR)AwV*8(^a5%L8 z6Hou2t^SXtL{*Rm`nKNxv(yvwfo{lyDC^0oB)Q0L#4XMPFy{s9=MA67cvS9MwY2Z)}Hi)mNW z-FkTNtLotBN~?88@2R?Gk0Yh=-tR)IHe`6MEPJ>Xr>JeIx3+?g#YC;Dof9>*naCxs zW5(8r+RUgVOB`*EPQ&Z@0X0E$vxz@^wJ6SjoKq#TBZF$8Sx#0<0n{^VBuecKmSkwbGGoWb*Ef@53k~q=+5b|B7XE(`kFIsBJV0d`K zTC;?qpSQmbRuHohk?}dFDE?Tg&8}yTq%!^N(VwSjLi;CWIeO59a8AEI@@NJ z`Uf<3`pY20l&!TrKb=pH6@~fxdW4-Acg0cSnc_Dg;=LYmCnV%C6=!UB2RtMRCsr^_$Yb@pPc2N%XJzH#Tzw5)%Kps0 zD;1(jBHn(wXzGuakrDC4Jw!ezP7LusVZ{GW7x6z?#Pkt#^%$;#_$=zG2*D z;@sTIH{#UXxDydu|9#J(bXJg9L;)_Ld?ALD65-A$UsvlC4rfK&=)$mR{{S609esiF zB{%0k!2UMt9Z^RS?zX4aAC$cWpcw;T$wieM;neKE`lB3#dn>c=-tlIw{;LzK@ zB;w?*?1qkpxKY-E9{Rrf6SmmLaWqfh4jCv+wlc(MF{Oo89W#4{gmqx$q%H@g=(3)( zfvw#4$8$CnnML@nH~cd1bxOD#S{xRW2_iKLU+p7Bz6ST&A|*s;rRYlU5UtF7)seq_4?9$8M=;<-V-Yo6e1Esj z{67HmC{9(f>9dX(ozu$I)T}Z5JI4xoXJHlEb@OO#tlb9(T2x-~!6D-oNAu`} zE!xTnHzW}@rEg34WfK7JgXCoQtVo?B z?a+v1w@7Kw67eOtFDT%`i71f_L_q!lCV|J?PmuGYEy%us&VrzrN}il7V1Bq^o~pS3 zGJkT3dG@Q*dXJ4dvXDh&Kmzm!Li^^gUjQ2AcHi@jOP7Lw6q+!2(TY6!Fd7v_k+pOb zvB|wa5)wwX>M3q-X`Gig|CRQ~l*jzH?Z2d}_Y+wJ|1dFidQ-anA)Wrw0gTE0J=Kx< zdvWXuXEp%-@{;YW_pvJS>F5RDS&s{mywZ~Bpd0f~G_f->YjOP516P8FRpH5bNM3-R z=kh`7qxiVZgxJjbpWUo?MR_83!WLbt|5asSUXtkv(r_rrubn}S1scr(Dey16h*%fn zj2~g7pI`1o{&nx6C9Wk~@c8_rnmo>Bl#elA($SaeH!ieqDQf`JqYJ6FTe zl*TVlm3%HnABm4e{LJAQEy(fJuP@>S`YM- z$9|69z^_j_T^aQcqfL8$YtBssaS&1Z?|MG_87}E+ZG7IHvE&KfoWH zb?AhxPjD^Sk+8@e-f@RrmJ|KCvKl#8DTQVieJzpILLGYMl&N#!qkh28A~m2{uNfOQ z={*#1DIM^0&a_96!{Yc?TNJ{NPjPZLx}tShxfP(!PH`4V7o8n`HOc;)iFL9A&qC2X zC@9<;+k0Wlp@t-m(4p7<8PH&Yf-s)-Pv`Ef1E9M@c6(&ip9Ch@V9j0v(d6=~HAd4dm1csn6d2;(q+qV{w*Tdi*$<>=&G4lcDE~le2R; zcsxvpc(6+Y@e}+R<-eB_8nDMHA2-|i*|*ErH+ebER0c9sqgLr!6zzoUp1wVlpES-Y z_oA?SBNd15D~@ql4}4S76Nbdrkd0&lTn-M@tm~h&J&6-)Xu;+@4rUBD^(pY#9B~xG z)jh5eTr+qhG!hZJ#FXLGoL$u;n6yq18NWnNjiC!NPuN(YDihnIQLCgvUbcBD)32}} zTqLlaBans!#as7#h|ZQ}SXvnAlsg#`hMai5sIcFRO~IOVmM0(#M=V||MN$*t2utFV zUu2~COf@78j@z?B=N6fZO9?ReX&%;o{Kn2(X5a6|iIob?C^rXnxRQshRG~f&a z3qV_WLpPwiCleP-yz#mco2mv96!QS8VzT@Rc(BA@y=qU_E9zazY((LUP9Dv9d|T}` z>##4Is1d$O_eZWmFKMIZ2e1*Q6<_RWzw^emBV)+Z*0~3BwTci})=<1{Q&o}aI+dWH z4zD5>3*ri?d6_{y4_-&2A-gvmov^V*$+LF4=EHb~hlhcWt57elsAQL&wDnBGczjn3M;;okfUckg3zuwIy5#@eS;RyLt zjNtP0fT!Iq$GO+bH$kFmbzLwKC*(e)T@kh`43-tGe76y{_JiPgmxs5E;me9Lrmd2N z^a->4f-ZWdRLwa~;j(1y#6Xu{l6_%f*W-Z$vv}*0o9KUlo(@Wx@E9(%`A$zCi&R2)AXDO2!pKxH#@6R~W1G^x`-&dWH2jR}b8P6`>f z=dG3dS_6*3ShQ71$qoal#wyy>Toc4iy|0R{B#SMdgT~m%99PO@K6EH{<|mkNt}cuy zI!(P0X#I94_U4I6lBW6A8hh!EGFSU#eMxHk?$RG7QpWpE(Kv=IK9wI%v~9BX+f3~; z%$wx%gz?N>n-k!7y3Cv2!lKi*{5PF}7l{&Ym+9Rzj}O5fU9#Mj$1HxxJ}-y$j&Qi7 zVFO>*LzmdNECGlKP3vUZZODG~b@NNTXP0wE1Q_Z`9x^qBUrPS~yV>L?E$7phOqvY@LVb z`%MJ5lpnEj9BZif*qejPb)rC*2o1ag46_XN4e7{#z2Nm>VO?%zy^pHu!`T0d&U2+a zfsI%Zvr(R>3b+%Q#1Y22?xeSIz4)+*y%GmewiNP^`xnwNTt*+0dVK6&4H;Et`?6Yz zsl3H#?jKUpG>`TBea7fvnMmVsx8iyg7N18;wwiE0f=_$InMD`Qg}b(v)#+63_wba? z8CjiR+B$BZSdCL&H|;u-AMH5gHfB2nZrSRm5nR7d$!ooA`q6N{p37&2f7c4kJSKk( z5IL2|zr%0%%HB;uaN&SmA0Ea)1^PtkI96}pi&~dA;@7ba(BoAOd5`_IKKGY2G8f#oAjRYOt*KA9HC9Z z!@jgnj@>i=0J=W==R6@{my=_EZFhhhjHaNNU z)$-A5R>XE179o0e$5UJ&-jQ$T$#Lgj*!9A7HQY)@aefnJ8CX|7Xu z58XKYDWHq&4k$XgJrPf_{~F}^xmU)_yeR?}s@HT&4a5T=CiSq%Ca*Qivd)KEOrAam z61nW=%k!}4u!e?a!IcgiwitKB)qR~;NOFhd?rc%fedDWq^;$k;0`uU=1nfr6H~2%i-RPlo9^N6PEW&?VAh+Z&I(iBB z(EoE`+^_8#f^dK6TZhVNN6q+K6I}y05%DVyXNAL7;_lw@K{pXOWp~T?52rJ*=fk*P zK~H)=OYuQmD-?+|d1~Q5A$DW)?7M^kwYBUoQthky>9)pMFBGVp%)I? zdNBhJs`lsLZX&@WJUlguYR3tqr-NSp+f3`Yu%4=}uN2#q3JMgvvjI?yzR; zeNjUY*jAygwYD^oR;^$8&O2SS;L96FX`TSEA*;-79`L(osaGQOQ02DC4EluS>*OBF z`p>b`fXz*T-N~_NSQDK#EBz0m??Xf_+(j3?s}R$0Z>IF29|@<=QmfsfS*tKnrx5q+ zL-4Q+MG#FgIzY3#gOf}!G(X<$f!}hA_KCSmT(wd9+|>M76OP-oM`;FE0}pxbNTjy5=y3HI?IZi2SKTufCf0j7 zuyoP@P9fg?8AUU_Cnc7>-st?L{~FdU2lBnyx_sJDjvseA#QA2^vYvE>aWb&9Y1DIf zNn_!%znq#_W{H6IPd*WFP&ArK+?F8Zm-hl+ik#YB<}DiosGz2lpOG9{-)E%oV# zY@hZ^SNqAY+zKfQ?Lv6LpW4gemTbOHo}G?(DP46exk|2WoDMP5Pdv;>tJX}O2d=b} zpKR$R&tJys=rkxNlg3?^UDgzXmyGBH6zs0L4+_t6hqwhW%R%=56t%y2EWDl-O((bx zi_p#K9HbMybX!u8-eax){2}^+35$OG{#%fRN!9h3TG|usC(KyN27iBYJ?xK1E2}c7SDU&UH?wYz2wy zZqNzf#$qIzS)#>w7cVFI5=R32Lf4{`s|rV|tUumgl1To$X;yTGVvbo(TIYD7FxyMq z^_KEO3{=+)>zQm*z~(P&6tmoA8Zakdt#+}^@+vhC_U5T^u;_fT9TUaZq;91|c@@o8Pu0~f_;Af246k(;ou+F!^p*1ZDk|sv!~&A-Y+9h zE)pIIA2b9`(bPzaYent^m0`(+7x)IG1qlXO8sj4>0d-$^1J6p+CYq)Q&+*mbVwi|} z<$Fq=lJtoTJP8n6RL*r#v(MP|>DqL%X!p%4Ha6LP)NZld@p^EXHS1o1JZ>0>k@Q4e z$?83SVO}xmN_)Nkkex zmD8x#R2gf>qQ7Z|<>U_o6D1^s3z|M_dRFqDXu7ZQvC_r0gyB?KQj-V0sI6bq$zDIB zybD2qd#ehYoHHy|$5jtzWx8GCChIKT#rYCiAzYSBI|`1ooGE~bZG?-RPAm(G_Q!W3 zts)b;TY9F9IqtqjAN!L6vTMvP@}9rNG>r8OiUo!$Ur+%#=7b;VGw*Kh?PW9Ht0;aw ztLRYBGtO#X|M8H{!XoMhH$N?xwHr-m)WJ2+GFI{a>}-haVBMtSm&wn|N$s`TisK{= zfj^sq6czo!-JzS%Hr@3-n6eK^sp6Xz3^}Zu!1vty^CN#u}&y@XLS31)LEE4=h8=4 z4ZvaRo}Ot{?N$xJJP+Sa?1ORNR1SlkvbM2D4Rv zLsXYvXkp)aZDUT!J*`|!_n16om>Ivv8hD(+O*y4BAwS1;**Cn@C%rhIT%4ph8UhHK z)3WNR43~_u8Fy9|j8gb=*uk6hx($nZMu+#qO?=2pSq@O?Mbc|le`FDhWTJmm(Ttg(bX z_0+W4CAQ@IXOZfcOO5pWiw4)WJX9RFV+*%SPo5g&oplw9_R6nc^*E@nTD~CILp=xr zgR~z>DRoT{(iRsL&}JO?C#tHwo*)JNG|Ic0pjs^wrE|$pyE+zEVMcj{v&_?cwIchq zz=&*h5Ciu`ZeRA#l&edhPWT)IkNDZ+ug1`Rog>>mNMuO+8pz~IqgZ1ylL+#?Ug77? zpD0#0G`=rZi5%1o+lsG~-s6)@8amvvGeK@Wm}hoK7oXzc`P<*BM56B~GvG<$-1(s8 z+H2Je!LR=N_{!U2F+uvE4|#yxx#|xatpxo}M}$C%R~i@_hlrOL6MZwF!9E{unkALE&KZ!+o?YM%%N*YIDm;|bJQmD zA0QYbye%|t6ZmNFV!X3DO}2$+y+bW~g_G3ZhWty14zHp6uQ}$K6#EWmn>iplKKVrQ z^bDQ5J6WelvR$n8ctL9SF2C2IghviDBEH&|p191-{qndyh2W12(sP~9;O$cWnEATF zka>SwmRkMs5Eqbdm}-SUW0Y0vZlPl=0}rcS^S1}m^L<4sW^1!Zy;LlNQIbM=z}%_$ zD?WEaa^#!uul1@0)wF1<>3E!WZu8yd?P?WI);~V6F31Rcx)iZt0Y`bF!{_eB!`9Ye)f0Bqiban#P5=^qy&k4Xa2=4FAh3m-sfAD|21Jvl-wRfXta;8GKw2OTr=zU!XLdnohfz-;C; zaXRI8r(X4~@b3?an8d;YN^KAZpm%Td>fy=3_;&+b{MB!$%JT0-r(V){-xL6dz7%cq zqUgS5j8J_w7gms~QSfoBGFpiUwf7IDH@5MVeW=bXQbAP{)7r~W4B_= zx!b$Efzk?VEkgUYb0Pu24W6G2?r*Vq?39NSw2NUDGcS0Ji?;7<%f*=y7I}%WyaAMF zD29R#fOe!nA{Rbx$px0i>5e?$VUPf~55wP$n7HSMRM?fPfmdoz{8Q19OB}Z8rt}H) zANT^tN56bhqm4J_td`P}Q?TVweQ|e4fAql5F8)@SQcfu3Qn|H`I6pt>&ms>DH{r~4 zZ)xBkV2*D|AZv)cC7L?KmJ}Mva`gL~AJ<*}zO<@l0Hh+=%r>(A%hoVKegXUW2P=dn z^*ZGb7H&UDzp*2${o5u=z5KT*>XAc^j}2J^ym;d{ph1XFw!3U(Y-9z7*^UOeoyEZC zQOx8S>>mHvZHA6U&?;bni8Yq5F)sq&c;O57P4fH%ak1!jvR)2303-9wmeGen?JMLq4*+9T+L{xU6qZ?HR>Lnwq&G0Z>awsO(1ZO8PpudK(yw& zO~bG9t@yAGNEgxyWOx+VUC5_bb8#{LdN`ot9;9ws@?r?ZJ$G0tW<;$y_;qlyco8y1 zY3Z+5{HLEsZK8NXlUcUh>jIkyu@ajExgvZ&HWZuBB=+1)gxSnh>VA)W zAklU;^l&s=Ryy%^(5zHc)3LkhxgTtbsbe1z8o%mr<5a%694N}>##keP-{~p@V0L@E z+duNGhVy*Yo~T}5q?qBpzo``tlJ&c|^p~R+BcW&c*&bZW%vt0d^W>8Pg^83@WBN%4 zHG`c3!{Tq5lJu7Ww{3EdKl!Z)GTAx2`uf8Wt}7`&a1Y9*bH!U^6wfPS??jS9wLZF;!I9s+UYQJp26K{3)dV^8HTQVNRv8HFjWxujFr}<83cCDpI z6FtoDE$hssVWR%`){C7d*a>WSj?luRj@^wBlx+XG%_p^{QQg(0BZksg6?0n+y7hi2 zDKnppgew(}0Gvu7U5mC3A%RubYv1(C48qppK}(W_0l~n)Jpjbmz&P(>opzUkh)p3P z%${o65C6u@e)>$>I(B|%$e#4ARor>^Vpa|RvyG$_t{)TLfavds^0uoz`(?5N%>09z z-Pt1gbiVv4EIbG6pxR~re*jpooo6(!>dU^eV)}`e2^!48AUDS=f`O;$cQD(TjhJ)0 zLEaK;*?Xruw-#F;2g(~b!ko$?8xA&olx=#QUs9iAdT)01UiChY%KiF=yEE%B%4haP zXi&&N=;HH+2JYv(V>qO17cr}}5F63F zG)d*_NuvRQbj^p`dl}%t@JSh~{?pr|kMpx5l0~k2QU#T-Q!4Ju%5{eu)IF+&`F2$l zbJrBTKIAY?gNG$WQLB7q=~$g{AA;Vj!dWT>mGTok-?tuT5;0B^?{x|e5lKy~C?4$v zmQb>kRr1r)zn(n`wSSf9K`VZkkcfJ9G!&^6N#Vr&@!gN5$5AqNRlhY;B4qXi+~3wg zXQJDOZ?;zHrYB5@pR(P(FVKB4xXZJf+a~-LHYFmSod2X$)|;R*W^y+%N3g^qA4AE= zb?x*Gu+voymCy}Xf;U74`v6d;%zAr8Q}n}nC%qql4S9GATG3;;Q9dVxrQo(kX^@UE z;gm|((y=#Hu%&IE!V6oq%)kkxbzmVwpf3uCNB*PN+4%6~wGY5VKEOO0smiDWhtJhm z9kYylz>hmlf9|&9O>@7F3a&%=orHX{_`Mbeq?EP}ApZQ*Iy$h6AqKa;9&qWGS|s3- zkNG}=sC&lyviC7^M!8yI|!hK(TC60fqt^tcZYSQDa8;95g4lWFx>+J&z&mAtZi3Nn2)|8Q!b~4 z);Jnjo0nxC*zUAzPtTh&XYBX$GkN9lFS^c#uBls~;i2>uPObJ`sXFRX@&@zsOB@66 zCe4()t1k}-)Fy*~P4~FFteVovrag{n_7jyp4}c%Y)ErF8GCDLBx?hZ@oV$)p&U4}O zjT|IpD0*^JCF0nDy_DgF4TC}WZKFIR=WDL2hxb#Zrnx;me}ad^M<=_Oov;opsfuGX z4-D2$wyCIIIe(R99vu!Z>f49rs)!m%|Hz%ED%ZDk)KULAT5S6}r`RU;CDUPpDT!uq zfKq!fTj0Q2_cWPJkmL;W%_>-tcvmd$r4g=yMS~X0YM5-_qJlhbWwP;pLcrZ5>tDjW zO7OodxT;>>{3^;qX7Ef9{dA^w5~k&L-zIy@dm`t(7=!h>px*oI%unG#{Y=w|lF1!G z1n(K7SE-OXAN2kBVTtPv7lrlGkqV3k@H^~y>LT6qb&}E0T96gDbVf#e&9mhTQxi&w zoYMD9HBny4;;w?jS8R`yb|WIrVsxQ*;t|!%MvK5Lzp%A+{M56Ur*6pKhYT-1_+@xM zkuDZ!m#){Vs7kM4#G<$}-6u<@Vh9z+HiQOXg1twT)M<;-9 z!TG1KATOb_3?63?ONWhOZC1hdaYFe)!2HkiK0D$b(lVB-p6Bd?ZWmYi9uQDPC3e7I zNTFpbq}Rb#a-d#Q8^4$4o#?l+&?f*hfdTnn{!I@NoX@4%PS0Xdz;6M$9yBhjkHVTC zr7;BE$@NC=9OwpLWQ=J>O5WJKR{M-y#dv??$0~PK6ROmOgo{cQ?^yC+Q;ELkSapP7 zcK)35kS;kHo;=8_qVJVU=gEG+u z%Jp=kUHGyq%<(~@fwGd4&W~4h^E&sBv9WKpxG3O1KPQtIxQ9D1O%Zs{ zV}lGg*b6sPw#AIs>P8JvXkf#h_sZLLma!&35&BW8G{KaA47?b?sy#9eJCwp0s2X68 zm+-WzYCJw&uP1@Dj{mH?`>7MHw_POOCx6oKuI=&F^u9D+^N1Z0b0cr+QNMS|6i{#?!`8$V|ns;e)I*6x+@?nAN6p})oxiVkX_Cf}c>cL4Tmj(PP zGz{Bpylmy3e=+Zfhb>qqh^tz#4He08n0f4+rXPp140#MF+6O@hFJh(V9(2P$l7AJu zAUa3^FW&&zY;2ePEu3R_S$jpE=9&E9`@=zcyfg6WS<$^#ngB1Cc|&B)UP62A=RLOi zCETc~n=gM~%zQ%-9xL=)(6xAKvkmXdra0Aw_Te}xrH@K&*Z3F1m%X-ZTQfGjs3qnz zhri?99bD&2vcb1KR(k7H`1ocCfn^OrwO`pZH=b+*)Ek<8AN_SYa{u#q8l_s$UAZgl z^MdfL)p!=bVgq>D1+EX1+0;sN-RqACUHILRvU4^&;vy*>f?Id3mNTJ-b#YTA-M=m{ zF3lr6pN>@l=XUk|Ax(koGoC632uSc>$w*1Okh}4X+0ZLHJR8+!H_(=;(hXkeAG(RG z59*tn89ymm;WDZSxSd1pm}g~O^)aVv=1sT(eb=ebEd$`ho9)Fm3_ zf4k`B@Cj3C;(|ty3pJr(KC{MTaa${kl8c$wOZSO-%{q0YM*L`W_dSc3EQut&)EcwZ z_U7xXrU^zjulhc!wS`4=CDYmXKq(rIbNtl{|L|Cb+IW~DRsLM{L$H>g6okc)maYkr z4}Kp9VAn~~KYl&6Ws;zKs=QoHZOgo;Nsi+FtmK8Sx|($BB~j^>)^r>xShU`um2#i& zL4S{1bqVa(3e^!9e7ibEy3ZKxXYq8ozVUq1nc^yP#D^>LP}bd$k#ga#F;wpI?TYmD z1W(^XD3!*!I5vBl_(iy)4b93A6U5sVp40x0-+}X8Jf8709N%=~?JXo>wm{~peFcqPT zfs<$&-g+f<&bfB&c|B)a$2g&;7Xt+PtN*)%@tw{Bue{?D)K6wjBO#v4fCve#WiT7iS?gLY^3v6S+S^g zN87380re~fZr&<`mo1@d&DzNewwL@lvbScZ@zjjn%hB)7+ij(kp;P=hvN=n`$+XxFYbKn=yzgK zSC>qc1Y4t(WZlz7W71EzC(0&7dM3WwGHMEcF~4u{2bb&aJ)66;633O}Vtf1l37Rlv z&jo^0im}caM0E!Jd|(P|j%wuS92gv7TqRfNLAkI!1VB@)CMg{mTSvbU^M!6&0z8#D z{{UEcR0*Q^`uC7rbd_YQ<-r?U_hS?&dYF$_pNs_IqlZV=_`^%t#+me(_r{+OwiEJ` zwf+xov4q63m7Vmz7|j%x>EZY<>zZu=wWI8r%SV!9U-n{DP}@ltr_TIg6Pd8luRbs* zmcW32&hR6|8rNUOXv1f^^ZjxaG-&Ki@rr5NxR>$!;Uao+s$Ih61tcmvBm5tXcFDn- zzej!Hp{mB+YW;Zp<0a?Y{;okGUmhH6fVxMI`_4kdHQDom4W7@)=O<8j3!@bkJ3*74 zyyM-VcBcMu1`%6Ge!l$Ug#b42e>h);Xmzf6xB!6SDu=z`(>gI8W(Ab{9(X3bQ&B~DOb^p z!d2IA`zgcnF0bIzhTx+ajUL5N01zid*EK1$*`|*z6!;`kLX2Ixf_&fbGfZ!*9 ze7K+&xHTI01^WDDWS40{z4^gM5{AQX8`HmkTncD{ zbJsWqn)~QX6gEl2uW#2hB}p{?elp`p@jfu}RE#P7Wgvha-hIEEl_Vl)KKV)%jUBq* z=PrSqx~X@Lz&YCtA!3)Jp1v_qsd-C19<`ooS_ResPF#{#17ruaZ|QmYCSRI6L+d%`)wfy_aCA!tm$Bdy$CW0r|c>zlZe}BAV5RVj7_{{+(oc{oQVi{3O zpnlkuA-FXkw|D}*kF&;In=Rf?*Ulv)B}lw~e@yX$cVm~zYaq!cj2b>lpC34^Kq!xI zdA_qK*z+izyj{ZF31rYCnO?`45;0-PQF;xM6T=9x1x`}vi z{^xyU=4<<`V|6+p@4OP|niq@-2`4By_m575-4yoZATR^HVv5i??bh?y%fIL@|Ds6*g6m3Eh5~#0qASOne~iG`8~K-L^6Y{{UtoqC$B0 zh5>H?f%JRH0_7^~f#+Mr>&Eq~a-f64a`Hds3JOIw8k=wLoLN}44xnv*e>nwUu1`js z98TVhz1_n;qo1n0YdP>ycfB3)fgFD$<2V7NqrmgG>Bo%X&>MtI8Y)oq+-^cUAAvKG z@n>)8`NA5mx7+vQ0(Y&bcfTILjI;!%nw$53y3I6cUIXjPg(L|Af74&40;@uR5xIa% za<+ZyZyV5!;opOe{+JHOL~aDwLe+QEaiY`~1CNjF#3(f>Rr_N=3i4M@TT-^E&c8hv zY8vravqj?|b;D?K9~el8O(oZje)tXmQ))H#xRtg$0Uyo*0SlLx$B5i&BB&~^gReZ~ zG2$f`pTGRh0|Ro8wk?ZQ=$bph&!N!r^^ih>g46HTE=SXs?~KQAnq*8U+h0z3Vnlr(>Td}w$Q?qlg4@~+!IzHvZOB59M$?-mAx=oo-w683yo!*R`&PTBE=WGAps zTI2caDDF+I*Xiqz7^fj13T-ZvSn{RNg&Y#i&M{koATI(tweya)XuuM>*1K^?V$*tk z++ah1c{k1|B!brK!K!i`8zq-qIY#jR+@Afccc={^%c(_?^1q-j71sU=j zQ|A@{osGM#m=z#`(AI@zMS7!;EN)oI! z^OPxabglE_1b`{XAKr2ah7qCU{{T!3L$Tk(_b~y9hST-LN~skuP7o>OPr2y`Q&L;LF-0~rA`MB@@nKu$+{<0kRmh2xTc zOue;X4f4Lc;0>y4wJ>BmY*^(rNT)*!AzDU6=JmjB6C-m#-MgvosE8jpAhx2+sci<`}cPXBXdD#}A#yikrxW){CJr;p%dBmn11_eG^~r7(DEqo^`A1 z)^ET=Z~*?MB@_Yyp&tESzA)Y-XzVr5yrxEVQh2iiptlm3g%XXW*|K29O!(v1tPlci zS|sCMati`n2z+ZR={-uHe~d6UUOL}8!0>UpA0U5Rh_HuTUV`_DB%DaSydLIO#E$$P z=l85oMj?=?(FK^LwN&GSUVEgU8^#8SpmBI9=NPo4qd%}X`OENJ7Q8f0=Wc43c!ELJ zc@w^lk3ur0d+bZZt zJ(KPHX0m1sC;hv^$S~4tzvG;CK~$j&=ly37IwGMy^~O_S4t8=|yle`0$CpMXYP(IR zuf8Pgx?k_>lehpi7XCE)`s5=MlGtzgpE=A5as&Iu0EHIE2k`FVN`W{xXZQ1)cj#SR zW$7$+Y4rNx8VZ|7XNT1L;Ka6bEaNF~CAl?bV)8FR-qDuwr zXCK}sX$F9F$F|}?j5Qs7KU{%Pd|n4xK-@K8J>zP(kgELr&EPbjCKGG z6?Av^{T$*8u?1*#KTLGcffyn4_05x|6VJnV{%`~>ln8|D4ToMCj)#a8a|anh}LQ2Q2x5({+3Pxw0YH`#$~n*Vh#eB|gWu z=LNeA5NYRJJHa%OAuVT{h6G&ACnz1YlOpZlSl^vu`Gks#^6@g2fvQ>Y&+)9ZMTX6EyX()_8d_0QL!;CB%xMmU z)mg(|T3{{TC|vIlijU*CBB02I-(%gM$G8<%4o zCZF$|17oo%XZy+$ZNS2E!}~DzEGCv-OOdISG{NhB{{T$X1l(vLZk{>*GQ{tz+vW4K zj0y-&5-IbE0fIe~kM6TV*niR!`Fe1PfNN>uzdQYLla(DBA75UwgJ6vb zJ!Z__=6m1Ac~vq%P6vy=zZq1IvrP{cbJo6b7Aj_hE&FZ82B>KXBdvTH0+$hC-j05n z_l;F$F2m~Y$GLzy?nSQfouNw6>yO(8stcfgzWiltYyn|;`(oQjDMSyiZ|{j*v%@yO zt|3)T&cRXq|$U5K7IMb%~cxc8|S--qf2Q|U1e{!8I#*~1uXu>) z1tOp;JZqd@d>5^4p8VVqQX&MM?LI$mcs>*e&RnI}#vuSJ0N>xA`H80j6a|)O_+5C$ zUWD9q^*%8R0-|_Rr@Y`uElv0}`(S}2tPYR$nj2-?XJP2aJi(GD^L%2cNf__F&3Mft zHRo$On#&8MfDQPUoDd=isDGn1Y(QZb;!FEr82;3V`A2A$3D?-cHq4;%XT za8}VmkY~R$30fsYzY+fNf+z{Lr&r_n!G-C;HSY0;fNhZP*7-@!6gUVJM?13sRzN0Q zc8*@mWM!6zTfV#l%OJkCmZpzEr_rUHO{?tkjfWg zFNx0g=e{wpPmE~qy8R|vlrwxZx7!n+4iQ8ftHx^8g zAb=6A{kP5(u8l3Y+DI zAywoICmcNTd*=(FL3`oXoZ;B5XRUePj~EAnU2*4MGXVl4qUwBLpd-}gb#qw1Q=gB% zG7DNKEZ3fS#vni}*RPW*LPuiz>fura(|0`_;2}oQMSh%h&PN><)F-bn_k-h*kWgl? zKa8dtNO!%@J!N{FM#kIs53ak%(LriA+19nsTE-ySLE*lB{{ZV11_v_m$z4Z0wF@nIPpM%dvLo_@T59j0Wh;kKILG1bl z0B!(@+w3QM=MQQnib3a!t>FE!BF*&J>i9YDC96R}!Ryv8A#7_q^ZoB4K@z$HXV+T5 zuv<4E*B{<8gn$aVZ*!H#j3Y{wC+UTyrz+?kvNaS<9X!W1EJlg5@0(hqp~v^eE*LZ* z&B>hIOHp+Ew=|-G9u7EscaX9@U325j$5PUQY<>>&f?91mn z#VnIS(tE}g1iB+O;d_ENWuI;8m}Mr@vn~0%$-_K6r7Wzn+;wDgu2&w8q-}470S7i zkel`15EPM3C~-4L(GO$u{_}F63;L9B`kSRP#!&RSomH#q*~6k4a+`oiv&vPI^})GQtLi) zya8yNF zv-=G3cExS<4lZvZa{D|7>-fWzK|vzV6K(T}*GN!$7f>~uhRh;#H$Odj?<(+#cIuyB zUh*PM1kw)YT%K^WSU^=an|NL|niRmOR_VtHXRI7SIW^Y!oap0kcPWb4-6N-g4RwjJ z0cnK$dGXWx<*S%n}ZC99`=EWI7Sbu%yu*BHd zZLn*^joo6St0frUZ_haL8z4VM@9F2~5TY35 z6*WIu@s-dn#QE>O@P(j7x0JU}$2-;lNr`o!^SAHD1A#?;!TxZH5jKP^-F~L1tXjGdJuxny;}Gsn(cJjAg? zw5_w>k`-kUalu4~5V zUK7LPF9%AOPmSdqv?Mw=(Z*ltUTSuS;%1>OtY_1G9eMSN8;24B{`_Q$u?U0W`T6&a zZ9tE}_szvv02Zj8E*KcCHQVLMJLofEt-AVe8C6YLk?;7!DAiF2Hg)wKtOI-&0 zaJ+SatQ$72Unw`|Sim0;G`5FsXRZ6dOto#`hc0&hxKxUQ12{c!dB>lq1`4m1_0j7r z%BTmEbv*w7CKW7{y@Nm>TmVyBT3OBWklWfC3(pgrQoB*2k$9uY$KMD-iBtf*Z|U*h zc}TlFK^u=8Ka5n0KsgDx`}c{E6TQfr@rJYjuE)rBm{H_`0xqwdi)Ub1?kRxaf|=Q^ zJ@51Jj8qy$x{$wB!;rRuC>AH3na&W4z}uqluck>XE7{XNyZFQf&;$%?!2FxTH{`7g z3J)v%;2=ZDntquo53RdD9GMJ@w#(yOe%Z1vgQ-1Wl&(#IUb)4Q(XnHkT&hk_FB9l` z##KNosQdGQP(kJfze$V9C?jh%fTINuFTsr?#D7s-t)f6r!1LeV2|Y!J?e@!(i$xu0 z&*NFD4QvBzHP3h(Q(;1nU45^{9fwwK4vq8koR5(kyVA}lyml^V6;^Y zlvu_j>Yd<$mf|5Oae)^L0T6oM93PAi1f8mGpS}Xntt1V*yzz-$i*-dT?|(SRmlM&e zJntHm1Es)q`2H}VLCM?PKD^{c9M)}o{!Fn{YZu&a_U56;vH|aXd}gwm>S^c2$9ICd zi_0F580jqz;`(=iwD>-HuDq`=z98AbB+%L9UTTcPoak5JR3#4Z(IGcD?^-VeV*KetFR+v zf;Hl3hZupe`X62En4<}dfp=-El+<=XCK!UVe(CM@p9844cT?at@ncjJP#UQ!{;x`JqlixesMw( zPFg;hYPx_4`Nr1{lE0>K;Elg$r{e;xZ%o`o&FbHrN^+a>`@Q0eXi?|IZ!Qq@z8ym# z9E}Th{{YQ<&yvZe-#qWvSsW;odpHL;0D`oi%jxSkn>Rrc-nE-pNl|upIyl1bqhX=q zU3BK_0*N;IJsRUR3VekY^oMoBbo7QgK<8Wf``VMhI*QA#HSZf^4(%yD`+CF3)LIoe zf4(rS03kHj58K{7fU{hXN&9BN4n+hv727_KoJ~;#pp7t=4xDGS=3Y2?zg|O+?Z%uHN%ZpgHQoRK5bF7yhY@i84L9KOR`Ms;#yJvK z=mwL?p>TBwM6p+i*CxJRu?FoR03P+r^PB|+j@drA#7NZQdJHz2`G+qbTa}6V0lyf8 z*a9^-?dKO@Wn~z3;K-u}quTtxPBKzT)K}3zw;BQvSV`rY^YqHRHPZ_8^S>EF#B9~B zIOiY@fhcZo-tnLnB~?6+?y|8$v~6u;fy3cR+8c+Y$%#^{VZPVF_4R}biOM*s-+K9( zqA?&Ax5kXxNKrg)e_0;=OWJPAqRRe4G=jAv#*Pq6P5=)pEwH8sPF0LSZ+ zf`EEE_TvLhNP#(<>wlc`Aa(cwudC}W5E}!PL8rg$!da;~6Y2Wk;=(SQFF#BA;LT>~MQ@9C(or48C#RmBJf(8ogvt2FA+O(^l&E#QdPG zZdil4%?NJ*C)m@^_x@w?%51B|9S*hB!&i-&74xn(*@9pwuq+kt@$rOKQ4V%@>ofu) zymvLni<;&1iLQG6F*^<-)&4RtJM{tQOX~-hK<&5B_q?GGOGuecqa=0ll>}1Ooj!Gv z6sSOYYphB`$Os-2;lc+{STD^t-f#?{YQ6lpCueS5dmXuT;OGG;qK&}f z{l3_v2$ZWG6YabiNFA)-5`4^1gaH8h2EFr~Lf(WcF$a)g(ag>(8CoJ$taDD!n6%e+Q*){$3gyAFzqwC-Ei$on$Q=)Ij=M&s?n@>LGkX59idRr zZ-1PP62;nezKkZKqD4OVrXZMi@$0OBN>~Ex;&bzc$r_8E`1|iQQ$QdN_lS}>836K5 zF#t@$VUTny5GJ6Kxiw%@LqM2NGkHCuCE3jS~P90Z@sx_$Whul z1J4(3RI@>`S9<1iI>~_u*8x0WBOs4%S6@8141g|4FMfge;-^(7v)kb!>ke^*4mG* ziO0?cZh_vOe)DolC3bv|)GBvkf;;&bz?q9{!o9O=E2?TSDLgnS!**{usV zbf12B=r9#f(5G%shw-c#W{8Myx8uev)u0l!=g928IF8j-5Id((+`(4Z5XgLcc)-*k z7=c?pudG|I+N_l6qPgYSFsGGg#xLg-G*hK51hZ;Qv7h)IEnf#o{HbV?;- zdV9v570{z=IXNF$0Ix}1hMzN@cZxP?Iyc;f{C+T^2v8y72V59mMF9kT-vpOt0U z_~VT{U?5UiZYP(idB*obkwb@5$c`9A4$DT}x1rWcwgn#b6Tnf&qJV9>EF7l3m=woB zI3x$6pPT`<3pw!k(>27);KH;~r6AD1+m{Ohjf=kBllo$;V9_xTJ`6Us6$G_4t{!&a zX-EuoWN=4LUitBf9ih#x_#?@!m033UFUO`Rg>BOpC9l@MA*#1Tk7U<|Qyji*2PxyL-oc zWR(ZQJo&)Y=usIT_tp(uI|S`{>%iKDh9G~%$S9w zD6lUt*SrcbY!z6^;{LNAc0siBx_&%Y=s{r9kH1(H zDCvG%`uT29fJj9d=f9_ZGK3eA747weXoS&Q_kG~4#6UzwPDtw-2bH86W8mo0Z z>9hCqjtf>s4ejb)Tt(C2q@S6Tz!4huk(-+?TFEz82Yln^0iifF-^|qalM-IQ*A{rz z`pVClRjV8H`OOM~ZEw%#?U$mJs%~+vv}6YZf&MwhBom&M?;@d6yaxmEad1(juI2!3 zHl=Q*KU{|ggs9ouSHGi#dcmP^j`gDN>6MTQOeU|-?>*ofSRLlL=gZNGX>ElK`X@M+ ztcn|-OtdYj+RYpK;eb%KtX{hh#wj3nX^qZ4dchBbD7imQGBhJ=6$mSDAsAnHWT&MC zsQ&;v^?)8+U5zwDUD_IOVA3FCX>@9RzB5r>1467d$m#39d9bYEX)gACZBFJ#P=O8~ z2VGooki;!fK73n@kzrwNZJBHG5%gF4&Pl-yT0cKMW4JOJU)z@NK%J*wY=lHKqdu9) zO@_7Rv({KfL|FV~N~i=JubJZ-&cGu8`e?s=eh77?z=AbaCYO)K2T=z02fr>yh_%!Q z!;Q>NTE+Cj9r^&bUpVFutxaDZBZUk(N_Zy!06bt>!O#*fZ#ewWZ3-@=F0j=HBi-bh zrX?=fDO1Ab5&^dm`uy>TB_In#==jAX+|EJOdEe=LV#$PM`%QA=R6$Cuqb~N(<)b zngBgMM~RGPXai~S`(zbU6l_L;-;ANJMh-6OocPV4ToQUWZ|%iQ8w0`7^D*Zki1lwu z>z$4ZZWSOSN85bO7-&%4jgm8h^Vx!h5JJ`O^NVmNhXLXGzotW4iNmw!?RvmB zt7-~&J+TU2suZJO_AHw)fCZH{1HI?03R%JRaSRx4oVis5EdKHI^Mz1O z6>B`;APHVY!U(8AmwjYL<$O2ijbsEohqqr?dgnChwxQU&(bh1mt*@>+sCe(*M}#E< z^OtKoZ!Wj{U;y41hAVwY?x@BhL6*X9UdWDS*>n_(kju)DYXt%YOBu%X<&wwt2uOItBuJSV2nsu(CMWFVz}9^Vjp4}Kv~-~H=O)GxaBA2lj`0#|Vna`F*S}e0H;;Te zW&>*~zNEVM>mrhA9BdbJcj>&jV3yeOkbG+R$7gqmvOd4}c#c)7Qa^)N=NHJr1dGMr zdAm#;w5zavKN8yELJnO{AHRT|myaGQjI>0MQBUHbrZNA(`NCr)d%f62L zxJNkxHX+zMuhGGjsOSS)eEFEQMWNcBkEh=amIlxis><=}ZYp5u7^*|{Fmq@KX~XPi zKRDn>@*?+6f7VT8sv7bg9DL%ET$LT^y$EKN3QH>uS zPJdkD2MC}ZL(cP0F<%U(ns1N5=JXm0(i=qf-8HXyw-W@QT@BJ|d-aJFz(91LX8~OV zKz5&L`sC^tmR_&=&gTh0j@lR~b1sONcK5PxH`rZrBg)~Z1i8Sx$3(go;QF=raeUO>yNSq+Ag@0DT5Ga)O%mX7El9PYvrt!DJ@OgQS-hxuNYE1iYAUH zIS2tL8%^GBzPO;;N`Zr z_1~i&25X3MsUH^#OLZbyBzf26%UnH>9z%;yw_C?A5G_+kGA-Z<+|_XG3q7=8a1N%&CV!AyLar?_2(Fvn~12N7hK|82gr`;;&^c2 z%xN^<&cEg($>2d=Uu>UW811Q`XVzU@r&fzPw_H>BVO6 zi?2Jyxd#;Bj~c)w2+^UgF#sIJhOZ~{f*O%J*R5bTHiDO~ckzjlP*rEs`_57pXjcyD zH?3fj0I(%fpI^omg@ZySy!kPEADB7x%y{RhYdAhSxN1CSS$Tc@W{5@4R#~rDy{|NY zF3oQBj0OVMs%x(Q0MsBd!1u}LBMDmv*%th&G@i6IqLPhfR3_4 z+2Cg$elT!WFK~;g{{S$-dPG3}$uNm-N(T!@g>8IbrAPu)2ba#nUhx%x$RWNb*56q% z5CTgzO)nqs6|nlC>>a1=fv^RsXkC1vuNc;MJPE(g7v2D>i3Y>4ymyxqLNmz> ztdRQOIFtegi!P!609=(o*JR*#@0_=QHl=^3XWJS9ZtA`7jA|>>Xq(+XA2_O;#3(zf ze0af!1vNDC-7fuM%G{{O$I}Fvt=ONT!Ny)}VQ*cY_sf#)0LNN(d4ztrl1E5T?2eCH zm=QBCL^kZ_LEE#=Eh|DO2{ikCu>ure5wv_~Fj3Znz4mc|RS!Ocj1Bjt#i$!7b0QPD z)&?x5D<0T(U=c|wZ@cuJ1Zhr5uEGu8QUz5bZBHMzYM#N9org-FgC?*v zro`ueT~CY>KpGY1M*Voft@LsyZS97eWVkskbcN>I-oCj>K|B#as((#=FoRNBPkFy{ z0^CZ;s>Igz&x+;s0HxmSmy$=EmeMW=FA=U^;}r7NN3i`byr|ess1D)J92g!V5d+BU z$6oMeTI8V}y6MsO!G!sV<}@cL$=!3u!AulHDBMJEM*`Nje!!|HKA z-V$UDdiYLx{A8#wpc)SOR*(=W&sf7{!0%4}F$hLz*JOTwIJ!1a zH4eHa{o()+NSa-@9Z=B*mT5Erz4*agTA@!5d%t4=rqR@of3CjSyzc%JN0MFFoZ1p9 zjSJdH9n0P-5-PTKvhCkqFtm*>GhCVu$@e}C?_SigvB0Pe zyc*;C)?DgQkbF~G`N77}C|dIMtQHR~Z7(h%PFA;aYIPrX5RKs|ki~lqcY%*F0UGni zUwjm6NTA10Hq16-ynz?>!BD0KOLY0{(es4?3EA85KNB>}FOYHNd&b~gl=0Vm`pw{& z+6KDv@qxg@PTuGDyk_pfWC!arPz=yWdw1jb!$$;)QnpXDU$(GXRJuBLYlHc4KW8GgGY(9`FRnXbY`=`9Uotr01W{=QkFXHg#ita^QOEsy`p=B@&7#L!vo% z&TR$|hO7B&7&n0=dPW%p3U Date: Fri, 13 Mar 2026 18:07:38 +0530 Subject: [PATCH 03/16] add rcli_overlay standalone Cocoa helper for visual mode Separate process with its own AppKit event loop that shows a draggable/resizable green-bordered transparent overlay window. Communicates with parent RCLI via stdin/stdout pipe protocol. --- src/audio/rcli_overlay.m | 154 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/audio/rcli_overlay.m diff --git a/src/audio/rcli_overlay.m b/src/audio/rcli_overlay.m new file mode 100644 index 0000000..1a37481 --- /dev/null +++ b/src/audio/rcli_overlay.m @@ -0,0 +1,154 @@ +// rcli_overlay — tiny standalone Cocoa app that shows a draggable/resizable +// green-bordered transparent overlay. Communicates with the parent RCLI +// process via stdin (commands) and stdout (responses). +// +// Commands (one per line on stdin): +// frame → replies "x,y,w,h\n" (screen coords, top-left origin) +// hide → sets alpha to 0 (for capture) +// show → restores alpha to 1 +// quit → exits +// +// Compile: clang -framework AppKit -framework CoreGraphics -o rcli_overlay rcli_overlay.m + +#import + +// ── Custom view: green dashed border + label ────────────────────────── +@interface OverlayView : NSView +@end + +@implementation OverlayView +- (void)drawRect:(NSRect)dirtyRect { + [[NSColor clearColor] set]; + NSRectFill(dirtyRect); + + NSBezierPath *border = [NSBezierPath bezierPathWithRect: + NSInsetRect(self.bounds, 3, 3)]; + [border setLineWidth:4.0]; + CGFloat dash[] = {10, 5}; + [border setLineDash:dash count:2 phase:0]; + [[NSColor colorWithRed:0.0 green:1.0 blue:0.4 alpha:0.9] set]; + [border stroke]; + + NSDictionary *attrs = @{ + NSFontAttributeName: [NSFont boldSystemFontOfSize:12], + NSForegroundColorAttributeName: + [NSColor colorWithRed:0.0 green:1.0 blue:0.4 alpha:0.9], + }; + [@" RCLI Visual Mode " drawAtPoint:NSMakePoint(10, self.bounds.size.height - 22) + withAttributes:attrs]; +} +- (BOOL)acceptsFirstMouse:(NSEvent *)e { return YES; } +@end + +// ── Custom window: borderless, transparent, floating, draggable ─────── +@interface OverlayWindow : NSWindow +@end + +@implementation OverlayWindow +- (instancetype)initWithRect:(NSRect)rect { + self = [super initWithContentRect:rect + styleMask:NSWindowStyleMaskBorderless | + NSWindowStyleMaskResizable + backing:NSBackingStoreBuffered + defer:NO]; + if (self) { + self.opaque = NO; + self.backgroundColor = [NSColor clearColor]; + self.level = NSFloatingWindowLevel; + self.hasShadow = NO; + self.movableByWindowBackground = YES; + self.contentView = [[OverlayView alloc] initWithFrame:rect]; + self.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces | + NSWindowCollectionBehaviorStationary; + } + return self; +} +- (BOOL)canBecomeKeyWindow { return YES; } +- (BOOL)canBecomeMainWindow { return NO; } +@end + +// ── Stdin reader (runs on a background thread) ──────────────────────── +@interface StdinReader : NSObject +@property (nonatomic, strong) OverlayWindow *window; +- (void)startReading; +- (void)handleCommand:(NSString *)cmd; +@end + +@implementation StdinReader + +- (void)startReading { + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + char buf[256]; + while (fgets(buf, sizeof(buf), stdin)) { + NSString *cmd = [[NSString stringWithUTF8String:buf] + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (cmd.length == 0) continue; + [self performSelectorOnMainThread:@selector(handleCommand:) + withObject:cmd + waitUntilDone:YES]; + } + // stdin closed — parent died, exit gracefully + dispatch_async(dispatch_get_main_queue(), ^{ + [NSApp terminate:nil]; + }); + }); +} + +- (void)handleCommand:(NSString *)cmd { + if ([cmd isEqualToString:@"frame"]) { + NSRect f = self.window.frame; + // Convert to top-left origin (Cocoa uses bottom-left) + CGFloat screenH = [NSScreen mainScreen].frame.size.height; + int x = (int)f.origin.x; + int y = (int)(screenH - f.origin.y - f.size.height); + int w = (int)f.size.width; + int h = (int)f.size.height; + printf("%d,%d,%d,%d\n", x, y, w, h); + fflush(stdout); + } else if ([cmd isEqualToString:@"hide"]) { + [self.window setAlphaValue:0.0]; + // Small delay for window server + [NSThread sleepForTimeInterval:0.05]; + printf("ok\n"); + fflush(stdout); + } else if ([cmd isEqualToString:@"show"]) { + [self.window setAlphaValue:1.0]; + printf("ok\n"); + fflush(stdout); + } else if ([cmd isEqualToString:@"quit"]) { + [NSApp terminate:nil]; + } +} + +@end + +// ── Main ────────────────────────────────────────────────────────────── +int main(int argc, const char *argv[]) { + @autoreleasepool { + NSApplication *app = [NSApplication sharedApplication]; + [app setActivationPolicy:NSApplicationActivationPolicyAccessory]; + + // Default: 800×600 centered + NSScreen *scr = [NSScreen mainScreen]; + NSRect sf = scr.frame; + CGFloat w = 800, h = 600; + CGFloat x = (sf.size.width - w) / 2; + CGFloat y = (sf.size.height - h) / 2; + + OverlayWindow *win = [[OverlayWindow alloc] + initWithRect:NSMakeRect(x, y, w, h)]; + [win makeKeyAndOrderFront:nil]; + [app activateIgnoringOtherApps:YES]; + + StdinReader *reader = [[StdinReader alloc] init]; + reader.window = win; + [reader startReading]; + + // Signal parent that we're ready + printf("ready\n"); + fflush(stdout); + + [app run]; + } + return 0; +} From 7f5f3bde1e18a23301a46abde04d3fde2027e07b Mon Sep 17 00:00:00 2001 From: AmanSwar Date: Fri, 13 Mar 2026 18:07:43 +0530 Subject: [PATCH 04/16] add screen_capture and rcli_overlay to build system Add screen_capture.mm to rcli library sources, rcli_overlay as a standalone executable target, and link CoreGraphics framework. --- CMakeLists.txt | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7a5dac5..e9515d9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -107,6 +107,7 @@ add_library(rcli STATIC src/audio/audio_io.cpp src/audio/mic_permission.mm src/audio/camera_capture.mm + src/audio/screen_capture.mm src/pipeline/orchestrator.cpp src/pipeline/sentence_detector.cpp src/tools/tool_engine.cpp @@ -139,7 +140,7 @@ add_library(rcli STATIC src/api/rcli_api.cpp ) -set_source_files_properties(src/audio/mic_permission.mm src/audio/camera_capture.mm +set_source_files_properties(src/audio/mic_permission.mm src/audio/camera_capture.mm src/audio/screen_capture.mm PROPERTIES LANGUAGE CXX) target_include_directories(rcli PUBLIC @@ -165,6 +166,7 @@ target_link_libraries(rcli PUBLIC "-framework CoreImage" "-framework CoreMedia" "-framework CoreVideo" + "-framework CoreGraphics" "-framework IOKit" ) @@ -198,6 +200,27 @@ target_compile_definitions(rcli_cli PRIVATE RCLI_VERSION="${PROJECT_VERSION}" ) +# ============================================================================= +# rcli_overlay — standalone Cocoa helper for visual overlay window +# ============================================================================= +add_executable(rcli_overlay + src/audio/rcli_overlay.m +) + +set_source_files_properties(src/audio/rcli_overlay.m PROPERTIES LANGUAGE CXX) + +target_compile_options(rcli_overlay PRIVATE -x objective-c++) + +target_link_libraries(rcli_overlay PRIVATE + "-framework AppKit" + "-framework CoreGraphics" +) + +set_target_properties(rcli_overlay PROPERTIES + OUTPUT_NAME "rcli_overlay" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" +) + # ============================================================================= # rcli_test — test executable # ============================================================================= From 3c6aed33248431da3ca925734685f3122a555b48 Mon Sep 17 00:00:00 2001 From: AmanSwar Date: Fri, 13 Mar 2026 18:07:48 +0530 Subject: [PATCH 05/16] add screen intent detection and VLM analysis with streamed TTS Detect screen-related voice intents via keyword combinations, capture screen (overlay or behind-terminal), analyze with VLM, and speak response using sentence-level streaming TTS for low TTFA. --- src/api/rcli_api.cpp | 265 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 253 insertions(+), 12 deletions(-) diff --git a/src/api/rcli_api.cpp b/src/api/rcli_api.cpp index 18cfadf..c59e0b2 100644 --- a/src/api/rcli_api.cpp +++ b/src/api/rcli_api.cpp @@ -16,6 +16,7 @@ #include "rag/index_builder.h" #include "pipeline/text_sanitizer.h" #include "pipeline/sentence_detector.h" +#include "audio/screen_capture.h" #include #include #include @@ -32,6 +33,10 @@ #include #include #include +#include +#include + +extern char** environ; #include "actions/action_registry.h" #include "actions/macos_actions.h" @@ -976,6 +981,111 @@ static std::vector try_parse_bare_tool_calls( return calls; } +// Forward declaration (defined later in VLM section) +static int vlm_init_locked(RCLIEngine* engine); + +// ============================================================================= +// Screen intent detection — intercept voice commands about the user's screen +// ============================================================================= + +static bool has_word(const std::string& text, const char* word) { + return text.find(word) != std::string::npos; +} + +static bool is_screen_intent(const std::string& input) { + // Normalize to lowercase for matching + std::string lower = input; + for (auto& c : lower) c = (char)std::tolower((unsigned char)c); + + // --- Tier 1: explicit screenshot keywords (always trigger) --- + if (has_word(lower, "screenshot") || has_word(lower, "screen capture") || + has_word(lower, "screen shot")) + return true; + + // --- Tier 2: "screen" + any vision/action verb --- + bool has_screen = has_word(lower, "screen"); + if (has_screen) { + static const char* screen_verbs[] = { + "look", "see", "show", "what", "tell", "describe", "explain", + "check", "analyze", "read", "capture", "going on", "happening", + }; + for (const auto* v : screen_verbs) { + if (has_word(lower, v)) return true; + } + } + + // --- Tier 3: visual context phrases (no "screen" needed) --- + // "does this look good/right/ok", "how does this look", etc. + if (has_word(lower, "does this look") || has_word(lower, "how does this look")) + return true; + // "what am I looking at" + if (has_word(lower, "looking at") && has_word(lower, "what")) + return true; + // "can you see this/that", "what do you see", "what can you see" + if ((has_word(lower, "can you see") || has_word(lower, "do you see")) && + !has_word(lower, "file") && !has_word(lower, "code") && !has_word(lower, "error")) + return true; + // "what's happening here", "explain what's happening" + if (has_word(lower, "happening here") || has_word(lower, "happening on")) + return true; + + return false; +} + +// Capture active window + analyze with VLM. Returns response or empty on failure. +// Caller must hold engine->mutex. +static std::string handle_screen_intent(RCLIEngine* engine, const std::string& user_text) { + // Generate a temp path + auto ts = std::chrono::system_clock::now().time_since_epoch().count(); + std::string path = "/tmp/rcli_screen_" + std::to_string(ts) + ".jpg"; + + int rc; + const char* capture_source; + if (screen_capture_overlay_active()) { + // Visual mode: capture the overlay region + capture_source = "visual frame"; + rc = screen_capture_overlay_region(path.c_str()); + } else { + // Fallback: capture the previously active app's window + char target_app[256]; + screen_capture_target_app_name(target_app, sizeof(target_app)); + capture_source = target_app; + rc = screen_capture_behind_terminal(path.c_str()); + } + LOG_INFO("RCLI", "[screen_intent] Capturing %s → %s", capture_source, path.c_str()); + if (rc != 0) { + LOG_ERROR("RCLI", "[screen_intent] Screen capture failed"); + return "I couldn't capture your screen. Please check screen recording permissions " + "in System Settings > Privacy & Security > Screen Recording."; + } + + // Initialize VLM if needed + if (!engine->vlm_initialized) { + if (vlm_init_locked(engine) != 0) { + return "I can see you're asking about your screen, but the VLM model isn't available. " + "Install one with: rcli models vlm"; + } + } + + // Build a natural prompt from the user's words + std::string vlm_prompt = user_text; + if (vlm_prompt.empty()) { + vlm_prompt = "Describe what you see on this screen in detail."; + } + + std::string result = engine->vlm_engine.analyze_image(path, vlm_prompt, nullptr); + if (result.empty()) { + return "I captured your screen but the analysis failed. Please try again."; + } + + // Prepend which app was captured so the user knows + std::string prefixed = "[Captured: " + std::string(capture_source) + "]\n" + result; + + // Store for stats retrieval + engine->last_vlm_response = prefixed; + return prefixed; +} + // ============================================================================= // Process command entry points // ============================================================================= @@ -991,6 +1101,14 @@ const char* rcli_process_command(RCLIHandle handle, const char* text) { LOG_TRACE("RCLI", "[process_command] engine->mutex acquired, input='%.40s'", text); std::string input(text); + // --- Screen intent intercept: capture active window + VLM --- + if (is_screen_intent(input)) { + engine->last_response = handle_screen_intent(engine, input); + engine->conversation_history.emplace_back("user", input); + engine->conversation_history.emplace_back("assistant", engine->last_response); + return engine->last_response.c_str(); + } + // --- MetalRT path: tool-aware inference via generate_raw (pre-formatted prompt) --- if (engine->pipeline.using_metalrt()) { auto& mrt = engine->pipeline.metalrt_llm(); @@ -1506,6 +1624,92 @@ const char* rcli_process_and_speak(RCLIHandle handle, const char* text, engine->streaming_cancelled.store(false, std::memory_order_release); std::string input(text); + // --- Screen intent intercept: capture + VLM + sentence-streamed TTS --- + if (is_screen_intent(input)) { + auto t_start_screen = std::chrono::steady_clock::now(); + std::string response = handle_screen_intent(engine, input); + engine->last_response = response; + engine->conversation_history.emplace_back("user", input); + engine->conversation_history.emplace_back("assistant", response); + + // Fire "response" callback so TUI displays the text + if (callback) { + callback("response", response.c_str(), user_data); + } + + // Sentence-streamed TTS (same pattern as LLM path for low TTFA) + std::string clean_text = rastack::sanitize_for_tts(response); + if (!clean_text.empty()) { + if (!engine->pipeline.audio().is_running()) { + engine->pipeline.audio().start(); + } + auto* rb = engine->pipeline.playback_ring_buffer(); + if (rb) { + rb->clear(); + + // Split into sentences and synthesize each one + std::vector sentences; + rastack::SentenceDetector splitter([&](const std::string& s) { + sentences.push_back(s); + }, /*min_words=*/3); + // Feed the entire text token-by-token (word by word) + for (size_t i = 0; i < clean_text.size(); ) { + size_t end = clean_text.find(' ', i); + if (end == std::string::npos) end = clean_text.size(); + else end++; // include space + splitter.feed(clean_text.substr(i, end - i)); + i = end; + } + splitter.flush(); + + bool first_audio = false; + for (auto& sentence : sentences) { + if (engine->streaming_cancelled.load(std::memory_order_acquire)) break; + + std::vector samples; + if (engine->pipeline.using_metalrt_tts()) { + samples = engine->pipeline.metalrt_tts().synthesize(sentence); + } else { + samples = engine->pipeline.tts().synthesize(sentence); + } + + // Write with backpressure + size_t offset = 0; + while (offset < samples.size() && + !engine->streaming_cancelled.load(std::memory_order_acquire)) { + size_t written = rb->write(samples.data() + offset, samples.size() - offset); + offset += written; + if (offset < samples.size()) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } + + if (!first_audio) { + first_audio = true; + if (callback) { + auto now = std::chrono::steady_clock::now(); + double ttfa_ms = std::chrono::duration(now - t_start_screen).count(); + char buf[32]; + snprintf(buf, sizeof(buf), "%.1f", ttfa_ms); + callback("first_audio", buf, user_data); + } + } + } + + // Wait for playback to drain + size_t samples_per_frame = 256; + while (rb->available_read() > samples_per_frame && + !engine->streaming_cancelled.load(std::memory_order_acquire)) { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + } + + if (callback) callback("complete", "{}", user_data); + return engine->last_response.c_str(); + } + auto t_start = std::chrono::steady_clock::now(); // --- TTS worker thread (sentence queue → ring buffer → CoreAudio) --- @@ -2756,6 +2960,37 @@ void rcli_get_context_info(RCLIHandle handle, int* out_prompt_tokens, int* out_c // VLM (Vision Language Model) // ============================================================================= +// Recursively create directories (like mkdir -p) +static bool mkdirs(const std::string& path) { + struct stat st; + if (stat(path.c_str(), &st) == 0) return S_ISDIR(st.st_mode); + // Recurse to create parent + auto slash = path.rfind('/'); + if (slash != std::string::npos && slash > 0) { + if (!mkdirs(path.substr(0, slash))) return false; + } + return mkdir(path.c_str(), 0755) == 0 || errno == EEXIST; +} + +// Download a file using fork/exec to avoid shell injection +static bool safe_download(const std::string& url, const std::string& dest) { + pid_t pid; + const char* argv[] = { + "curl", "-L", "--progress-bar", "-o", dest.c_str(), url.c_str(), nullptr + }; + int status = 0; + posix_spawn_file_actions_t actions; + posix_spawn_file_actions_init(&actions); + if (posix_spawnp(&pid, "curl", &actions, nullptr, + const_cast(argv), environ) != 0) { + posix_spawn_file_actions_destroy(&actions); + return false; + } + posix_spawn_file_actions_destroy(&actions); + waitpid(pid, &status, 0); + return WIFEXITED(status) && WEXITSTATUS(status) == 0; +} + static bool download_vlm_model(const std::string& url, const std::string& dest) { // Check if already exists if (access(dest.c_str(), R_OK) == 0) return true; @@ -2764,24 +2999,22 @@ static bool download_vlm_model(const std::string& url, const std::string& dest) // Ensure parent directory exists std::string dir = dest.substr(0, dest.rfind('/')); - std::string mkdir_cmd = "mkdir -p '" + dir + "'"; - (void)system(mkdir_cmd.c_str()); + if (!mkdirs(dir)) { + LOG_ERROR("VLM", "Failed to create directory: %s", dir.c_str()); + return false; + } - // Download with curl (progress bar) - std::string cmd = "curl -L --progress-bar -o '" + dest + "' '" + url + "'"; - int rc = system(cmd.c_str()); - if (rc != 0) { - LOG_ERROR("VLM", "Download failed (exit=%d)", rc); + // Download with curl (no shell interpolation) + if (!safe_download(url, dest)) { + LOG_ERROR("VLM", "Download failed"); unlink(dest.c_str()); return false; } return true; } -int rcli_vlm_init(RCLIHandle handle) { - if (!handle) return -1; - auto* engine = static_cast(handle); - +// Internal init (caller must hold engine->mutex) +static int vlm_init_locked(RCLIEngine* engine) { if (engine->vlm_initialized) return 0; // Fallback to default models dir if not set @@ -2843,12 +3076,20 @@ int rcli_vlm_init(RCLIHandle handle) { return 0; } +int rcli_vlm_init(RCLIHandle handle) { + if (!handle) return -1; + auto* engine = static_cast(handle); + std::lock_guard lock(engine->mutex); + return vlm_init_locked(engine); +} + const char* rcli_vlm_analyze(RCLIHandle handle, const char* image_path, const char* prompt) { if (!handle || !image_path) return ""; auto* engine = static_cast(handle); + std::lock_guard lock(engine->mutex); if (!engine->vlm_initialized) { - if (rcli_vlm_init(handle) != 0) { + if (vlm_init_locked(engine) != 0) { engine->last_vlm_response = "Error: VLM engine failed to initialize."; return engine->last_vlm_response.c_str(); } From fdb76a00ad5f90b6270290d2a96fa9130af731c9 Mon Sep 17 00:00:00 2001 From: AmanSwar Date: Fri, 13 Mar 2026 18:07:54 +0530 Subject: [PATCH 06/16] wire visual mode into TUI with [S] toggle and streamed TTS [S] key toggles visual overlay on/off, status bar shows active state, screen/visual text commands trigger capture + VLM analysis, switched from rcli_speak to rcli_speak_streaming for lower TTFA. --- src/cli/tui_app.h | 87 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/src/cli/tui_app.h b/src/cli/tui_app.h index 57fc7b6..4671531 100644 --- a/src/cli/tui_app.h +++ b/src/cli/tui_app.h @@ -14,9 +14,13 @@ #include "engines/metalrt_loader.h" #include "engines/vlm_engine.h" #include "audio/camera_capture.h" +#include "audio/screen_capture.h" #include "models/vlm_model_registry.h" #include "core/log.h" #include "core/personality.h" +#include + +extern char** environ; #include #include @@ -440,6 +444,17 @@ class TuiApp { run_camera_vlm("Describe what you see in this photo in detail."); return true; } + // S key: toggle visual mode (overlay frame for screen capture) + if (c == "s" || c == "S") { + if (screen_capture_overlay_active()) { + screen_capture_hide_overlay(); + add_system_message("Visual mode OFF"); + } else { + screen_capture_show_overlay(0, 0, 0, 0); + add_system_message("Visual mode ON — drag/resize the green frame over content, then ask a question"); + } + return true; + } if (c == "t" || c == "T") { tool_trace_enabled_ = !tool_trace_enabled_.load(std::memory_order_relaxed); add_system_message(tool_trace_enabled_ ? "Tool call trace: ON" : "Tool call trace: OFF"); @@ -1077,6 +1092,10 @@ class TuiApp { right.push_back(text("[A] actions ") | dim); right.push_back(text("[C] convo ") | dim); right.push_back(text("[V] camera ") | dim); + if (screen_capture_overlay_active()) + right.push_back(text("[S] visual ● ") | ftxui::color(ftxui::Color::Green)); + else + right.push_back(text("[S] visual ") | dim); right.push_back(text("[R] RAG ") | dim); right.push_back(text("[P] personality ") | dim); right.push_back(text("[D] cleanup ") | dim); @@ -2208,6 +2227,10 @@ class TuiApp { engine_, photo_path.c_str(), prompt_copy.c_str()); if (response && response[0]) { add_response(response, "VLM"); + // Speak the VLM response + voice_state_ = VoiceState::SPEAKING; + screen_->Post(Event::Custom); + rcli_speak(engine_, response); // Show performance stats RCLIVlmStats stats; if (rcli_vlm_get_stats(engine_, &stats) == 0) { @@ -2221,7 +2244,53 @@ class TuiApp { } voice_state_ = VoiceState::IDLE; // Open the captured photo in Preview - system(("open '" + photo_path + "' &").c_str()); + { + pid_t pid; + const char* argv[] = {"open", photo_path.c_str(), nullptr}; + posix_spawnp(&pid, "open", nullptr, nullptr, + const_cast(argv), environ); + } + screen_->Post(Event::Custom); + }).detach(); + } + + void run_screen_vlm(const std::string& prompt) { + char app_name[256]; + screen_capture_target_app_name(app_name, sizeof(app_name)); + add_system_message(std::string("Capturing screenshot of ") + app_name + "..."); + voice_state_ = VoiceState::THINKING; + std::string prompt_copy = prompt; + std::thread([this, prompt_copy]() { + std::string screen_path = "/tmp/rcli_screen_" + + std::to_string(std::chrono::system_clock::now().time_since_epoch().count()) + ".jpg"; + int rc = screen_capture_screenshot(screen_path.c_str()); + if (rc != 0) { + add_response("(Screen capture failed. Check screen recording permissions in System Settings > Privacy & Security > Screen Recording.)", ""); + voice_state_ = VoiceState::IDLE; + screen_->Post(Event::Custom); + return; + } + add_system_message("Screenshot captured! Analyzing with VLM..."); + screen_->Post(Event::Custom); + const char* response = rcli_vlm_analyze( + engine_, screen_path.c_str(), prompt_copy.c_str()); + if (response && response[0]) { + add_response(response, "VLM"); + // Speak via sentence-streamed TTS through ring buffer + voice_state_ = VoiceState::SPEAKING; + screen_->Post(Event::Custom); + rcli_speak_streaming(engine_, response, nullptr, nullptr); + RCLIVlmStats stats; + if (rcli_vlm_get_stats(engine_, &stats) == 0) { + char buf[128]; + snprintf(buf, sizeof(buf), "⚡ %.1f tok/s | %d tokens | %.1fs total", + stats.gen_tok_per_sec, stats.generated_tokens, stats.total_time_sec); + add_system_message(buf); + } + } else { + add_response("(VLM analysis failed. Install a VLM model: rcli models vlm)", ""); + } + voice_state_ = VoiceState::IDLE; screen_->Post(Event::Custom); }).detach(); } @@ -2285,6 +2354,22 @@ class TuiApp { return; } + if (cmd == "visual") { + if (screen_capture_overlay_active()) { + screen_capture_hide_overlay(); + add_system_message("Visual mode OFF"); + } else { + screen_capture_show_overlay(0, 0, 0, 0); + add_system_message("Visual mode ON — drag/resize the green frame, then ask a question"); + } + return; + } + + if (cmd == "screen" || cmd == "screenshot") { + run_screen_vlm("Describe what you see on this screen in detail."); + return; + } + if (cmd == "camera" || cmd == "photo" || cmd == "webcam") { run_camera_vlm("Describe what you see in this photo in detail."); return; From e6bf7657a2cc48d2a22a3b25d665b9f5ead76bd8 Mon Sep 17 00:00:00 2001 From: AmanSwar Date: Fri, 13 Mar 2026 18:08:00 +0530 Subject: [PATCH 07/16] add screen CLI subcommand and TTS to camera/screen commands --- src/cli/main.cpp | 70 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/src/cli/main.cpp b/src/cli/main.cpp index 2207117..e584aba 100644 --- a/src/cli/main.cpp +++ b/src/cli/main.cpp @@ -30,6 +30,10 @@ #include "mtmd.h" #include "mtmd-helper.h" #include "audio/camera_capture.h" +#include "audio/screen_capture.h" +#include + +extern char** environ; // Defined in cli_common.h as a forward declaration; implemented here because // it depends on the Objective-C mic_permission bridge compiled into this TU. @@ -542,6 +546,10 @@ static int cmd_camera(const Args& args) { const char* response = rcli_vlm_analyze(g_engine, photo_path.c_str(), prompt.c_str()); if (response && response[0]) { fprintf(stdout, "%s\n", response); + if (!args.no_speak) { + rcli_init(g_engine, args.models_dir.c_str(), args.gpu_layers); + rcli_speak(g_engine, response); + } // Print performance stats RCLIVlmStats stats; if (rcli_vlm_get_stats(g_engine, &stats) == 0) { @@ -550,7 +558,66 @@ static int cmd_camera(const Args& args) { stats.total_time_sec, stats.first_token_ms, color::reset); } // Open the captured photo in Preview so user can see what was captured - system(("open '" + photo_path + "'").c_str()); + { + pid_t pid; + const char* argv[] = {"open", photo_path.c_str(), nullptr}; + posix_spawnp(&pid, "open", nullptr, nullptr, + const_cast(argv), environ); + } + } else { + fprintf(stderr, "%s%sError: VLM analysis failed%s\n", + color::bold, color::red, color::reset); + rcli_destroy(g_engine); + return 1; + } + + rcli_destroy(g_engine); + return 0; +} + +// ============================================================================= +// Screen subcommand — screenshot + analyze +// ============================================================================= + +static int cmd_screen(const Args& args) { + std::string prompt = args.arg1.empty() + ? "Describe what you see on this screen in detail." : args.arg1; + + fprintf(stderr, "%sCapturing screenshot...%s\n", color::dim, color::reset); + std::string screen_path = "/tmp/rcli_screen.jpg"; + + int rc = screen_capture_screenshot(screen_path.c_str()); + if (rc != 0) { + fprintf(stderr, "%s%sError: Screen capture failed. Check screen recording permissions.%s\n", + color::bold, color::red, color::reset); + return 1; + } + fprintf(stderr, "%sScreenshot captured! Analyzing with VLM...%s\n", color::dim, color::reset); + + std::string config_json = "{\"models_dir\": \"" + args.models_dir + "\"}"; + g_engine = rcli_create(config_json.c_str()); + if (!g_engine) return 1; + + if (rcli_vlm_init(g_engine) != 0) { + fprintf(stderr, "%s%sError: Failed to initialize VLM engine%s\n", + color::bold, color::red, color::reset); + rcli_destroy(g_engine); + return 1; + } + + const char* response = rcli_vlm_analyze(g_engine, screen_path.c_str(), prompt.c_str()); + if (response && response[0]) { + fprintf(stdout, "%s\n", response); + if (!args.no_speak) { + rcli_init(g_engine, args.models_dir.c_str(), args.gpu_layers); + rcli_speak(g_engine, response); + } + RCLIVlmStats stats; + if (rcli_vlm_get_stats(g_engine, &stats) == 0) { + fprintf(stderr, "\n%s⚡ %.1f tok/s (%d tokens, %.1fs total, first token %.0fms)%s\n", + color::dim, stats.gen_tok_per_sec, stats.generated_tokens, + stats.total_time_sec, stats.first_token_ms, color::reset); + } } else { fprintf(stderr, "%s%sError: VLM analysis failed%s\n", color::bold, color::red, color::reset); @@ -1068,6 +1135,7 @@ int main(int argc, char** argv) { if (args.command == "rag") return cmd_rag(args); if (args.command == "vlm") return cmd_vlm(args); if (args.command == "camera") return cmd_camera(args); + if (args.command == "screen") return cmd_screen(args); if (args.command == "setup") return cmd_setup(args); if (args.command == "models") return cmd_models(args); if (args.command == "voices") return cmd_voices(args); From 593e072fbe1beaa9dfc4af0e5ec267ccf022728e Mon Sep 17 00:00:00 2001 From: AmanSwar Date: Fri, 13 Mar 2026 18:08:05 +0530 Subject: [PATCH 08/16] add screen command to help text and usage examples --- src/cli/help.h | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/cli/help.h b/src/cli/help.h index ccceaea..9ecca9b 100644 --- a/src/cli/help.h +++ b/src/cli/help.h @@ -20,6 +20,7 @@ inline void print_usage(const char* argv0) { " %sactions%s [name] List all actions, or show detail for one\n" " %saction%s [json] Execute a named action directly\n" " %svlm%s [prompt] Analyze image with Vision Language Model\n" + " %sscreen%s [prompt] Capture screenshot & analyze with VLM\n" " %srag%s RAG: ingest docs, query, status\n" " %ssetup%s Download AI models (~1GB)\n" " %smodels%s Manage all AI models (LLM, STT, TTS)\n" @@ -48,6 +49,8 @@ inline void print_usage(const char* argv0) { " rcli actions # see all actions\n" " rcli vlm photo.jpg # analyze an image\n" " rcli vlm photo.jpg \"What is this?\" # image with custom prompt\n" + " rcli screen # capture & analyze screen\n" + " rcli screen \"What app is open?\" # screen with custom prompt\n" " rcli actions create_note # action detail\n" " rcli setup # download models\n\n", color::bold, color::orange, color::reset, @@ -73,6 +76,7 @@ inline void print_usage(const char* argv0) { color::green, color::reset, color::green, color::reset, color::green, color::reset, + color::green, color::reset, color::dim, color::reset, color::dim, color::reset); } @@ -135,10 +139,12 @@ inline void print_help_interactive() { fprintf(stderr, " %srag status%s show indexed documents\n", color::bold, color::reset); fprintf(stderr, " %srag ingest %s index docs for Q&A\n", color::bold, color::reset); fprintf(stderr, " %scamera%s capture photo from webcam & analyze\n", color::bold, color::reset); + fprintf(stderr, " %sscreen%s capture screenshot & analyze\n", color::bold, color::reset); fprintf(stderr, " %squit%s exit\n\n", color::bold, color::reset); fprintf(stderr, " %s%s Vision:%s\n", color::bold, color::orange, color::reset); fprintf(stderr, " Drag & drop an image file to analyze it with the VLM.\n"); - fprintf(stderr, " Type %scamera%s to capture a photo from your webcam.\n\n", color::bold, color::reset); + fprintf(stderr, " Type %scamera%s to capture a photo from your webcam.\n", color::bold, color::reset); + fprintf(stderr, " Type %sscreen%s to capture and analyze your screen.\n\n", color::bold, color::reset); fprintf(stderr, " %s%s Try:%s\n", color::bold, color::orange, color::reset); fprintf(stderr, " %s\"Open Safari\" \"What's on my calendar?\" \"Set volume to 50\"%s\n\n", color::dim, color::reset); From b6af46ad814a9fc9c885459c06ed9e38b6e26084 Mon Sep 17 00:00:00 2001 From: AmanSwar Date: Fri, 13 Mar 2026 18:08:09 +0530 Subject: [PATCH 09/16] minor fixes: TTS engine, VLM engine, text sanitizer, tests --- src/engines/tts_engine.cpp | 17 ++++++ src/engines/tts_engine.h | 6 ++ src/engines/vlm_engine.cpp | 6 +- src/pipeline/text_sanitizer.h | 105 ++++++++++++++++++++++++++++++++++ src/test/test_pipeline.cpp | 45 ++++++++------- 5 files changed, 157 insertions(+), 22 deletions(-) diff --git a/src/engines/tts_engine.cpp b/src/engines/tts_engine.cpp index cf5cd95..b139960 100644 --- a/src/engines/tts_engine.cpp +++ b/src/engines/tts_engine.cpp @@ -77,9 +77,26 @@ bool TtsEngine::init(const TtsConfig& config) { return true; } +bool TtsEngine::reinit() { + if (!initialized_) return false; + LOG_DEBUG("TTS", "Reinitializing ONNX session to prevent audio degradation"); + if (tts_) { + SherpaOnnxDestroyOfflineTts(tts_); + tts_ = nullptr; + } + initialized_ = false; + synth_count_ = 0; + return init(config_); +} + std::vector TtsEngine::synthesize(const std::string& text) { if (!initialized_ || !tts_) return {}; + // Periodically reinit to prevent audio quality degradation + if (++synth_count_ >= kReinitInterval) { + reinit(); + } + stats_ = TtsStats{}; int64_t t_start = now_us(); diff --git a/src/engines/tts_engine.h b/src/engines/tts_engine.h index 40c36e9..90b9018 100644 --- a/src/engines/tts_engine.h +++ b/src/engines/tts_engine.h @@ -63,12 +63,18 @@ class TtsEngine { // Change speaker at runtime (Kokoro multi-voice) void set_speaker_id(int id) { config_.speaker_id = id; } + // Reinitialize the ONNX Runtime session to flush accumulated state. + // Call periodically to prevent audio degradation over long sessions. + bool reinit(); + private: const SherpaOnnxOfflineTts* tts_ = nullptr; TtsConfig config_; TtsStats stats_; int sample_rate_ = 22050; bool initialized_ = false; + int synth_count_ = 0; // synthesis calls since last reinit + static constexpr int kReinitInterval = 20; // reinit every N calls }; } // namespace rastack diff --git a/src/engines/vlm_engine.cpp b/src/engines/vlm_engine.cpp index 0238063..1f2d09b 100644 --- a/src/engines/vlm_engine.cpp +++ b/src/engines/vlm_engine.cpp @@ -7,6 +7,7 @@ #include "mtmd-helper.h" #include #include +#include namespace rastack { @@ -32,8 +33,9 @@ bool VlmEngine::init(const VlmConfig& config) { config_ = config; - // Initialize backend (loads Metal, etc.) - ggml_backend_load_all(); + // Initialize backend (loads Metal, etc.) — safe to call multiple times + static std::once_flag backend_init_flag; + std::call_once(backend_init_flag, [] { ggml_backend_load_all(); }); // Load language model llama_model_params model_params = llama_model_default_params(); diff --git a/src/pipeline/text_sanitizer.h b/src/pipeline/text_sanitizer.h index b21b1a0..5c454a3 100644 --- a/src/pipeline/text_sanitizer.h +++ b/src/pipeline/text_sanitizer.h @@ -73,6 +73,33 @@ inline std::string sanitize_for_tts(const std::string& text) { out = std::move(cleaned); } + // 4b. Strip emote/action markers like *laughs*, *sighs*, *smiles*, etc. + // These are non-speakable stage directions that LLMs often generate. + { + std::string cleaned; + cleaned.reserve(out.size()); + for (size_t i = 0; i < out.size(); i++) { + if (out[i] == '*') { + size_t close = out.find('*', i + 1); + if (close != std::string::npos && close - i <= 30) { + // Check it looks like an emote (single word or short phrase, no nested formatting) + bool is_emote = true; + for (size_t j = i + 1; j < close; j++) { + if (out[j] == '*' || out[j] == '\n') { is_emote = false; break; } + } + if (is_emote) { + i = close; // skip past closing * + // Also skip trailing space if present + if (i + 1 < out.size() && out[i + 1] == ' ') i++; + continue; + } + } + } + cleaned += out[i]; + } + out = std::move(cleaned); + } + // 5. Strip markdown symbols and non-speakable formatting { std::string cleaned; @@ -215,6 +242,84 @@ inline std::string sanitize_for_tts(const std::string& text) { } } + // 6c. Replace brand names / proper nouns that G2P spells letter-by-letter + // with phonetic approximations so TTS pronounces them naturally. + { + struct Phonetic { const char* from; const char* to; }; + static const Phonetic table[] = { + {"Spotify", "Spotifye"}, + {"spotify", "spotifye"}, + {"SPOTIFY", "Spotifye"}, + {"YouTube", "You Tube"}, + {"Youtube", "You Tube"}, + {"youtube", "you tube"}, + {"YOUTUBE", "You Tube"}, + {"WiFi", "Why Fye"}, + {"wifi", "why fye"}, + {"WIFI", "Why Fye"}, + {"Wi-Fi", "Why Fye"}, + {"iPhone", "eye phone"}, + {"iphone", "eye phone"}, + {"IPHONE", "eye phone"}, + {"iPad", "eye pad"}, + {"ipad", "eye pad"}, + {"IPAD", "eye pad"}, + {"macOS", "mac O S"}, + {"MacOS", "mac O S"}, + {"iOS", "eye O S"}, + {"AirPods", "Air Pods"}, + {"airpods", "air pods"}, + {"AIRPODS", "Air Pods"}, + {"ChatGPT", "Chat G P T"}, + {"WhatsApp", "Whats App"}, + {"whatsapp", "whats app"}, + {"WHATSAPP", "Whats App"}, + {"TikTok", "Tick Tock"}, + {"tiktok", "tick tock"}, + {"TIKTOK", "Tick Tock"}, + {"LinkedIn", "Linked In"}, + {"linkedin", "linked in"}, + {"LINKEDIN", "Linked In"}, + }; + for (auto& p : table) { + std::string needle(p.from); + std::string replacement(p.to); + size_t pos = 0; + while ((pos = out.find(needle, pos)) != std::string::npos) { + bool left_ok = (pos == 0 || out[pos - 1] == ' ' || out[pos - 1] == '\n' || + out[pos - 1] == '"' || out[pos - 1] == '\''); + size_t end = pos + needle.size(); + bool right_ok = (end >= out.size() || out[end] == ' ' || out[end] == ',' || + out[end] == '.' || out[end] == '!' || out[end] == '?' || + out[end] == '\n' || out[end] == ';' || out[end] == ':' || + out[end] == '\'' || out[end] == '"'); + if (left_ok && right_ok) { + out.replace(pos, needle.size(), replacement); + pos += replacement.size(); + } else { + pos += needle.size(); + } + } + } + } + + // 6d. Replace hyphens between letters/words with spaces so G2P does not + // spell out hyphenated compounds (e.g. "well-known" → "well known"). + { + std::string cleaned; + cleaned.reserve(out.size()); + for (size_t i = 0; i < out.size(); i++) { + if (out[i] == '-' && i > 0 && i + 1 < out.size() && + std::isalpha((unsigned char)out[i - 1]) && + std::isalpha((unsigned char)out[i + 1])) { + cleaned += ' '; + } else { + cleaned += out[i]; + } + } + out = std::move(cleaned); + } + // 7. Collapse multiple whitespace to single space, trim { std::string cleaned; diff --git a/src/test/test_pipeline.cpp b/src/test/test_pipeline.cpp index a4b7bfb..d73a1b8 100644 --- a/src/test/test_pipeline.cpp +++ b/src/test/test_pipeline.cpp @@ -783,31 +783,36 @@ static void test_metalrt_llm(const std::string& models_dir) { engine.reset_conversation(); engine.generate("hi"); - // Benchmark 3 prompts - const char* prompts[] = { - "What is 2+2?", - "Write a haiku about the sea.", - "Explain gravity in one sentence.", - }; - - TEST_SECTION("MetalRT LLM Inference (Metal GPU)"); - for (int i = 0; i < 3; i++) { + // Benchmark across max_tokens sweep: 64, 128, 256, 512, 1024, 2048 + const int token_limits[] = { 64, 128, 256, 512, 1024, 2048 }; + const char* prompt = "Write a detailed essay about the history and future of artificial intelligence, " + "covering early pioneers, neural networks, deep learning breakthroughs, " + "large language models, and predictions for the next decade."; + + TEST_SECTION("MetalRT LLM Token Sweep Benchmark (Metal GPU)"); + fprintf(stderr, "\n \033[1;33m%-12s %8s %12s %10s %12s %10s %10s\033[0m\n", + "max_tokens", "gen_tok", "decode_ms", "tok/s", "prefill_ms", "pf_tok/s", "wall_ms"); + fprintf(stderr, " \033[33m%s\033[0m\n", + "------------ -------- ------------ ---------- ------------ ---------- ----------"); + + for (int limit : token_limits) { + engine.set_max_tokens(limit); + engine.set_ignore_eos(true); engine.reset_conversation(); + t0 = std::chrono::steady_clock::now(); - std::string result = engine.generate(prompts[i]); + std::string result = engine.generate(prompt); double gen_ms = elapsed_ms(t0); const auto& stats = engine.last_stats(); - TEST_INFO("--- Run %d ---", i + 1); - TEST_INFO(" Prompt: \"%s\"", prompts[i]); - TEST_INFO(" Response: \"%.*s%s\"", (int)std::min(result.size(), (size_t)80), - result.c_str(), result.size() > 80 ? "..." : ""); - TEST_INFO(" Backend: MetalRT (Metal GPU)"); - TEST_INFO(" Prefill: %.1f ms (%d tokens, %.0f tok/s)", - stats.prompt_eval_us / 1000.0, stats.prompt_tokens, stats.prompt_tps()); - TEST_INFO(" Decode: %.1f ms (%d tokens, %.0f tok/s)", - stats.generation_us / 1000.0, stats.generated_tokens, stats.gen_tps()); - TEST_INFO(" Wall: %.1f ms", gen_ms); + fprintf(stderr, " %-12d %8d %10.1f ms %8.1f %10.1f ms %8.0f %8.1f ms\n", + limit, + stats.generated_tokens, + stats.generation_us / 1000.0, + stats.gen_tps(), + stats.prompt_eval_us / 1000.0, + stats.prompt_tps(), + gen_ms); TEST("run produces output", !result.empty()); } } From 2953f7b28a3e12c25420c4ecf53a40961144aa26 Mon Sep 17 00:00:00 2001 From: AmanSwar Date: Sat, 14 Mar 2026 15:29:46 +0530 Subject: [PATCH 10/16] Integrate MetalRT VLM backend with automatic fallback to llama.cpp Adds vision function pointer resolution to MetalRTLoader and routes VLM calls through MetalRT when the dylib is loaded and the Qwen3-VL safetensors model is installed. Falls back to llama.cpp VlmEngine transparently if MetalRT is unavailable. --- src/api/rcli_api.cpp | 144 ++++++++++++++++++++++++++++++--- src/engines/metalrt_loader.cpp | 16 ++++ src/engines/metalrt_loader.h | 41 ++++++++++ 3 files changed, 189 insertions(+), 12 deletions(-) diff --git a/src/api/rcli_api.cpp b/src/api/rcli_api.cpp index c59e0b2..f44839c 100644 --- a/src/api/rcli_api.cpp +++ b/src/api/rcli_api.cpp @@ -119,8 +119,20 @@ struct RCLIEngine { // VLM (Vision Language Model) subsystem VlmEngine vlm_engine; bool vlm_initialized = false; + bool using_metalrt_vlm = false; // true when VLM is running on MetalRT backend + void* metalrt_vision_handle = nullptr; // opaque handle from metalrt_vision_create() std::string last_vlm_response; + // MetalRT VLM stats (filled after each analyze call) + struct { + double vision_encode_ms = 0; + double prefill_ms = 0; + double decode_ms = 0; + double tps = 0; + int prompt_tokens = 0; + int generated_tokens = 0; + } metalrt_vlm_stats; + std::mutex mutex; bool initialized = false; }; @@ -206,6 +218,13 @@ void rcli_destroy(RCLIHandle handle) { if (engine->initialized) { engine->pipeline.stop_live(); } + // Destroy MetalRT vision handle if loaded + if (engine->metalrt_vision_handle) { + auto& loader = rastack::MetalRTLoader::instance(); + if (loader.vision_destroy) + loader.vision_destroy(engine->metalrt_vision_handle); + engine->metalrt_vision_handle = nullptr; + } delete engine; } @@ -1073,7 +1092,27 @@ static std::string handle_screen_intent(RCLIEngine* engine, const std::string& u vlm_prompt = "Describe what you see on this screen in detail."; } - std::string result = engine->vlm_engine.analyze_image(path, vlm_prompt, nullptr); + std::string result; + if (engine->using_metalrt_vlm && engine->metalrt_vision_handle) { + auto& loader = rastack::MetalRTLoader::instance(); + rastack::MetalRTLoader::MetalRTVisionOptions opts{}; + opts.max_tokens = 512; + opts.top_k = 40; + opts.temperature = 0.0f; + opts.think = false; + + rastack::MetalRTLoader::MetalRTVisionResult vr; + { + std::lock_guard gpu_lock(loader.gpu_mutex()); + vr = loader.vision_analyze(engine->metalrt_vision_handle, + path.c_str(), vlm_prompt.c_str(), &opts); + } + result = vr.response ? std::string(vr.response) : (vr.text ? std::string(vr.text) : ""); + if (loader.vision_free_result) loader.vision_free_result(vr); + } else { + result = engine->vlm_engine.analyze_image(path, vlm_prompt, nullptr); + } + if (result.empty()) { return "I captured your screen but the analysis failed. Please try again."; } @@ -3025,6 +3064,34 @@ static int vlm_init_locked(RCLIEngine* engine) { engine->models_dir = "./models"; } + // --- Try MetalRT vision backend first (if dylib loaded and VLM model installed) --- + auto& mrt_loader = rastack::MetalRTLoader::instance(); + if (mrt_loader.is_loaded() && mrt_loader.has_vision()) { + // Look for Qwen3-VL-2B safetensors model in MetalRT models dir + std::string vlm_dir = rcli::metalrt_models_dir() + "/Qwen3-VL-2B-MLX-4bit"; + std::string safetensors = vlm_dir + "/model.safetensors"; + if (access(safetensors.c_str(), R_OK) == 0) { + LOG_INFO("VLM", "MetalRT VLM model found at %s", vlm_dir.c_str()); + void* handle = mrt_loader.vision_create(); + if (handle) { + std::lock_guard gpu_lock(mrt_loader.gpu_mutex()); + if (mrt_loader.vision_load(handle, vlm_dir.c_str())) { + engine->metalrt_vision_handle = handle; + engine->using_metalrt_vlm = true; + engine->vlm_initialized = true; + const char* mname = mrt_loader.vision_model_name + ? mrt_loader.vision_model_name(handle) : "Qwen3-VL-2B"; + LOG_INFO("VLM", "MetalRT VLM engine ready (%s)", mname); + return 0; + } + LOG_WARN("VLM", "MetalRT vision_load failed, falling back to llama.cpp"); + mrt_loader.vision_destroy(handle); + } + } + } + + // --- Fallback: llama.cpp VLM backend --- + // Find or download VLM model auto vlm_models = rcli::all_vlm_models(); rcli::VlmModelDef model_def; @@ -3099,13 +3166,57 @@ const char* rcli_vlm_analyze(RCLIHandle handle, const char* image_path, const ch ? std::string(prompt) : "Describe this image in detail."; - std::string result = engine->vlm_engine.analyze_image( - std::string(image_path), text_prompt, nullptr); + if (engine->using_metalrt_vlm && engine->metalrt_vision_handle) { + // MetalRT vision backend — stream tokens to build result + auto& loader = rastack::MetalRTLoader::instance(); + std::string accumulated; + + rastack::MetalRTLoader::MetalRTVisionOptions opts{}; + opts.max_tokens = 512; + opts.top_k = 40; + opts.temperature = 0.0f; + opts.think = false; + + rastack::MetalRTStreamCb stream_cb = [](const char* piece, void* ud) -> bool { + auto* out = static_cast(ud); + out->append(piece); + return true; + }; - if (result.empty()) { - engine->last_vlm_response = "Error: Failed to analyze image."; + rastack::MetalRTLoader::MetalRTVisionResult vr; + { + std::lock_guard gpu_lock(loader.gpu_mutex()); + vr = loader.vision_analyze_stream(engine->metalrt_vision_handle, + image_path, text_prompt.c_str(), + stream_cb, &accumulated, &opts); + } + + // Store stats + engine->metalrt_vlm_stats.vision_encode_ms = vr.vision_encode_ms; + engine->metalrt_vlm_stats.prefill_ms = vr.prefill_ms; + engine->metalrt_vlm_stats.decode_ms = vr.decode_ms; + engine->metalrt_vlm_stats.tps = vr.tps; + engine->metalrt_vlm_stats.prompt_tokens = vr.prompt_tokens; + engine->metalrt_vlm_stats.generated_tokens = vr.generated_tokens; + + std::string result = vr.response ? std::string(vr.response) : accumulated; + if (loader.vision_free_result) loader.vision_free_result(vr); + + if (result.empty()) { + engine->last_vlm_response = "Error: Failed to analyze image."; + } else { + engine->last_vlm_response = result; + } } else { - engine->last_vlm_response = result; + // llama.cpp VLM backend + std::string result = engine->vlm_engine.analyze_image( + std::string(image_path), text_prompt, nullptr); + + if (result.empty()) { + engine->last_vlm_response = "Error: Failed to analyze image."; + } else { + engine->last_vlm_response = result; + } } return engine->last_vlm_response.c_str(); } @@ -3121,12 +3232,21 @@ int rcli_vlm_get_stats(RCLIHandle handle, RCLIVlmStats* out_stats) { auto* engine = static_cast(handle); if (!engine->vlm_initialized) return -1; - auto& s = engine->vlm_engine.last_stats(); - out_stats->gen_tok_per_sec = s.gen_tps(); - out_stats->generated_tokens = static_cast(s.generated_tokens); - out_stats->total_time_sec = (s.image_encode_us + s.generation_us) / 1e6; - out_stats->image_encode_ms = s.image_encode_us / 1000.0; - out_stats->first_token_ms = s.first_token_us / 1000.0; + if (engine->using_metalrt_vlm) { + auto& s = engine->metalrt_vlm_stats; + out_stats->gen_tok_per_sec = s.tps; + out_stats->generated_tokens = s.generated_tokens; + out_stats->total_time_sec = (s.vision_encode_ms + s.prefill_ms + s.decode_ms) / 1000.0; + out_stats->image_encode_ms = s.vision_encode_ms; + out_stats->first_token_ms = s.prefill_ms; + } else { + auto& s = engine->vlm_engine.last_stats(); + out_stats->gen_tok_per_sec = s.gen_tps(); + out_stats->generated_tokens = static_cast(s.generated_tokens); + out_stats->total_time_sec = (s.image_encode_us + s.generation_us) / 1e6; + out_stats->image_encode_ms = s.image_encode_us / 1000.0; + out_stats->first_token_ms = s.first_token_us / 1000.0; + } return 0; } diff --git a/src/engines/metalrt_loader.cpp b/src/engines/metalrt_loader.cpp index 7dd5363..ba0f1c8 100644 --- a/src/engines/metalrt_loader.cpp +++ b/src/engines/metalrt_loader.cpp @@ -186,6 +186,22 @@ bool MetalRTLoader::load() { LOG_DEBUG("MetalRT", "TTS symbols: tts_create=%p tts_synthesize=%p tts_sample_rate=%p", (void*)tts_create, (void*)tts_synthesize, (void*)tts_sample_rate); + // Vision (VLM) symbols (optional) + vision_create = resolve("metalrt_vision_create"); + vision_destroy = resolve("metalrt_vision_destroy"); + vision_load = resolve("metalrt_vision_load"); + vision_analyze = resolve("metalrt_vision_analyze"); + vision_analyze_stream = resolve("metalrt_vision_analyze_stream"); + vision_generate = resolve("metalrt_vision_generate"); + vision_generate_stream = resolve("metalrt_vision_generate_stream"); + vision_reset = resolve("metalrt_vision_reset"); + vision_model_name = resolve("metalrt_vision_model_name"); + vision_device_name = resolve("metalrt_vision_device_name"); + vision_free_result = resolve("metalrt_vision_free_result"); + + LOG_DEBUG("MetalRT", "VLM symbols: vision_create=%p vision_analyze=%p vision_stream=%p", + (void*)vision_create, (void*)vision_analyze, (void*)vision_analyze_stream); + if (!fn_abi_version_ || !create || !destroy || !load_model || !generate) { LOG_ERROR("MetalRT", "dylib missing required LLM symbols: abi=%p create=%p destroy=%p load=%p gen=%p", (void*)fn_abi_version_, (void*)create, (void*)destroy, (void*)load_model, (void*)generate); diff --git a/src/engines/metalrt_loader.h b/src/engines/metalrt_loader.h index 6d6b0b8..41247ed 100644 --- a/src/engines/metalrt_loader.h +++ b/src/engines/metalrt_loader.h @@ -128,6 +128,47 @@ class MetalRTLoader { TtsFreeAudioFn tts_free_audio = nullptr; TtsSampleRateFn tts_sample_rate = nullptr; + // --- Vision (VLM) function pointers --- + + struct MetalRTVisionResult { + const char* text; + const char* thinking; + const char* response; + int prompt_tokens; + int generated_tokens; + double vision_encode_ms; + double prefill_ms; + double decode_ms; + double tps; + }; + + struct MetalRTVisionOptions { + int max_tokens; + int top_k; + float temperature; + bool think; + }; + + using VisionAnalyzeFn = MetalRTVisionResult (*)(void*, const char*, const char*, const MetalRTVisionOptions*); + using VisionAnalyzeStreamFn = MetalRTVisionResult (*)(void*, const char*, const char*, MetalRTStreamCb, void*, const MetalRTVisionOptions*); + using VisionGenerateFn = MetalRTVisionResult (*)(void*, const char*, const MetalRTVisionOptions*); + using VisionGenerateStreamFn = MetalRTVisionResult (*)(void*, const char*, MetalRTStreamCb, void*, const MetalRTVisionOptions*); + using VisionFreeResultFn = void (*)(MetalRTVisionResult); + + CreateFn vision_create = nullptr; + DestroyFn vision_destroy = nullptr; + LoadFn vision_load = nullptr; + VisionAnalyzeFn vision_analyze = nullptr; + VisionAnalyzeStreamFn vision_analyze_stream = nullptr; + VisionGenerateFn vision_generate = nullptr; + VisionGenerateStreamFn vision_generate_stream = nullptr; + ResetFn vision_reset = nullptr; + ModelNameFn vision_model_name = nullptr; + DeviceNameFn vision_device_name = nullptr; + VisionFreeResultFn vision_free_result = nullptr; + + bool has_vision() const { return vision_create != nullptr && vision_analyze != nullptr; } + // --- Install / remove / version management --- static bool install(const std::string& version = "latest"); From c634d547128565c9f5748f9bf727261ad2285b72 Mon Sep 17 00:00:00 2001 From: AmanSwar Date: Sat, 14 Mar 2026 18:06:07 +0530 Subject: [PATCH 11/16] Add auto-download for Qwen3-VL model in setup and on first VLM use Register Qwen3-VL-2B in MetalRT component models, auto-download from HuggingFace in vlm_init_locked() when model files are missing, and update setup/download UI to handle VLM component type. --- src/api/rcli_api.cpp | 17 ++++++++++++++++- src/cli/main.cpp | 7 ++++--- src/cli/setup_cmds.h | 6 ++++-- src/models/model_registry.h | 20 +++++++++++++++++++- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/api/rcli_api.cpp b/src/api/rcli_api.cpp index f44839c..1abcda6 100644 --- a/src/api/rcli_api.cpp +++ b/src/api/rcli_api.cpp @@ -3067,9 +3067,24 @@ static int vlm_init_locked(RCLIEngine* engine) { // --- Try MetalRT vision backend first (if dylib loaded and VLM model installed) --- auto& mrt_loader = rastack::MetalRTLoader::instance(); if (mrt_loader.is_loaded() && mrt_loader.has_vision()) { - // Look for Qwen3-VL-2B safetensors model in MetalRT models dir std::string vlm_dir = rcli::metalrt_models_dir() + "/Qwen3-VL-2B-MLX-4bit"; std::string safetensors = vlm_dir + "/model.safetensors"; + + // Auto-download VLM model if not installed + if (access(safetensors.c_str(), R_OK) != 0) { + LOG_INFO("VLM", "MetalRT VLM model not found, downloading..."); + std::string hf_base = "https://huggingface.co/runanywhere/Qwen3-VL-2B-Instruct-4bit/resolve/main/"; + std::string dl_cmd = "bash -c '" + "set -e; mkdir -p \"" + vlm_dir + "\"; " + "curl -fL -# -o \"" + vlm_dir + "/config.json\" \"" + hf_base + "config.json\"; " + "curl -fL -# -o \"" + vlm_dir + "/model.safetensors\" \"" + hf_base + "model.safetensors\"; " + "curl -fL -# -o \"" + vlm_dir + "/tokenizer.json\" \"" + hf_base + "tokenizer.json\"; " + "'"; + if (system(dl_cmd.c_str()) != 0) { + LOG_WARN("VLM", "MetalRT VLM model download failed"); + } + } + if (access(safetensors.c_str(), R_OK) == 0) { LOG_INFO("VLM", "MetalRT VLM model found at %s", vlm_dir.c_str()); void* handle = mrt_loader.vision_create(); diff --git a/src/cli/main.cpp b/src/cli/main.cpp index e584aba..93279af 100644 --- a/src/cli/main.cpp +++ b/src/cli/main.cpp @@ -856,16 +856,17 @@ static int cmd_metalrt(const Args& args) { inst ? color::reset : ""); } - // STT/TTS component models + // STT/TTS/VLM component models size_t offset = mrt_models.size(); - fprintf(stderr, "\n %s— STT/TTS Components —%s\n", color::bold, color::reset); + fprintf(stderr, "\n %s— STT/TTS/VLM Components —%s\n", color::bold, color::reset); fprintf(stderr, " %s# %-28s %-8s %-5s Status%s\n", color::bold, "Model", "Size", "Type", color::reset); for (size_t i = 0; i < comp_models.size(); i++) { auto& cm = comp_models[i]; bool inst = rcli::is_metalrt_component_installed(cm); - std::string type_label = (cm.component == "stt") ? "STT" : "TTS"; + std::string type_label = (cm.component == "stt") ? "STT" + : (cm.component == "vlm") ? "VLM" : "TTS"; fprintf(stderr, " %s%zu%s %-28s %-8s %-5s %s%s%s\n", color::bold, offset + i + 1, color::reset, cm.name.c_str(), diff --git a/src/cli/setup_cmds.h b/src/cli/setup_cmds.h index f33dcc7..b5f85fb 100644 --- a/src/cli/setup_cmds.h +++ b/src/cli/setup_cmds.h @@ -178,13 +178,15 @@ inline int cmd_setup(const Args& args) { if (!cm.default_install) continue; std::string cm_dir = rcli::metalrt_models_dir() + "/" + cm.dir_name; if (rcli::is_metalrt_component_installed(cm)) { - std::string skip_label = (cm.component == "stt") ? "STT" : "TTS"; + std::string skip_label = (cm.component == "stt") ? "STT" + : (cm.component == "vlm") ? "VLM" : "TTS"; fprintf(stderr, " %s%sMetalRT %s already installed:%s %s\n", color::bold, color::green, skip_label.c_str(), color::reset, cm.name.c_str()); continue; } - std::string type_label = (cm.component == "stt") ? "STT" : "TTS"; + std::string type_label = (cm.component == "stt") ? "STT" + : (cm.component == "vlm") ? "VLM" : "TTS"; fprintf(stderr, " %sDownloading MetalRT %s: %s (~%s)...%s\n", color::dim, type_label.c_str(), cm.name.c_str(), rcli::format_size(cm.size_mb).c_str(), color::reset); diff --git a/src/models/model_registry.h b/src/models/model_registry.h index 79d3da4..6b1e99c 100644 --- a/src/models/model_registry.h +++ b/src/models/model_registry.h @@ -287,7 +287,7 @@ inline bool is_metalrt_model_installed(const LlmModelDef& m) { struct MetalRTComponentModel { std::string id; std::string name; - std::string component; // "stt" or "tts" + std::string component; // "stt", "tts", or "vlm" std::string hf_repo; // HuggingFace repo path (org/repo) std::string hf_subdir; // subdirectory within repo (empty for flat repos) std::string dir_name; // local dir under metalrt_models_dir() @@ -347,9 +347,27 @@ inline std::vector metalrt_component_models() { "", true, }, + { + "metalrt-qwen3-vl-2b", + "Qwen3-VL 2B (MLX 4-bit)", + "vlm", + "runanywhere/Qwen3-VL-2B-Instruct-4bit", + "", + "Qwen3-VL-2B-MLX-4bit", + 1200, + "Qwen3 Vision-Language model for image understanding", + "", + true, + }, }; } +inline bool is_metalrt_vlm_installed() { + std::string dir = metalrt_models_dir() + "/Qwen3-VL-2B-MLX-4bit"; + std::string safetensors = dir + "/model.safetensors"; + return access(safetensors.c_str(), R_OK) == 0; +} + inline bool is_metalrt_component_installed(const MetalRTComponentModel& m) { std::string dir = metalrt_models_dir() + "/" + m.dir_name; if (access(dir.c_str(), R_OK) != 0) return false; From c05f90de60d618d68ba5f6ea9f72411899b339f3 Mon Sep 17 00:00:00 2001 From: AmanSwar Date: Sun, 15 Mar 2026 03:57:32 +0530 Subject: [PATCH 12/16] Fix multi-turn KV cache corruption in MetalRT streaming path Always use reset_cache=true for KV cache continuation in both rcli_process_command and rcli_process_and_speak. The incremental path (reset_cache=false) was unsafe because metalrt_kv_continuation_len tracked text length but not generated response tokens, causing duplicate content in the KV cache and corrupted attention on turn 2+. --- src/api/rcli_api.cpp | 40 ++++++++++++++-------------------------- 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/src/api/rcli_api.cpp b/src/api/rcli_api.cpp index 1abcda6..11382c7 100644 --- a/src/api/rcli_api.cpp +++ b/src/api/rcli_api.cpp @@ -1191,19 +1191,12 @@ const char* rcli_process_command(RCLIHandle handle, const char* text) { full_prompt.compare(0, cached.size(), cached) == 0) { std::string full_continuation = full_prompt.substr(cached.size()); - if (engine->metalrt_kv_continuation_len > 0 && - engine->metalrt_kv_continuation_len < full_continuation.size()) { - std::string new_part = full_continuation.substr(engine->metalrt_kv_continuation_len); - LOG_TRACE("RCLI", "[process_command] incremental continue " - "(new=%zu chars, skip=%zu already in KV)", - new_part.size(), engine->metalrt_kv_continuation_len); - raw_output = mrt.generate_raw_continue(new_part, nullptr, false); - } else { - LOG_TRACE("RCLI", "[process_command] full continue " - "(continuation=%zu chars)", full_continuation.size()); - raw_output = mrt.generate_raw_continue(full_continuation, nullptr, true); - } - engine->metalrt_kv_continuation_len = full_continuation.size(); + // Always re-prefill full continuation from cached system prompt. + // Incremental continue (reset_cache=false) is unsafe because the KV + // cache includes generated tokens not tracked by continuation_len. + LOG_TRACE("RCLI", "[process_command] full continue " + "(continuation=%zu chars)", full_continuation.size()); + raw_output = mrt.generate_raw_continue(full_continuation, nullptr, true); } else { LOG_TRACE("RCLI", "[process_command] calling mrt.generate_raw() ..."); raw_output = mrt.generate_raw(full_prompt); @@ -1961,19 +1954,14 @@ const char* rcli_process_and_speak(RCLIHandle handle, const char* text, full_continuation.size(), engine->metalrt_kv_continuation_len); - if (engine->metalrt_kv_continuation_len > 0 && - engine->metalrt_kv_continuation_len < full_continuation.size()) { - std::string new_part = full_continuation.substr(engine->metalrt_kv_continuation_len); - LOG_DEBUG("RCLI", "[speak] incremental continue " - "(new=%zu chars, skip=%zu already in KV)", - new_part.size(), engine->metalrt_kv_continuation_len); - response = mrt.generate_raw_continue(new_part, streaming_cb, false); - } else { - LOG_DEBUG("RCLI", "[speak] full continue " - "(continuation=%zu chars)", full_continuation.size()); - response = mrt.generate_raw_continue(full_continuation, streaming_cb, true); - } - engine->metalrt_kv_continuation_len = full_continuation.size(); + // Always truncate to cached system prompt and re-prefill the full + // continuation. The incremental path (reset_cache=false) is unsafe + // because the KV cache also contains generated-response tokens that + // metalrt_kv_continuation_len does not account for, which causes + // duplicate content in the KV and corrupts multi-turn attention. + LOG_DEBUG("RCLI", "[speak] full continue " + "(continuation=%zu chars)", full_continuation.size()); + response = mrt.generate_raw_continue(full_continuation, streaming_cb, true); } else { LOG_DEBUG("RCLI", "[speak] cache MISS path — calling generate_raw() " "(has_cache=%d prefix_match=%d)", From 57eb86049d730181fda13cb21818c04a7f03a2fe Mon Sep 17 00:00:00 2001 From: AmanSwar Date: Sun, 15 Mar 2026 04:30:44 +0530 Subject: [PATCH 13/16] Add GPU model swap for VLM visual mode and streaming VLM analysis Visual mode (S key) now swaps MetalRT LLM out and VLM in on the GPU, avoiding dual-model GPU corruption. Voice commands in visual mode route through VLM screen capture + streaming analysis with TTS. Exiting visual mode unloads VLM and restores the LLM with re-cached system prompt. New C API: rcli_vlm_enter(), rcli_vlm_exit(), rcli_vlm_analyze_stream() --- src/api/rcli_api.cpp | 164 ++++++++++++++++++++++++++++++++++++ src/api/rcli_api.h | 17 ++++ src/cli/tui_app.h | 58 ++++++++++--- src/pipeline/orchestrator.h | 3 + 4 files changed, 229 insertions(+), 13 deletions(-) diff --git a/src/api/rcli_api.cpp b/src/api/rcli_api.cpp index 11382c7..cf32916 100644 --- a/src/api/rcli_api.cpp +++ b/src/api/rcli_api.cpp @@ -3253,6 +3253,170 @@ int rcli_vlm_get_stats(RCLIHandle handle, RCLIVlmStats* out_stats) { return 0; } +// ============================================================================= +// VLM GPU swap: enter/exit visual mode by swapping LLM ↔ VLM on GPU +// ============================================================================= + +int rcli_vlm_enter(RCLIHandle handle) { + if (!handle) return -1; + auto* engine = static_cast(handle); + std::lock_guard lock(engine->mutex); + + // Already in VLM mode? + if (engine->using_metalrt_vlm && engine->metalrt_vision_handle) return 0; + + auto& loader = rastack::MetalRTLoader::instance(); + if (!loader.is_loaded() || !loader.has_vision()) return -1; + + // Check VLM model is installed + std::string vlm_dir = rcli::metalrt_models_dir() + "/Qwen3-VL-2B-MLX-4bit"; + if (access((vlm_dir + "/model.safetensors").c_str(), R_OK) != 0) { + LOG_ERROR("VLM", "MetalRT VLM model not installed at %s", vlm_dir.c_str()); + return -1; + } + + // Unload MetalRT LLM to free GPU + if (engine->pipeline.using_metalrt()) { + LOG_INFO("VLM", "Unloading MetalRT LLM to make room for VLM..."); + engine->pipeline.metalrt_llm().shutdown(); + } + + // Load MetalRT VLM + void* vhandle = loader.vision_create(); + if (!vhandle) { + LOG_ERROR("VLM", "vision_create() failed"); + // Try to restore LLM + engine->pipeline.metalrt_llm().init(engine->pipeline.config().metalrt); + return -1; + } + + { + std::lock_guard gpu_lock(loader.gpu_mutex()); + if (!loader.vision_load(vhandle, vlm_dir.c_str())) { + LOG_ERROR("VLM", "vision_load() failed"); + loader.vision_destroy(vhandle); + engine->pipeline.metalrt_llm().init(engine->pipeline.config().metalrt); + return -1; + } + } + + engine->metalrt_vision_handle = vhandle; + engine->using_metalrt_vlm = true; + engine->vlm_initialized = true; + LOG_INFO("VLM", "MetalRT VLM loaded (LLM swapped out)"); + return 0; +} + +int rcli_vlm_exit(RCLIHandle handle) { + if (!handle) return -1; + auto* engine = static_cast(handle); + std::lock_guard lock(engine->mutex); + + // Unload VLM + if (engine->metalrt_vision_handle) { + auto& loader = rastack::MetalRTLoader::instance(); + if (loader.vision_destroy) + loader.vision_destroy(engine->metalrt_vision_handle); + engine->metalrt_vision_handle = nullptr; + } + engine->using_metalrt_vlm = false; + engine->vlm_initialized = false; + LOG_INFO("VLM", "MetalRT VLM unloaded"); + + // Reload MetalRT LLM + if (engine->pipeline.using_metalrt()) { + auto& mrt = engine->pipeline.metalrt_llm(); + const auto& cfg = engine->pipeline.config(); + if (mrt.init(cfg.metalrt)) { + // Re-cache system prompt + std::string sys = mrt.profile().build_tool_system_prompt( + cfg.system_prompt, engine->pipeline.tools().get_tool_definitions_json()); + std::string prefix = mrt.profile().build_system_prefix(sys); + mrt.cache_system_prompt(prefix); + mrt.set_system_prompt(sys); + engine->metalrt_kv_continuation_len = 0; + LOG_INFO("VLM", "MetalRT LLM restored"); + } else { + LOG_ERROR("VLM", "Failed to restore MetalRT LLM!"); + return -1; + } + } + return 0; +} + +int rcli_vlm_analyze_stream(RCLIHandle handle, const char* image_path, + const char* prompt, + RCLIEventCallback callback, void* user_data) { + if (!handle || !image_path) return -1; + auto* engine = static_cast(handle); + std::lock_guard lock(engine->mutex); + + if (!engine->using_metalrt_vlm || !engine->metalrt_vision_handle) { + LOG_ERROR("VLM", "VLM not loaded. Call rcli_vlm_enter() first."); + return -1; + } + + std::string text_prompt = (prompt && prompt[0]) + ? std::string(prompt) : "Describe this image in detail."; + + auto& loader = rastack::MetalRTLoader::instance(); + std::string accumulated; + + struct StreamCtx { + RCLIEventCallback cb; + void* ud; + std::string* accum; + }; + StreamCtx sctx{callback, user_data, &accumulated}; + + rastack::MetalRTStreamCb stream_cb = [](const char* piece, void* ud) -> bool { + auto* ctx = static_cast(ud); + // Skip special tokens + if (std::strstr(piece, "<|im_end|>") || std::strstr(piece, "<|im_start|>")) + return true; + ctx->accum->append(piece); + if (ctx->cb) ctx->cb("token", piece, ctx->ud); + return true; + }; + + rastack::MetalRTLoader::MetalRTVisionOptions opts{}; + opts.max_tokens = 512; + opts.top_k = 40; + opts.temperature = 0.0f; + opts.think = false; + + rastack::MetalRTLoader::MetalRTVisionResult vr; + { + std::lock_guard gpu_lock(loader.gpu_mutex()); + vr = loader.vision_analyze_stream(engine->metalrt_vision_handle, + image_path, text_prompt.c_str(), + stream_cb, &sctx, &opts); + } + + // Store stats + engine->metalrt_vlm_stats.vision_encode_ms = vr.vision_encode_ms; + engine->metalrt_vlm_stats.prefill_ms = vr.prefill_ms; + engine->metalrt_vlm_stats.decode_ms = vr.decode_ms; + engine->metalrt_vlm_stats.tps = vr.tps; + engine->metalrt_vlm_stats.prompt_tokens = vr.prompt_tokens; + engine->metalrt_vlm_stats.generated_tokens = vr.generated_tokens; + + std::string result = vr.response ? std::string(vr.response) : accumulated; + if (loader.vision_free_result) loader.vision_free_result(vr); + engine->last_vlm_response = result.empty() ? "Error: Failed to analyze image." : result; + + if (callback) { + callback("response", engine->last_vlm_response.c_str(), user_data); + char stats_buf[256]; + snprintf(stats_buf, sizeof(stats_buf), + "{\"tps\":%.1f,\"tokens\":%d,\"vision_encode_ms\":%.1f}", + vr.tps, vr.generated_tokens, vr.vision_encode_ms); + callback("stats", stats_buf, user_data); + } + + return engine->last_vlm_response.find("Error:") == 0 ? -1 : 0; +} + } // extern "C" std::vector rcli_get_all_action_defs(RCLIHandle handle) { diff --git a/src/api/rcli_api.h b/src/api/rcli_api.h index e058945..a38b308 100644 --- a/src/api/rcli_api.h +++ b/src/api/rcli_api.h @@ -291,6 +291,23 @@ typedef struct { // Get stats from the last VLM analysis. Returns 0 on success. int rcli_vlm_get_stats(RCLIHandle handle, RCLIVlmStats* out_stats); +// Swap MetalRT LLM out and VLM in on the GPU (for visual mode). +// Unloads the LLM model, loads the MetalRT VLM model. +// Returns 0 on success, -1 on failure. +int rcli_vlm_enter(RCLIHandle handle); + +// Swap MetalRT VLM out and LLM back in on the GPU (exit visual mode). +// Unloads the VLM model, reloads the LLM and re-caches the system prompt. +// Returns 0 on success, -1 on failure. +int rcli_vlm_exit(RCLIHandle handle); + +// Streaming VLM image analysis (use after rcli_vlm_enter). +// Fires callback with events: "token", "response", "stats". +// Returns 0 on success, -1 on failure. +int rcli_vlm_analyze_stream(RCLIHandle handle, const char* image_path, + const char* prompt, + RCLIEventCallback callback, void* user_data); + #ifdef __cplusplus } #endif diff --git a/src/cli/tui_app.h b/src/cli/tui_app.h index 4671531..67debf4 100644 --- a/src/cli/tui_app.h +++ b/src/cli/tui_app.h @@ -444,14 +444,31 @@ class TuiApp { run_camera_vlm("Describe what you see in this photo in detail."); return true; } - // S key: toggle visual mode (overlay frame for screen capture) + // S key: toggle visual mode (swap LLM ↔ VLM on GPU) if (c == "s" || c == "S") { if (screen_capture_overlay_active()) { + // Exit visual mode: hide overlay, swap VLM → LLM screen_capture_hide_overlay(); - add_system_message("Visual mode OFF"); + add_system_message("Exiting visual mode, restoring LLM..."); + screen_->Post(Event::Custom); + std::thread([this]() { + rcli_vlm_exit(engine_); + add_system_message("Visual mode OFF — LLM restored"); + screen_->Post(Event::Custom); + }).detach(); } else { - screen_capture_show_overlay(0, 0, 0, 0); - add_system_message("Visual mode ON — drag/resize the green frame over content, then ask a question"); + // Enter visual mode: swap LLM → VLM, show overlay + add_system_message("Entering visual mode, loading VLM..."); + screen_->Post(Event::Custom); + std::thread([this]() { + if (rcli_vlm_enter(engine_) == 0) { + screen_capture_show_overlay(0, 0, 0, 0); + add_system_message("Visual mode ON — drag/resize the green frame, then ask a question"); + } else { + add_system_message("Failed to load VLM model"); + } + screen_->Post(Event::Custom); + }).detach(); } return true; } @@ -560,6 +577,11 @@ class TuiApp { std::string user_text = transcript; add_user_message(user_text); + // Visual mode: route voice to VLM screen analysis instead of LLM + if (screen_capture_overlay_active()) { + run_screen_vlm(user_text); + return; + } voice_state_ = VoiceState::THINKING; screen_->Post(Event::Custom); @@ -2265,21 +2287,31 @@ class TuiApp { std::to_string(std::chrono::system_clock::now().time_since_epoch().count()) + ".jpg"; int rc = screen_capture_screenshot(screen_path.c_str()); if (rc != 0) { - add_response("(Screen capture failed. Check screen recording permissions in System Settings > Privacy & Security > Screen Recording.)", ""); + add_response("(Screen capture failed. Check screen recording permissions.)", ""); voice_state_ = VoiceState::IDLE; screen_->Post(Event::Custom); return; } - add_system_message("Screenshot captured! Analyzing with VLM..."); + add_system_message("Analyzing with VLM..."); screen_->Post(Event::Custom); - const char* response = rcli_vlm_analyze( - engine_, screen_path.c_str(), prompt_copy.c_str()); - if (response && response[0]) { - add_response(response, "VLM"); - // Speak via sentence-streamed TTS through ring buffer + + // Streaming VLM analysis — accumulate response for display + TTS + std::string accumulated; + auto stream_cb = [](const char* event, const char* data, void* ud) { + auto* accum = static_cast(ud); + if (std::strcmp(event, "token") == 0) { + accum->append(data); + } + }; + int vlm_rc = rcli_vlm_analyze_stream(engine_, screen_path.c_str(), + prompt_copy.c_str(), stream_cb, &accumulated); + + if (vlm_rc == 0 && !accumulated.empty()) { + add_response(accumulated, "VLM"); + // Speak via sentence-streamed TTS voice_state_ = VoiceState::SPEAKING; screen_->Post(Event::Custom); - rcli_speak_streaming(engine_, response, nullptr, nullptr); + rcli_speak_streaming(engine_, accumulated.c_str(), nullptr, nullptr); RCLIVlmStats stats; if (rcli_vlm_get_stats(engine_, &stats) == 0) { char buf[128]; @@ -2288,7 +2320,7 @@ class TuiApp { add_system_message(buf); } } else { - add_response("(VLM analysis failed. Install a VLM model: rcli models vlm)", ""); + add_response("(VLM analysis failed)", ""); } voice_state_ = VoiceState::IDLE; screen_->Post(Event::Custom); diff --git a/src/pipeline/orchestrator.h b/src/pipeline/orchestrator.h index 6122815..51a5527 100644 --- a/src/pipeline/orchestrator.h +++ b/src/pipeline/orchestrator.h @@ -101,6 +101,9 @@ class Orchestrator { LlmBackend active_llm_backend() const { return active_backend_; } bool using_metalrt() const { return active_backend_ == LlmBackend::METALRT; } + // Access the pipeline config (e.g. for MetalRT model dir during VLM swap) + const PipelineConfig& config() const { return config_; } + // Update the base system prompt (e.g. when personality changes) void set_system_prompt(const std::string& prompt) { config_.system_prompt = prompt; } From 14908e3b98934c60bd82321bfbed1f3b1e2d9c88 Mon Sep 17 00:00:00 2001 From: AmanSwar Date: Sun, 15 Mar 2026 04:31:08 +0530 Subject: [PATCH 14/16] Improve visual mode overlay: thicker border, corner handles, label pill 6px solid rounded border with outer glow, 18px corner grab handles with white center dots, centered green label pill, 120x80 minimum size. --- src/audio/rcli_overlay.m | 81 +++++++++++++++++++++++++++++----------- 1 file changed, 59 insertions(+), 22 deletions(-) diff --git a/src/audio/rcli_overlay.m b/src/audio/rcli_overlay.m index 1a37481..274a3fc 100644 --- a/src/audio/rcli_overlay.m +++ b/src/audio/rcli_overlay.m @@ -1,42 +1,83 @@ -// rcli_overlay — tiny standalone Cocoa app that shows a draggable/resizable -// green-bordered transparent overlay. Communicates with the parent RCLI -// process via stdin (commands) and stdout (responses). +// rcli_overlay — standalone Cocoa app showing a draggable/resizable overlay +// frame for screen capture. Communicates with parent RCLI via stdin/stdout. // // Commands (one per line on stdin): // frame → replies "x,y,w,h\n" (screen coords, top-left origin) // hide → sets alpha to 0 (for capture) // show → restores alpha to 1 // quit → exits -// -// Compile: clang -framework AppKit -framework CoreGraphics -o rcli_overlay rcli_overlay.m #import -// ── Custom view: green dashed border + label ────────────────────────── +static const CGFloat kBorder = 6.0; +static const CGFloat kRadius = 12.0; +static const CGFloat kHandle = 18.0; // corner handle size +static const CGFloat kEdgeGrab = 14.0; // invisible edge grab zone + +// ── Custom view: bold border + corner handles + label pill ───────────── @interface OverlayView : NSView @end @implementation OverlayView + - (void)drawRect:(NSRect)dirtyRect { [[NSColor clearColor] set]; NSRectFill(dirtyRect); - NSBezierPath *border = [NSBezierPath bezierPathWithRect: - NSInsetRect(self.bounds, 3, 3)]; - [border setLineWidth:4.0]; - CGFloat dash[] = {10, 5}; - [border setLineDash:dash count:2 phase:0]; - [[NSColor colorWithRed:0.0 green:1.0 blue:0.4 alpha:0.9] set]; + NSRect inner = NSInsetRect(self.bounds, kBorder, kBorder); + NSColor *green = [NSColor colorWithRed:0.15 green:0.9 blue:0.45 alpha:0.92]; + + // Outer glow + NSBezierPath *glow = [NSBezierPath bezierPathWithRoundedRect:inner + xRadius:kRadius yRadius:kRadius]; + [glow setLineWidth:kBorder + 6]; + [[green colorWithAlphaComponent:0.12] set]; + [glow stroke]; + + // Main border — solid, thick, rounded + NSBezierPath *border = [NSBezierPath bezierPathWithRoundedRect:inner + xRadius:kRadius yRadius:kRadius]; + [border setLineWidth:kBorder]; + [green set]; [border stroke]; + // Corner handles — filled rounded squares with white dot + CGFloat hs = kHandle; + CGFloat off = kBorder / 2; + NSRect corners[4] = { + NSMakeRect(NSMinX(inner) - off, NSMinY(inner) - off, hs, hs), + NSMakeRect(NSMaxX(inner) + off - hs, NSMinY(inner) - off, hs, hs), + NSMakeRect(NSMinX(inner) - off, NSMaxY(inner) + off - hs, hs, hs), + NSMakeRect(NSMaxX(inner) + off - hs, NSMaxY(inner) + off - hs, hs, hs), + }; + for (int i = 0; i < 4; i++) { + NSBezierPath *h = [NSBezierPath bezierPathWithRoundedRect:corners[i] + xRadius:4 yRadius:4]; + [green set]; + [h fill]; + // White center dot + NSRect dot = NSInsetRect(corners[i], 5, 5); + [[NSColor colorWithWhite:1.0 alpha:0.85] set]; + [[NSBezierPath bezierPathWithOvalInRect:dot] fill]; + } + + // Label pill — centered at top + NSString *label = @" RCLI Visual Mode "; NSDictionary *attrs = @{ - NSFontAttributeName: [NSFont boldSystemFontOfSize:12], - NSForegroundColorAttributeName: - [NSColor colorWithRed:0.0 green:1.0 blue:0.4 alpha:0.9], + NSFontAttributeName: [NSFont systemFontOfSize:11 weight:NSFontWeightBold], + NSForegroundColorAttributeName: [NSColor blackColor], }; - [@" RCLI Visual Mode " drawAtPoint:NSMakePoint(10, self.bounds.size.height - 22) - withAttributes:attrs]; + NSSize sz = [label sizeWithAttributes:attrs]; + CGFloat px = NSMidX(self.bounds) - sz.width / 2 - 6; + CGFloat py = NSMaxY(inner) - 2; + NSRect pill = NSMakeRect(px, py, sz.width + 12, sz.height + 6); + NSBezierPath *pillPath = [NSBezierPath bezierPathWithRoundedRect:pill + xRadius:10 yRadius:10]; + [green set]; + [pillPath fill]; + [label drawAtPoint:NSMakePoint(px + 6, py + 3) withAttributes:attrs]; } + - (BOOL)acceptsFirstMouse:(NSEvent *)e { return YES; } @end @@ -60,6 +101,7 @@ - (instancetype)initWithRect:(NSRect)rect { self.contentView = [[OverlayView alloc] initWithFrame:rect]; self.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces | NSWindowCollectionBehaviorStationary; + self.minSize = NSMakeSize(120, 80); } return self; } @@ -87,7 +129,6 @@ - (void)startReading { withObject:cmd waitUntilDone:YES]; } - // stdin closed — parent died, exit gracefully dispatch_async(dispatch_get_main_queue(), ^{ [NSApp terminate:nil]; }); @@ -97,7 +138,6 @@ - (void)startReading { - (void)handleCommand:(NSString *)cmd { if ([cmd isEqualToString:@"frame"]) { NSRect f = self.window.frame; - // Convert to top-left origin (Cocoa uses bottom-left) CGFloat screenH = [NSScreen mainScreen].frame.size.height; int x = (int)f.origin.x; int y = (int)(screenH - f.origin.y - f.size.height); @@ -107,7 +147,6 @@ - (void)handleCommand:(NSString *)cmd { fflush(stdout); } else if ([cmd isEqualToString:@"hide"]) { [self.window setAlphaValue:0.0]; - // Small delay for window server [NSThread sleepForTimeInterval:0.05]; printf("ok\n"); fflush(stdout); @@ -128,7 +167,6 @@ int main(int argc, const char *argv[]) { NSApplication *app = [NSApplication sharedApplication]; [app setActivationPolicy:NSApplicationActivationPolicyAccessory]; - // Default: 800×600 centered NSScreen *scr = [NSScreen mainScreen]; NSRect sf = scr.frame; CGFloat w = 800, h = 600; @@ -144,7 +182,6 @@ int main(int argc, const char *argv[]) { reader.window = win; [reader startReading]; - // Signal parent that we're ready printf("ready\n"); fflush(stdout); From 8a3e51437278234fd5fe78d6035cfef17a2ce8a2 Mon Sep 17 00:00:00 2001 From: Shubham Malhotra Date: Sat, 14 Mar 2026 21:04:09 -0700 Subject: [PATCH 15/16] fixed vlm to be using fallback for llamacpp / llamacpp vlm working - metalRT to do --- src/api/rcli_api.cpp | 163 +++++++++++++++++++++++++++------------- src/api/rcli_api.h | 8 ++ src/cli/main.cpp | 28 ++++++- src/cli/model_pickers.h | 9 ++- src/cli/tui_app.h | 46 +++++++++--- 5 files changed, 186 insertions(+), 68 deletions(-) diff --git a/src/api/rcli_api.cpp b/src/api/rcli_api.cpp index cf32916..5d4638d 100644 --- a/src/api/rcli_api.cpp +++ b/src/api/rcli_api.cpp @@ -122,6 +122,8 @@ struct RCLIEngine { bool using_metalrt_vlm = false; // true when VLM is running on MetalRT backend void* metalrt_vision_handle = nullptr; // opaque handle from metalrt_vision_create() std::string last_vlm_response; + std::string vlm_backend_name; // "llama.cpp (Metal GPU)" or "MetalRT" + std::string vlm_model_name; // e.g. "Qwen3 VL 2B" // MetalRT VLM stats (filled after each analyze call) struct { @@ -3084,6 +3086,8 @@ static int vlm_init_locked(RCLIEngine* engine) { engine->vlm_initialized = true; const char* mname = mrt_loader.vision_model_name ? mrt_loader.vision_model_name(handle) : "Qwen3-VL-2B"; + engine->vlm_backend_name = "MetalRT"; + engine->vlm_model_name = mname; LOG_INFO("VLM", "MetalRT VLM engine ready (%s)", mname); return 0; } @@ -3094,6 +3098,11 @@ static int vlm_init_locked(RCLIEngine* engine) { } // --- Fallback: llama.cpp VLM backend --- + // MetalRT dylib either not loaded, or doesn't export vision symbols yet. + // Use llama.cpp with GGUF models — still runs on Metal GPU via ggml-metal. + if (mrt_loader.is_loaded() && !mrt_loader.has_vision()) { + LOG_INFO("VLM", "MetalRT engine active but VLM not yet supported in dylib — using llama.cpp for vision"); + } // Find or download VLM model auto vlm_models = rcli::all_vlm_models(); @@ -3142,7 +3151,10 @@ static int vlm_init_locked(RCLIEngine* engine) { } engine->vlm_initialized = true; - LOG_INFO("VLM", "VLM engine ready (%s)", model_def.name.c_str()); + engine->using_metalrt_vlm = false; + engine->vlm_backend_name = "llama.cpp (Metal GPU)"; + engine->vlm_model_name = model_def.name; + LOG_INFO("VLM", "VLM engine ready — %s via llama.cpp (Metal GPU)", model_def.name.c_str()); return 0; } @@ -3230,6 +3242,18 @@ int rcli_vlm_is_ready(RCLIHandle handle) { return engine->vlm_initialized ? 1 : 0; } +const char* rcli_vlm_backend_name(RCLIHandle handle) { + if (!handle) return ""; + auto* engine = static_cast(handle); + return engine->vlm_backend_name.c_str(); +} + +const char* rcli_vlm_model_name(RCLIHandle handle) { + if (!handle) return ""; + auto* engine = static_cast(handle); + return engine->vlm_model_name.c_str(); +} + int rcli_vlm_get_stats(RCLIHandle handle, RCLIVlmStats* out_stats) { if (!handle || !out_stats) return -1; auto* engine = static_cast(handle); @@ -3312,16 +3336,24 @@ int rcli_vlm_exit(RCLIHandle handle) { auto* engine = static_cast(handle); std::lock_guard lock(engine->mutex); - // Unload VLM + // Unload MetalRT VLM if active if (engine->metalrt_vision_handle) { auto& loader = rastack::MetalRTLoader::instance(); if (loader.vision_destroy) loader.vision_destroy(engine->metalrt_vision_handle); engine->metalrt_vision_handle = nullptr; } + + // Shutdown llama.cpp VLM if it was the active backend + if (!engine->using_metalrt_vlm && engine->vlm_engine.is_initialized()) { + engine->vlm_engine.shutdown(); + } + engine->using_metalrt_vlm = false; engine->vlm_initialized = false; - LOG_INFO("VLM", "MetalRT VLM unloaded"); + engine->vlm_backend_name.clear(); + engine->vlm_model_name.clear(); + LOG_INFO("VLM", "VLM unloaded"); // Reload MetalRT LLM if (engine->pipeline.using_metalrt()) { @@ -3351,67 +3383,96 @@ int rcli_vlm_analyze_stream(RCLIHandle handle, const char* image_path, auto* engine = static_cast(handle); std::lock_guard lock(engine->mutex); - if (!engine->using_metalrt_vlm || !engine->metalrt_vision_handle) { - LOG_ERROR("VLM", "VLM not loaded. Call rcli_vlm_enter() first."); - return -1; + // Lazy-init VLM if not yet loaded + if (!engine->vlm_initialized) { + if (vlm_init_locked(engine) != 0) { + LOG_ERROR("VLM", "Failed to initialize VLM engine for streaming"); + return -1; + } } std::string text_prompt = (prompt && prompt[0]) ? std::string(prompt) : "Describe this image in detail."; - auto& loader = rastack::MetalRTLoader::instance(); - std::string accumulated; + if (engine->using_metalrt_vlm && engine->metalrt_vision_handle) { + // --- MetalRT VLM streaming path --- + auto& loader = rastack::MetalRTLoader::instance(); + std::string accumulated; - struct StreamCtx { - RCLIEventCallback cb; - void* ud; - std::string* accum; - }; - StreamCtx sctx{callback, user_data, &accumulated}; + struct StreamCtx { + RCLIEventCallback cb; + void* ud; + std::string* accum; + }; + StreamCtx sctx{callback, user_data, &accumulated}; - rastack::MetalRTStreamCb stream_cb = [](const char* piece, void* ud) -> bool { - auto* ctx = static_cast(ud); - // Skip special tokens - if (std::strstr(piece, "<|im_end|>") || std::strstr(piece, "<|im_start|>")) + rastack::MetalRTStreamCb stream_cb = [](const char* piece, void* ud) -> bool { + auto* ctx = static_cast(ud); + if (std::strstr(piece, "<|im_end|>") || std::strstr(piece, "<|im_start|>")) + return true; + ctx->accum->append(piece); + if (ctx->cb) ctx->cb("token", piece, ctx->ud); return true; - ctx->accum->append(piece); - if (ctx->cb) ctx->cb("token", piece, ctx->ud); - return true; - }; + }; - rastack::MetalRTLoader::MetalRTVisionOptions opts{}; - opts.max_tokens = 512; - opts.top_k = 40; - opts.temperature = 0.0f; - opts.think = false; + rastack::MetalRTLoader::MetalRTVisionOptions opts{}; + opts.max_tokens = 512; + opts.top_k = 40; + opts.temperature = 0.0f; + opts.think = false; - rastack::MetalRTLoader::MetalRTVisionResult vr; - { - std::lock_guard gpu_lock(loader.gpu_mutex()); - vr = loader.vision_analyze_stream(engine->metalrt_vision_handle, - image_path, text_prompt.c_str(), - stream_cb, &sctx, &opts); - } + rastack::MetalRTLoader::MetalRTVisionResult vr; + { + std::lock_guard gpu_lock(loader.gpu_mutex()); + vr = loader.vision_analyze_stream(engine->metalrt_vision_handle, + image_path, text_prompt.c_str(), + stream_cb, &sctx, &opts); + } - // Store stats - engine->metalrt_vlm_stats.vision_encode_ms = vr.vision_encode_ms; - engine->metalrt_vlm_stats.prefill_ms = vr.prefill_ms; - engine->metalrt_vlm_stats.decode_ms = vr.decode_ms; - engine->metalrt_vlm_stats.tps = vr.tps; - engine->metalrt_vlm_stats.prompt_tokens = vr.prompt_tokens; - engine->metalrt_vlm_stats.generated_tokens = vr.generated_tokens; + engine->metalrt_vlm_stats.vision_encode_ms = vr.vision_encode_ms; + engine->metalrt_vlm_stats.prefill_ms = vr.prefill_ms; + engine->metalrt_vlm_stats.decode_ms = vr.decode_ms; + engine->metalrt_vlm_stats.tps = vr.tps; + engine->metalrt_vlm_stats.prompt_tokens = vr.prompt_tokens; + engine->metalrt_vlm_stats.generated_tokens = vr.generated_tokens; - std::string result = vr.response ? std::string(vr.response) : accumulated; - if (loader.vision_free_result) loader.vision_free_result(vr); - engine->last_vlm_response = result.empty() ? "Error: Failed to analyze image." : result; + std::string result = vr.response ? std::string(vr.response) : accumulated; + if (loader.vision_free_result) loader.vision_free_result(vr); + engine->last_vlm_response = result.empty() ? "Error: Failed to analyze image." : result; - if (callback) { - callback("response", engine->last_vlm_response.c_str(), user_data); - char stats_buf[256]; - snprintf(stats_buf, sizeof(stats_buf), - "{\"tps\":%.1f,\"tokens\":%d,\"vision_encode_ms\":%.1f}", - vr.tps, vr.generated_tokens, vr.vision_encode_ms); - callback("stats", stats_buf, user_data); + if (callback) { + callback("response", engine->last_vlm_response.c_str(), user_data); + char stats_buf[256]; + snprintf(stats_buf, sizeof(stats_buf), + "{\"tps\":%.1f,\"tokens\":%d,\"vision_encode_ms\":%.1f}", + vr.tps, vr.generated_tokens, vr.vision_encode_ms); + callback("stats", stats_buf, user_data); + } + } else { + // --- llama.cpp VLM streaming path --- + rastack::TokenCallback token_cb = nullptr; + if (callback) { + token_cb = [callback, user_data](const rastack::TokenOutput& tok) { + if (!tok.text.empty()) { + callback("token", tok.text.c_str(), user_data); + } + }; + } + + std::string result = engine->vlm_engine.analyze_image( + std::string(image_path), text_prompt, token_cb); + + engine->last_vlm_response = result.empty() ? "Error: Failed to analyze image." : result; + + if (callback) { + callback("response", engine->last_vlm_response.c_str(), user_data); + auto& s = engine->vlm_engine.last_stats(); + char stats_buf[256]; + snprintf(stats_buf, sizeof(stats_buf), + "{\"tps\":%.1f,\"tokens\":%lld,\"vision_encode_ms\":%.1f}", + s.gen_tps(), s.generated_tokens, s.image_encode_us / 1000.0); + callback("stats", stats_buf, user_data); + } } return engine->last_vlm_response.find("Error:") == 0 ? -1 : 0; diff --git a/src/api/rcli_api.h b/src/api/rcli_api.h index a38b308..e6906d1 100644 --- a/src/api/rcli_api.h +++ b/src/api/rcli_api.h @@ -279,6 +279,14 @@ const char* rcli_vlm_analyze(RCLIHandle handle, const char* image_path, const ch // Returns 1 if ready, 0 if not. int rcli_vlm_is_ready(RCLIHandle handle); +// Get the name of the active VLM backend (e.g. "llama.cpp (Metal GPU)" or "MetalRT"). +// Returns "" if VLM is not initialized. +const char* rcli_vlm_backend_name(RCLIHandle handle); + +// Get the name of the active VLM model (e.g. "Qwen3 VL 2B Instruct"). +// Returns "" if VLM is not initialized. +const char* rcli_vlm_model_name(RCLIHandle handle); + // VLM performance stats from the last analysis call. typedef struct { double gen_tok_per_sec; // Generation tokens/second diff --git a/src/cli/main.cpp b/src/cli/main.cpp index 93279af..f20afe2 100644 --- a/src/cli/main.cpp +++ b/src/cli/main.cpp @@ -491,12 +491,20 @@ static int cmd_vlm(const Args& args) { return 1; } + // Show which VLM backend is active + const char* backend = rcli_vlm_backend_name(g_engine); + const char* model = rcli_vlm_model_name(g_engine); + if (backend && backend[0]) { + fprintf(stderr, "%s VLM: %s%s%s via %s%s%s%s\n", + color::dim, color::reset, color::bold, model, + color::reset, color::dim, backend, color::reset); + } + fprintf(stderr, "%sAnalyzing image: %s%s\n", color::dim, image_path.c_str(), color::reset); const char* response = rcli_vlm_analyze(g_engine, image_path.c_str(), prompt.c_str()); if (response && response[0]) { fprintf(stdout, "%s\n", response); - // Print performance stats RCLIVlmStats stats; if (rcli_vlm_get_stats(g_engine, &stats) == 0) { fprintf(stderr, "\n%s⚡ %.1f tok/s (%d tokens, %.1fs total, first token %.0fms)%s\n", @@ -543,6 +551,14 @@ static int cmd_camera(const Args& args) { return 1; } + const char* backend = rcli_vlm_backend_name(g_engine); + const char* model = rcli_vlm_model_name(g_engine); + if (backend && backend[0]) { + fprintf(stderr, "%s VLM: %s%s%s via %s%s%s%s\n", + color::dim, color::reset, color::bold, model, + color::reset, color::dim, backend, color::reset); + } + const char* response = rcli_vlm_analyze(g_engine, photo_path.c_str(), prompt.c_str()); if (response && response[0]) { fprintf(stdout, "%s\n", response); @@ -550,14 +566,12 @@ static int cmd_camera(const Args& args) { rcli_init(g_engine, args.models_dir.c_str(), args.gpu_layers); rcli_speak(g_engine, response); } - // Print performance stats RCLIVlmStats stats; if (rcli_vlm_get_stats(g_engine, &stats) == 0) { fprintf(stderr, "\n%s⚡ %.1f tok/s (%d tokens, %.1fs total, first token %.0fms)%s\n", color::dim, stats.gen_tok_per_sec, stats.generated_tokens, stats.total_time_sec, stats.first_token_ms, color::reset); } - // Open the captured photo in Preview so user can see what was captured { pid_t pid; const char* argv[] = {"open", photo_path.c_str(), nullptr}; @@ -605,6 +619,14 @@ static int cmd_screen(const Args& args) { return 1; } + const char* backend = rcli_vlm_backend_name(g_engine); + const char* model = rcli_vlm_model_name(g_engine); + if (backend && backend[0]) { + fprintf(stderr, "%s VLM: %s%s%s via %s%s%s%s\n", + color::dim, color::reset, color::bold, model, + color::reset, color::dim, backend, color::reset); + } + const char* response = rcli_vlm_analyze(g_engine, screen_path.c_str(), prompt.c_str()); if (response && response[0]) { fprintf(stdout, "%s\n", response); diff --git a/src/cli/model_pickers.h b/src/cli/model_pickers.h index a12e1b5..e0109d9 100644 --- a/src/cli/model_pickers.h +++ b/src/cli/model_pickers.h @@ -694,9 +694,12 @@ inline int cmd_info() { auto vlm_all_info = rcli::all_vlm_models(); auto [vlm_found, vlm_def] = rcli::find_installed_vlm(models_dir); - std::string vlm_info = vlm_found - ? (vlm_def.name + " (llama.cpp + mtmd)") - : "not installed — run: rcli models vlm"; + std::string vlm_info; + if (vlm_found) { + vlm_info = vlm_def.name + " (llama.cpp, Metal GPU)"; + } else { + vlm_info = "not installed — run: rcli models vlm"; + } fprintf(stdout, "\n%s%s RCLI%s %s%s%s\n\n" diff --git a/src/cli/tui_app.h b/src/cli/tui_app.h index 67debf4..b200482 100644 --- a/src/cli/tui_app.h +++ b/src/cli/tui_app.h @@ -447,7 +447,6 @@ class TuiApp { // S key: toggle visual mode (swap LLM ↔ VLM on GPU) if (c == "s" || c == "S") { if (screen_capture_overlay_active()) { - // Exit visual mode: hide overlay, swap VLM → LLM screen_capture_hide_overlay(); add_system_message("Exiting visual mode, restoring LLM..."); screen_->Post(Event::Custom); @@ -457,15 +456,27 @@ class TuiApp { screen_->Post(Event::Custom); }).detach(); } else { - // Enter visual mode: swap LLM → VLM, show overlay add_system_message("Entering visual mode, loading VLM..."); screen_->Post(Event::Custom); std::thread([this]() { + // Try MetalRT VLM first; if unavailable, lazily init llama.cpp VLM + bool ready = false; if (rcli_vlm_enter(engine_) == 0) { + ready = true; + } else if (rcli_vlm_init(engine_) == 0) { + ready = true; + } + if (ready) { + const char* vbe = rcli_vlm_backend_name(engine_); + const char* vmodel = rcli_vlm_model_name(engine_); screen_capture_show_overlay(0, 0, 0, 0); - add_system_message("Visual mode ON — drag/resize the green frame, then ask a question"); + std::string msg = "Visual mode ON"; + if (vbe && vbe[0]) + msg += std::string(" — ") + vmodel + " via " + vbe; + msg += ". Drag/resize the green frame, then ask a question"; + add_system_message(msg); } else { - add_system_message("Failed to load VLM model"); + add_system_message("Failed to load VLM model. Install one: rcli models vlm"); } screen_->Post(Event::Custom); }).detach(); @@ -2243,17 +2254,25 @@ class TuiApp { screen_->Post(Event::Custom); return; } - add_system_message("Photo captured! Analyzing with VLM..."); + add_system_message("Photo captured! Loading VLM..."); screen_->Post(Event::Custom); + const char* response = rcli_vlm_analyze( engine_, photo_path.c_str(), prompt_copy.c_str()); + + // Show which backend handled it + const char* vbe = rcli_vlm_backend_name(engine_); + const char* vmodel = rcli_vlm_model_name(engine_); + if (vbe && vbe[0]) { + add_system_message(std::string("VLM: ") + vmodel + " via " + vbe); + screen_->Post(Event::Custom); + } + if (response && response[0]) { add_response(response, "VLM"); - // Speak the VLM response voice_state_ = VoiceState::SPEAKING; screen_->Post(Event::Custom); rcli_speak(engine_, response); - // Show performance stats RCLIVlmStats stats; if (rcli_vlm_get_stats(engine_, &stats) == 0) { char buf[128]; @@ -2265,7 +2284,6 @@ class TuiApp { add_response("(VLM analysis failed. Install a VLM model: rcli models vlm)", ""); } voice_state_ = VoiceState::IDLE; - // Open the captured photo in Preview { pid_t pid; const char* argv[] = {"open", photo_path.c_str(), nullptr}; @@ -2292,10 +2310,9 @@ class TuiApp { screen_->Post(Event::Custom); return; } - add_system_message("Analyzing with VLM..."); + add_system_message("Loading VLM..."); screen_->Post(Event::Custom); - // Streaming VLM analysis — accumulate response for display + TTS std::string accumulated; auto stream_cb = [](const char* event, const char* data, void* ud) { auto* accum = static_cast(ud); @@ -2306,9 +2323,16 @@ class TuiApp { int vlm_rc = rcli_vlm_analyze_stream(engine_, screen_path.c_str(), prompt_copy.c_str(), stream_cb, &accumulated); + // Show which backend handled it + const char* vbe = rcli_vlm_backend_name(engine_); + const char* vmodel = rcli_vlm_model_name(engine_); + if (vbe && vbe[0]) { + add_system_message(std::string("VLM: ") + vmodel + " via " + vbe); + screen_->Post(Event::Custom); + } + if (vlm_rc == 0 && !accumulated.empty()) { add_response(accumulated, "VLM"); - // Speak via sentence-streamed TTS voice_state_ = VoiceState::SPEAKING; screen_->Post(Event::Custom); rcli_speak_streaming(engine_, accumulated.c_str(), nullptr, nullptr); From 6a37c95d728f3565988b74007328527f978a8cd7 Mon Sep 17 00:00:00 2001 From: Shubham Malhotra Date: Sat, 14 Mar 2026 21:44:48 -0700 Subject: [PATCH 16/16] updates for lm --- README.md | 36 ++- src/api/rcli_api.cpp | 379 ++++---------------------------- src/cli/main.cpp | 18 +- src/cli/model_pickers.h | 2 +- src/cli/tui_app.h | 26 +-- src/models/model_registry.h | 17 -- src/models/vlm_model_registry.h | 14 +- 7 files changed, 108 insertions(+), 384 deletions(-) diff --git a/README.md b/README.md index dcefc11..972342a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ MIT

      -**RCLI** is an on-device voice AI for macOS. A complete STT + LLM + TTS pipeline running natively on Apple Silicon — 38 macOS actions via voice, local RAG over your documents, sub-200ms end-to-end latency. No cloud, no API keys. +**RCLI** is an on-device voice AI for macOS. A complete STT + LLM + TTS + VLM pipeline running natively on Apple Silicon — 40 macOS actions via voice, local RAG over your documents, on-device vision (camera & screen analysis), sub-200ms end-to-end latency. No cloud, no API keys. Powered by [MetalRT](#metalrt-gpu-engine), a proprietary GPU inference engine built by [RunAnywhere, Inc.](https://runanywhere.ai) specifically for Apple Silicon. @@ -112,6 +112,9 @@ rcli # interactive TUI (push-to-talk + text) rcli listen # continuous voice mode rcli ask "open Safari" # one-shot command rcli ask "play some jazz on Spotify" +rcli vlm photo.jpg "what's in this image?" # vision analysis +rcli camera # live camera VLM +rcli screen # screen capture VLM rcli metalrt # MetalRT GPU engine management rcli llamacpp # llama.cpp engine management ``` @@ -149,7 +152,18 @@ A full STT + LLM + TTS pipeline running on Metal GPU with three concurrent threa - **Tool Calling** — LLM-native tool call formats (Qwen3, LFM2, etc.) - **Multi-turn Memory** — Sliding window conversation history with token-budget trimming -### 38 macOS Actions +### Vision (VLM) + +Analyze images, camera captures, and screen regions using on-device vision-language models. VLM runs on the llama.cpp engine via Metal GPU — no cloud. + +- **Image Analysis** — `rcli vlm photo.jpg "describe this"` for single-image queries +- **Camera** — Press **V** in the TUI or run `rcli camera` for live camera analysis +- **Screen Capture** — Press **S** in the TUI or run `rcli screen` to analyze screen regions +- **Models** — Qwen3 VL 2B, Liquid LFM2 VL 1.6B, SmolVLM 500M — download on demand via `rcli models vlm` + +> **Note:** VLM is currently available on the llama.cpp engine. MetalRT VLM support is coming soon. + +### 40 macOS Actions Control your Mac by voice or text. The LLM routes intent to actions executed locally via AppleScript and shell commands. @@ -161,7 +175,7 @@ Control your Mac by voice or text. The LLM routes intent to actions executed loc | **System** | `open_app`, `quit_app`, `set_volume`, `toggle_dark_mode`, `screenshot`, `lock_screen` | | **Web** | `search_web`, `search_youtube`, `open_url`, `open_maps` | -Run `rcli actions` to see all 38, or toggle them on/off in the TUI Actions panel. +Run `rcli actions` to see all 40, or toggle them on/off in the TUI Actions panel. > **Tip:** If tool calling feels unreliable, press **X** in the TUI to clear the conversation and reset context. With small LLMs, accumulated context can degrade tool-calling accuracy — a fresh context often fixes it. @@ -181,7 +195,9 @@ A terminal dashboard with push-to-talk, live hardware monitoring, model manageme | Key | Action | |-----|--------| | **SPACE** | Push-to-talk | -| **M** | Models — browse, download, hot-swap LLM/STT/TTS | +| **V** | Camera — capture and analyze with VLM | +| **S** | Screen — capture and analyze a screen region with VLM | +| **M** | Models — browse, download, hot-swap LLM/STT/TTS/VLM | | **A** | Actions — browse, enable/disable macOS actions | | **R** | RAG — ingest documents | | **X** | Clear conversation and reset context | @@ -207,7 +223,7 @@ MetalRT is distributed under a [proprietary license](https://github.com/Runanywh ## Supported Models -RCLI supports 20+ models across LLM, STT, TTS, VAD, and embeddings. All run locally on Apple Silicon. Use `rcli models` to browse, download, or switch. +RCLI supports 20+ models across LLM, STT, TTS, VLM, VAD, and embeddings. All run locally on Apple Silicon. Use `rcli models` to browse, download, or switch. **LLM:** LFM2 1.2B (default), LFM2 350M, LFM2.5 1.2B, LFM2 2.6B, Qwen3 0.6B, Qwen3.5 0.8B/2B/4B, Qwen3 4B @@ -215,10 +231,13 @@ RCLI supports 20+ models across LLM, STT, TTS, VAD, and embeddings. All run loca **TTS:** Piper Lessac/Amy, KittenTTS Nano, Matcha LJSpeech, Kokoro English/Multi-lang -**Default install** (`rcli setup`): ~1GB — LFM2 1.2B + Whisper + Piper + Silero VAD + Snowflake embeddings. +**VLM:** Qwen3 VL 2B, Liquid LFM2 VL 1.6B, SmolVLM 500M — on-demand download via `rcli models vlm` (llama.cpp engine only) + +**Default install** (`rcli setup`): ~1GB — LFM2 1.2B + Whisper + Piper + Silero VAD + Snowflake embeddings. VLM models are downloaded on demand. ```bash rcli models # interactive model management +rcli models vlm # download/manage VLM models rcli upgrade-llm # guided LLM upgrade rcli voices # browse and switch TTS voices rcli cleanup # remove unused models @@ -247,10 +266,13 @@ All dependencies are vendored or CMake-fetched. Requires CMake 3.15+ and Apple C rcli Interactive TUI (push-to-talk + text + trace) rcli listen Continuous voice mode rcli ask One-shot text command +rcli vlm [prompt] Analyze an image with VLM +rcli camera [prompt] Live camera capture + VLM analysis +rcli screen [prompt] Screen capture + VLM analysis rcli actions [name] List actions or show detail rcli rag ingest Index documents for RAG rcli rag query Query indexed documents -rcli models [llm|stt|tts] Manage AI models +rcli models [llm|stt|tts|vlm] Manage AI models rcli voices Manage TTS voices rcli metalrt MetalRT GPU engine management rcli llamacpp llama.cpp engine management diff --git a/src/api/rcli_api.cpp b/src/api/rcli_api.cpp index 5d4638d..f292c78 100644 --- a/src/api/rcli_api.cpp +++ b/src/api/rcli_api.cpp @@ -119,22 +119,10 @@ struct RCLIEngine { // VLM (Vision Language Model) subsystem VlmEngine vlm_engine; bool vlm_initialized = false; - bool using_metalrt_vlm = false; // true when VLM is running on MetalRT backend - void* metalrt_vision_handle = nullptr; // opaque handle from metalrt_vision_create() std::string last_vlm_response; std::string vlm_backend_name; // "llama.cpp (Metal GPU)" or "MetalRT" std::string vlm_model_name; // e.g. "Qwen3 VL 2B" - // MetalRT VLM stats (filled after each analyze call) - struct { - double vision_encode_ms = 0; - double prefill_ms = 0; - double decode_ms = 0; - double tps = 0; - int prompt_tokens = 0; - int generated_tokens = 0; - } metalrt_vlm_stats; - std::mutex mutex; bool initialized = false; }; @@ -220,13 +208,6 @@ void rcli_destroy(RCLIHandle handle) { if (engine->initialized) { engine->pipeline.stop_live(); } - // Destroy MetalRT vision handle if loaded - if (engine->metalrt_vision_handle) { - auto& loader = rastack::MetalRTLoader::instance(); - if (loader.vision_destroy) - loader.vision_destroy(engine->metalrt_vision_handle); - engine->metalrt_vision_handle = nullptr; - } delete engine; } @@ -1083,8 +1064,9 @@ static std::string handle_screen_intent(RCLIEngine* engine, const std::string& u // Initialize VLM if needed if (!engine->vlm_initialized) { if (vlm_init_locked(engine) != 0) { - return "I can see you're asking about your screen, but the VLM model isn't available. " - "Install one with: rcli models vlm"; + return "I can see you're asking about your screen, but VLM isn't available. " + "It requires the llama.cpp engine and a VLM model. " + "Switch with: rcli engine llamacpp, then download a model: rcli models vlm"; } } @@ -1094,26 +1076,7 @@ static std::string handle_screen_intent(RCLIEngine* engine, const std::string& u vlm_prompt = "Describe what you see on this screen in detail."; } - std::string result; - if (engine->using_metalrt_vlm && engine->metalrt_vision_handle) { - auto& loader = rastack::MetalRTLoader::instance(); - rastack::MetalRTLoader::MetalRTVisionOptions opts{}; - opts.max_tokens = 512; - opts.top_k = 40; - opts.temperature = 0.0f; - opts.think = false; - - rastack::MetalRTLoader::MetalRTVisionResult vr; - { - std::lock_guard gpu_lock(loader.gpu_mutex()); - vr = loader.vision_analyze(engine->metalrt_vision_handle, - path.c_str(), vlm_prompt.c_str(), &opts); - } - result = vr.response ? std::string(vr.response) : (vr.text ? std::string(vr.text) : ""); - if (loader.vision_free_result) loader.vision_free_result(vr); - } else { - result = engine->vlm_engine.analyze_image(path, vlm_prompt, nullptr); - } + std::string result = engine->vlm_engine.analyze_image(path, vlm_prompt, nullptr); if (result.empty()) { return "I captured your screen but the analysis failed. Please try again."; @@ -3020,33 +2983,11 @@ static bool safe_download(const std::string& url, const std::string& dest) { return WIFEXITED(status) && WEXITSTATUS(status) == 0; } -static bool download_vlm_model(const std::string& url, const std::string& dest) { - // Check if already exists - if (access(dest.c_str(), R_OK) == 0) return true; - - LOG_INFO("VLM", "Downloading: %s", dest.c_str()); - - // Ensure parent directory exists - std::string dir = dest.substr(0, dest.rfind('/')); - if (!mkdirs(dir)) { - LOG_ERROR("VLM", "Failed to create directory: %s", dir.c_str()); - return false; - } - - // Download with curl (no shell interpolation) - if (!safe_download(url, dest)) { - LOG_ERROR("VLM", "Download failed"); - unlink(dest.c_str()); - return false; - } - return true; -} - // Internal init (caller must hold engine->mutex) +// VLM is only available on the llama.cpp engine. MetalRT VLM support coming soon. static int vlm_init_locked(RCLIEngine* engine) { if (engine->vlm_initialized) return 0; - // Fallback to default models dir if not set if (engine->models_dir.empty()) { if (const char* home = getenv("HOME")) engine->models_dir = std::string(home) + "/Library/RCLI/models"; @@ -3054,62 +2995,17 @@ static int vlm_init_locked(RCLIEngine* engine) { engine->models_dir = "./models"; } - // --- Try MetalRT vision backend first (if dylib loaded and VLM model installed) --- - auto& mrt_loader = rastack::MetalRTLoader::instance(); - if (mrt_loader.is_loaded() && mrt_loader.has_vision()) { - std::string vlm_dir = rcli::metalrt_models_dir() + "/Qwen3-VL-2B-MLX-4bit"; - std::string safetensors = vlm_dir + "/model.safetensors"; - - // Auto-download VLM model if not installed - if (access(safetensors.c_str(), R_OK) != 0) { - LOG_INFO("VLM", "MetalRT VLM model not found, downloading..."); - std::string hf_base = "https://huggingface.co/runanywhere/Qwen3-VL-2B-Instruct-4bit/resolve/main/"; - std::string dl_cmd = "bash -c '" - "set -e; mkdir -p \"" + vlm_dir + "\"; " - "curl -fL -# -o \"" + vlm_dir + "/config.json\" \"" + hf_base + "config.json\"; " - "curl -fL -# -o \"" + vlm_dir + "/model.safetensors\" \"" + hf_base + "model.safetensors\"; " - "curl -fL -# -o \"" + vlm_dir + "/tokenizer.json\" \"" + hf_base + "tokenizer.json\"; " - "'"; - if (system(dl_cmd.c_str()) != 0) { - LOG_WARN("VLM", "MetalRT VLM model download failed"); - } - } - - if (access(safetensors.c_str(), R_OK) == 0) { - LOG_INFO("VLM", "MetalRT VLM model found at %s", vlm_dir.c_str()); - void* handle = mrt_loader.vision_create(); - if (handle) { - std::lock_guard gpu_lock(mrt_loader.gpu_mutex()); - if (mrt_loader.vision_load(handle, vlm_dir.c_str())) { - engine->metalrt_vision_handle = handle; - engine->using_metalrt_vlm = true; - engine->vlm_initialized = true; - const char* mname = mrt_loader.vision_model_name - ? mrt_loader.vision_model_name(handle) : "Qwen3-VL-2B"; - engine->vlm_backend_name = "MetalRT"; - engine->vlm_model_name = mname; - LOG_INFO("VLM", "MetalRT VLM engine ready (%s)", mname); - return 0; - } - LOG_WARN("VLM", "MetalRT vision_load failed, falling back to llama.cpp"); - mrt_loader.vision_destroy(handle); - } - } - } - - // --- Fallback: llama.cpp VLM backend --- - // MetalRT dylib either not loaded, or doesn't export vision symbols yet. - // Use llama.cpp with GGUF models — still runs on Metal GPU via ggml-metal. - if (mrt_loader.is_loaded() && !mrt_loader.has_vision()) { - LOG_INFO("VLM", "MetalRT engine active but VLM not yet supported in dylib — using llama.cpp for vision"); + // VLM requires the llama.cpp engine + if (engine->initialized && engine->pipeline.using_metalrt()) { + LOG_ERROR("VLM", "VLM is currently available with the llama.cpp engine. Switch with: rcli engine llamacpp"); + return -1; } - // Find or download VLM model + // Check if any VLM model is installed (on-demand, no auto-download) auto vlm_models = rcli::all_vlm_models(); rcli::VlmModelDef model_def; bool found = false; - // Check if any VLM model is installed for (auto& m : vlm_models) { if (rcli::is_vlm_model_installed(engine->models_dir, m)) { model_def = m; @@ -3118,23 +3014,12 @@ static int vlm_init_locked(RCLIEngine* engine) { } } - // If no model installed, download default if (!found) { - auto [has_default, def] = rcli::get_default_vlm_model(); - if (!has_default) { - LOG_ERROR("VLM", "No VLM model defined in registry"); - return -1; - } - model_def = def; - - std::string model_path = engine->models_dir + "/" + model_def.model_filename; - std::string mmproj_path = engine->models_dir + "/" + model_def.mmproj_filename; - - if (!download_vlm_model(model_def.model_url, model_path)) return -1; - if (!download_vlm_model(model_def.mmproj_url, mmproj_path)) return -1; + LOG_ERROR("VLM", "No VLM model installed. Download one with: rcli models vlm"); + return -1; } - // Initialize VLM engine + // Initialize VLM engine with the installed model VlmConfig config; config.model_path = engine->models_dir + "/" + model_def.model_filename; config.mmproj_path = engine->models_dir + "/" + model_def.mmproj_filename; @@ -3151,7 +3036,6 @@ static int vlm_init_locked(RCLIEngine* engine) { } engine->vlm_initialized = true; - engine->using_metalrt_vlm = false; engine->vlm_backend_name = "llama.cpp (Metal GPU)"; engine->vlm_model_name = model_def.name; LOG_INFO("VLM", "VLM engine ready — %s via llama.cpp (Metal GPU)", model_def.name.c_str()); @@ -3172,7 +3056,7 @@ const char* rcli_vlm_analyze(RCLIHandle handle, const char* image_path, const ch if (!engine->vlm_initialized) { if (vlm_init_locked(engine) != 0) { - engine->last_vlm_response = "Error: VLM engine failed to initialize."; + engine->last_vlm_response = "VLM not available. Requires llama.cpp engine (rcli engine llamacpp) and a VLM model (rcli models vlm)."; return engine->last_vlm_response.c_str(); } } @@ -3181,49 +3065,7 @@ const char* rcli_vlm_analyze(RCLIHandle handle, const char* image_path, const ch ? std::string(prompt) : "Describe this image in detail."; - if (engine->using_metalrt_vlm && engine->metalrt_vision_handle) { - // MetalRT vision backend — stream tokens to build result - auto& loader = rastack::MetalRTLoader::instance(); - std::string accumulated; - - rastack::MetalRTLoader::MetalRTVisionOptions opts{}; - opts.max_tokens = 512; - opts.top_k = 40; - opts.temperature = 0.0f; - opts.think = false; - - rastack::MetalRTStreamCb stream_cb = [](const char* piece, void* ud) -> bool { - auto* out = static_cast(ud); - out->append(piece); - return true; - }; - - rastack::MetalRTLoader::MetalRTVisionResult vr; - { - std::lock_guard gpu_lock(loader.gpu_mutex()); - vr = loader.vision_analyze_stream(engine->metalrt_vision_handle, - image_path, text_prompt.c_str(), - stream_cb, &accumulated, &opts); - } - - // Store stats - engine->metalrt_vlm_stats.vision_encode_ms = vr.vision_encode_ms; - engine->metalrt_vlm_stats.prefill_ms = vr.prefill_ms; - engine->metalrt_vlm_stats.decode_ms = vr.decode_ms; - engine->metalrt_vlm_stats.tps = vr.tps; - engine->metalrt_vlm_stats.prompt_tokens = vr.prompt_tokens; - engine->metalrt_vlm_stats.generated_tokens = vr.generated_tokens; - - std::string result = vr.response ? std::string(vr.response) : accumulated; - if (loader.vision_free_result) loader.vision_free_result(vr); - - if (result.empty()) { - engine->last_vlm_response = "Error: Failed to analyze image."; - } else { - engine->last_vlm_response = result; - } - } else { - // llama.cpp VLM backend + { std::string result = engine->vlm_engine.analyze_image( std::string(image_path), text_prompt, nullptr); @@ -3259,21 +3101,12 @@ int rcli_vlm_get_stats(RCLIHandle handle, RCLIVlmStats* out_stats) { auto* engine = static_cast(handle); if (!engine->vlm_initialized) return -1; - if (engine->using_metalrt_vlm) { - auto& s = engine->metalrt_vlm_stats; - out_stats->gen_tok_per_sec = s.tps; - out_stats->generated_tokens = s.generated_tokens; - out_stats->total_time_sec = (s.vision_encode_ms + s.prefill_ms + s.decode_ms) / 1000.0; - out_stats->image_encode_ms = s.vision_encode_ms; - out_stats->first_token_ms = s.prefill_ms; - } else { - auto& s = engine->vlm_engine.last_stats(); - out_stats->gen_tok_per_sec = s.gen_tps(); - out_stats->generated_tokens = static_cast(s.generated_tokens); - out_stats->total_time_sec = (s.image_encode_us + s.generation_us) / 1e6; - out_stats->image_encode_ms = s.image_encode_us / 1000.0; - out_stats->first_token_ms = s.first_token_us / 1000.0; - } + auto& s = engine->vlm_engine.last_stats(); + out_stats->gen_tok_per_sec = s.gen_tps(); + out_stats->generated_tokens = static_cast(s.generated_tokens); + out_stats->total_time_sec = (s.image_encode_us + s.generation_us) / 1e6; + out_stats->image_encode_ms = s.image_encode_us / 1000.0; + out_stats->first_token_ms = s.first_token_us / 1000.0; return 0; } @@ -3286,49 +3119,8 @@ int rcli_vlm_enter(RCLIHandle handle) { auto* engine = static_cast(handle); std::lock_guard lock(engine->mutex); - // Already in VLM mode? - if (engine->using_metalrt_vlm && engine->metalrt_vision_handle) return 0; - - auto& loader = rastack::MetalRTLoader::instance(); - if (!loader.is_loaded() || !loader.has_vision()) return -1; - - // Check VLM model is installed - std::string vlm_dir = rcli::metalrt_models_dir() + "/Qwen3-VL-2B-MLX-4bit"; - if (access((vlm_dir + "/model.safetensors").c_str(), R_OK) != 0) { - LOG_ERROR("VLM", "MetalRT VLM model not installed at %s", vlm_dir.c_str()); - return -1; - } - - // Unload MetalRT LLM to free GPU - if (engine->pipeline.using_metalrt()) { - LOG_INFO("VLM", "Unloading MetalRT LLM to make room for VLM..."); - engine->pipeline.metalrt_llm().shutdown(); - } - - // Load MetalRT VLM - void* vhandle = loader.vision_create(); - if (!vhandle) { - LOG_ERROR("VLM", "vision_create() failed"); - // Try to restore LLM - engine->pipeline.metalrt_llm().init(engine->pipeline.config().metalrt); - return -1; - } - - { - std::lock_guard gpu_lock(loader.gpu_mutex()); - if (!loader.vision_load(vhandle, vlm_dir.c_str())) { - LOG_ERROR("VLM", "vision_load() failed"); - loader.vision_destroy(vhandle); - engine->pipeline.metalrt_llm().init(engine->pipeline.config().metalrt); - return -1; - } - } - - engine->metalrt_vision_handle = vhandle; - engine->using_metalrt_vlm = true; - engine->vlm_initialized = true; - LOG_INFO("VLM", "MetalRT VLM loaded (LLM swapped out)"); - return 0; + if (engine->vlm_initialized) return 0; + return vlm_init_locked(engine); } int rcli_vlm_exit(RCLIHandle handle) { @@ -3336,43 +3128,14 @@ int rcli_vlm_exit(RCLIHandle handle) { auto* engine = static_cast(handle); std::lock_guard lock(engine->mutex); - // Unload MetalRT VLM if active - if (engine->metalrt_vision_handle) { - auto& loader = rastack::MetalRTLoader::instance(); - if (loader.vision_destroy) - loader.vision_destroy(engine->metalrt_vision_handle); - engine->metalrt_vision_handle = nullptr; - } - - // Shutdown llama.cpp VLM if it was the active backend - if (!engine->using_metalrt_vlm && engine->vlm_engine.is_initialized()) { + if (engine->vlm_engine.is_initialized()) { engine->vlm_engine.shutdown(); } - engine->using_metalrt_vlm = false; engine->vlm_initialized = false; engine->vlm_backend_name.clear(); engine->vlm_model_name.clear(); LOG_INFO("VLM", "VLM unloaded"); - - // Reload MetalRT LLM - if (engine->pipeline.using_metalrt()) { - auto& mrt = engine->pipeline.metalrt_llm(); - const auto& cfg = engine->pipeline.config(); - if (mrt.init(cfg.metalrt)) { - // Re-cache system prompt - std::string sys = mrt.profile().build_tool_system_prompt( - cfg.system_prompt, engine->pipeline.tools().get_tool_definitions_json()); - std::string prefix = mrt.profile().build_system_prefix(sys); - mrt.cache_system_prompt(prefix); - mrt.set_system_prompt(sys); - engine->metalrt_kv_continuation_len = 0; - LOG_INFO("VLM", "MetalRT LLM restored"); - } else { - LOG_ERROR("VLM", "Failed to restore MetalRT LLM!"); - return -1; - } - } return 0; } @@ -3394,85 +3157,29 @@ int rcli_vlm_analyze_stream(RCLIHandle handle, const char* image_path, std::string text_prompt = (prompt && prompt[0]) ? std::string(prompt) : "Describe this image in detail."; - if (engine->using_metalrt_vlm && engine->metalrt_vision_handle) { - // --- MetalRT VLM streaming path --- - auto& loader = rastack::MetalRTLoader::instance(); - std::string accumulated; - - struct StreamCtx { - RCLIEventCallback cb; - void* ud; - std::string* accum; - }; - StreamCtx sctx{callback, user_data, &accumulated}; - - rastack::MetalRTStreamCb stream_cb = [](const char* piece, void* ud) -> bool { - auto* ctx = static_cast(ud); - if (std::strstr(piece, "<|im_end|>") || std::strstr(piece, "<|im_start|>")) - return true; - ctx->accum->append(piece); - if (ctx->cb) ctx->cb("token", piece, ctx->ud); - return true; + // llama.cpp VLM streaming path + rastack::TokenCallback token_cb = nullptr; + if (callback) { + token_cb = [callback, user_data](const rastack::TokenOutput& tok) { + if (!tok.text.empty()) { + callback("token", tok.text.c_str(), user_data); + } }; + } - rastack::MetalRTLoader::MetalRTVisionOptions opts{}; - opts.max_tokens = 512; - opts.top_k = 40; - opts.temperature = 0.0f; - opts.think = false; - - rastack::MetalRTLoader::MetalRTVisionResult vr; - { - std::lock_guard gpu_lock(loader.gpu_mutex()); - vr = loader.vision_analyze_stream(engine->metalrt_vision_handle, - image_path, text_prompt.c_str(), - stream_cb, &sctx, &opts); - } - - engine->metalrt_vlm_stats.vision_encode_ms = vr.vision_encode_ms; - engine->metalrt_vlm_stats.prefill_ms = vr.prefill_ms; - engine->metalrt_vlm_stats.decode_ms = vr.decode_ms; - engine->metalrt_vlm_stats.tps = vr.tps; - engine->metalrt_vlm_stats.prompt_tokens = vr.prompt_tokens; - engine->metalrt_vlm_stats.generated_tokens = vr.generated_tokens; - - std::string result = vr.response ? std::string(vr.response) : accumulated; - if (loader.vision_free_result) loader.vision_free_result(vr); - engine->last_vlm_response = result.empty() ? "Error: Failed to analyze image." : result; - - if (callback) { - callback("response", engine->last_vlm_response.c_str(), user_data); - char stats_buf[256]; - snprintf(stats_buf, sizeof(stats_buf), - "{\"tps\":%.1f,\"tokens\":%d,\"vision_encode_ms\":%.1f}", - vr.tps, vr.generated_tokens, vr.vision_encode_ms); - callback("stats", stats_buf, user_data); - } - } else { - // --- llama.cpp VLM streaming path --- - rastack::TokenCallback token_cb = nullptr; - if (callback) { - token_cb = [callback, user_data](const rastack::TokenOutput& tok) { - if (!tok.text.empty()) { - callback("token", tok.text.c_str(), user_data); - } - }; - } - - std::string result = engine->vlm_engine.analyze_image( - std::string(image_path), text_prompt, token_cb); + std::string result = engine->vlm_engine.analyze_image( + std::string(image_path), text_prompt, token_cb); - engine->last_vlm_response = result.empty() ? "Error: Failed to analyze image." : result; + engine->last_vlm_response = result.empty() ? "Error: Failed to analyze image." : result; - if (callback) { - callback("response", engine->last_vlm_response.c_str(), user_data); - auto& s = engine->vlm_engine.last_stats(); - char stats_buf[256]; - snprintf(stats_buf, sizeof(stats_buf), - "{\"tps\":%.1f,\"tokens\":%lld,\"vision_encode_ms\":%.1f}", - s.gen_tps(), s.generated_tokens, s.image_encode_us / 1000.0); - callback("stats", stats_buf, user_data); - } + if (callback) { + callback("response", engine->last_vlm_response.c_str(), user_data); + auto& s = engine->vlm_engine.last_stats(); + char stats_buf[256]; + snprintf(stats_buf, sizeof(stats_buf), + "{\"tps\":%.1f,\"tokens\":%lld,\"vision_encode_ms\":%.1f}", + s.gen_tps(), s.generated_tokens, s.image_encode_us / 1000.0); + callback("stats", stats_buf, user_data); } return engine->last_vlm_response.find("Error:") == 0 ? -1 : 0; diff --git a/src/cli/main.cpp b/src/cli/main.cpp index f20afe2..58cd4e1 100644 --- a/src/cli/main.cpp +++ b/src/cli/main.cpp @@ -485,8 +485,10 @@ static int cmd_vlm(const Args& args) { // Initialize VLM fprintf(stderr, "%sInitializing VLM...%s\n", color::dim, color::reset); if (rcli_vlm_init(g_engine) != 0) { - fprintf(stderr, "%s%sError: Failed to initialize VLM engine%s\n", - color::bold, color::red, color::reset); + fprintf(stderr, "\n%s%s VLM not available.%s\n\n", color::bold, color::red, color::reset); + fprintf(stderr, " VLM requires the llama.cpp engine and a VLM model.\n"); + fprintf(stderr, " Switch engine: %srcli engine llamacpp%s\n", color::bold, color::reset); + fprintf(stderr, " Download model: %srcli models vlm%s\n\n", color::bold, color::reset); rcli_destroy(g_engine); return 1; } @@ -545,8 +547,10 @@ static int cmd_camera(const Args& args) { if (!g_engine) return 1; if (rcli_vlm_init(g_engine) != 0) { - fprintf(stderr, "%s%sError: Failed to initialize VLM engine%s\n", - color::bold, color::red, color::reset); + fprintf(stderr, "\n%s%s VLM not available.%s\n\n", color::bold, color::red, color::reset); + fprintf(stderr, " VLM requires the llama.cpp engine and a VLM model.\n"); + fprintf(stderr, " Switch engine: %srcli engine llamacpp%s\n", color::bold, color::reset); + fprintf(stderr, " Download model: %srcli models vlm%s\n\n", color::bold, color::reset); rcli_destroy(g_engine); return 1; } @@ -613,8 +617,10 @@ static int cmd_screen(const Args& args) { if (!g_engine) return 1; if (rcli_vlm_init(g_engine) != 0) { - fprintf(stderr, "%s%sError: Failed to initialize VLM engine%s\n", - color::bold, color::red, color::reset); + fprintf(stderr, "\n%s%s VLM not available.%s\n\n", color::bold, color::red, color::reset); + fprintf(stderr, " VLM requires the llama.cpp engine and a VLM model.\n"); + fprintf(stderr, " Switch engine: %srcli engine llamacpp%s\n", color::bold, color::reset); + fprintf(stderr, " Download model: %srcli models vlm%s\n\n", color::bold, color::reset); rcli_destroy(g_engine); return 1; } diff --git a/src/cli/model_pickers.h b/src/cli/model_pickers.h index e0109d9..ec0b847 100644 --- a/src/cli/model_pickers.h +++ b/src/cli/model_pickers.h @@ -415,7 +415,7 @@ inline int pick_metalrt_stt() { inline int pick_vlm(const std::string& models_dir) { auto all = rcli::all_vlm_models(); - fprintf(stderr, "\n %s%s VLM Models (Vision)%s\n\n", color::bold, color::orange, color::reset); + fprintf(stderr, "\n %s%s VLM Models (Vision \xC2\xB7 llama.cpp)%s\n\n", color::bold, color::orange, color::reset); fprintf(stderr, " %s# %-30s %-12s %s%s\n", color::bold, "Model", "Size", "Status", color::reset); diff --git a/src/cli/tui_app.h b/src/cli/tui_app.h index b200482..7b01d1e 100644 --- a/src/cli/tui_app.h +++ b/src/cli/tui_app.h @@ -444,29 +444,22 @@ class TuiApp { run_camera_vlm("Describe what you see in this photo in detail."); return true; } - // S key: toggle visual mode (swap LLM ↔ VLM on GPU) + // S key: toggle visual mode (VLM only on llama.cpp engine) if (c == "s" || c == "S") { if (screen_capture_overlay_active()) { screen_capture_hide_overlay(); - add_system_message("Exiting visual mode, restoring LLM..."); + add_system_message("Exiting visual mode..."); screen_->Post(Event::Custom); std::thread([this]() { rcli_vlm_exit(engine_); - add_system_message("Visual mode OFF — LLM restored"); + add_system_message("Visual mode OFF"); screen_->Post(Event::Custom); }).detach(); } else { add_system_message("Entering visual mode, loading VLM..."); screen_->Post(Event::Custom); std::thread([this]() { - // Try MetalRT VLM first; if unavailable, lazily init llama.cpp VLM - bool ready = false; - if (rcli_vlm_enter(engine_) == 0) { - ready = true; - } else if (rcli_vlm_init(engine_) == 0) { - ready = true; - } - if (ready) { + if (rcli_vlm_init(engine_) == 0) { const char* vbe = rcli_vlm_backend_name(engine_); const char* vmodel = rcli_vlm_model_name(engine_); screen_capture_show_overlay(0, 0, 0, 0); @@ -476,7 +469,7 @@ class TuiApp { msg += ". Drag/resize the green frame, then ask a question"; add_system_message(msg); } else { - add_system_message("Failed to load VLM model. Install one: rcli models vlm"); + add_system_message("VLM requires the llama.cpp engine. Switch with: rcli engine llamacpp, then download a model via [M] \xe2\x86\x92 VLM Models"); } screen_->Post(Event::Custom); }).detach(); @@ -1518,6 +1511,7 @@ class TuiApp { e.is_archive = false; models_entries_.push_back(e); } + } else { // ---- llama.cpp engine: show GGUF models only ---- const auto* llm_active = rcli::resolve_active_model(dir, llm_all); @@ -1564,7 +1558,7 @@ class TuiApp { // VLM models (vision) auto vlm_all = rcli::all_vlm_models(); - { ModelEntry h; h.name = "VLM Models (Vision)"; h.is_header = true; models_entries_.push_back(h); } + { ModelEntry h; h.name = "VLM Models (Vision \xC2\xB7 llama.cpp)"; h.is_header = true; models_entries_.push_back(h); } for (auto& m : vlm_all) { ModelEntry e; e.name = m.name; e.id = m.id; e.modality = "VLM"; @@ -2281,7 +2275,7 @@ class TuiApp { add_system_message(buf); } } else { - add_response("(VLM analysis failed. Install a VLM model: rcli models vlm)", ""); + add_response("(VLM not available. Requires llama.cpp engine and a VLM model. Use [M] → VLM Models to download.)", ""); } voice_state_ = VoiceState::IDLE; { @@ -2344,7 +2338,7 @@ class TuiApp { add_system_message(buf); } } else { - add_response("(VLM analysis failed)", ""); + add_response("(VLM not available. Requires llama.cpp engine and a VLM model. Use [M] → VLM Models to download.)", ""); } voice_state_ = VoiceState::IDLE; screen_->Post(Event::Custom); @@ -2587,7 +2581,7 @@ class TuiApp { add_system_message(buf); } } else { - add_response("(VLM analysis failed)", ""); + add_response("(VLM not available. Requires llama.cpp engine and a VLM model. Use [M] → VLM Models to download.)", ""); } voice_state_ = VoiceState::IDLE; screen_->Post(Event::Custom); diff --git a/src/models/model_registry.h b/src/models/model_registry.h index 6b1e99c..e0084d1 100644 --- a/src/models/model_registry.h +++ b/src/models/model_registry.h @@ -347,26 +347,9 @@ inline std::vector metalrt_component_models() { "", true, }, - { - "metalrt-qwen3-vl-2b", - "Qwen3-VL 2B (MLX 4-bit)", - "vlm", - "runanywhere/Qwen3-VL-2B-Instruct-4bit", - "", - "Qwen3-VL-2B-MLX-4bit", - 1200, - "Qwen3 Vision-Language model for image understanding", - "", - true, - }, }; } -inline bool is_metalrt_vlm_installed() { - std::string dir = metalrt_models_dir() + "/Qwen3-VL-2B-MLX-4bit"; - std::string safetensors = dir + "/model.safetensors"; - return access(safetensors.c_str(), R_OK) == 0; -} inline bool is_metalrt_component_installed(const MetalRTComponentModel& m) { std::string dir = metalrt_models_dir() + "/" + m.dir_name; diff --git a/src/models/vlm_model_registry.h b/src/models/vlm_model_registry.h index 3846ae2..5556d7a 100644 --- a/src/models/vlm_model_registry.h +++ b/src/models/vlm_model_registry.h @@ -39,7 +39,19 @@ inline std::vector all_vlm_models() { /* model_size_mb */ 1830, /* mmproj_size_mb */ 445, /* description */ "Qwen3 Vision-Language model. High quality image analysis.", - /* is_default */ true, + /* is_default */ false, + }, + { + /* id */ "lfm2-vl-1.6b", + /* name */ "Liquid LFM2 VL 1.6B", + /* model_filename */ "LFM2-VL-1.6B-Q8_0.gguf", + /* mmproj_filename */ "mmproj-LFM2-VL-1.6B-Q8_0.gguf", + /* model_url */ "https://huggingface.co/LiquidAI/LFM2-VL-1.6B-GGUF/resolve/main/LFM2-VL-1.6B-Q8_0.gguf", + /* mmproj_url */ "https://huggingface.co/LiquidAI/LFM2-VL-1.6B-GGUF/resolve/main/mmproj-LFM2-VL-1.6B-Q8_0.gguf", + /* model_size_mb */ 1250, + /* mmproj_size_mb */ 210, + /* description */ "Liquid Foundation Model for vision. Fast, 128K context.", + /* is_default */ false, }, { /* id */ "smolvlm-500m",