diff --git a/README.md b/README.md index b628faa..33dd560 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,17 @@ Search a filename, a phrase, or describe what you're looking for in your own words. See the relevant passage or media preview alongside your results, then open the file. Your files stay on your device. +![searchi starts as a compact floating search bar](docs/screenshots/launcher-empty.png) + +*One shortcut. One floating search bar. Results appear when you type.* + ![searchi finding a supplier agreement and highlighting its matching passage](docs/screenshots/search-light.png) *“cancel the supplier partnership” finds the agreement and brings its termination passage into view.* -Press **Ctrl + Shift + Space** on Windows to bring searchi forward and type. +Press **Ctrl + Shift + Space** on Windows to bring up a floating search bar. +Type to reveal results and a preview; clear the query to return to the compact bar. Escape or closing the window hides it while search stays ready. Use the tray icon beside the clock to open searchi or quit it. @@ -26,6 +31,7 @@ icon beside the clock to open searchi or quit it. | What you remember | What to try | | --- | --- | +| An application to open | `VS Code`, `vscode` or `Calculator` | | The file's name | `Supplier agreement.pdf` | | Words inside a document | `sixty days written notice` | | What a document is about | `cancel the supplier partnership` | @@ -83,7 +89,7 @@ like an explanation, expand the small **Why this result?** detail. Light and dark themes follow your system preference. Keyboard navigation, short transitions and reduced-motion support keep the interface familiar and comfortable. In **Settings → Appearance**, choose a solid, glass or -frosted-glass window background and adjust its tint. Reading surfaces stay +frosted-glass window background and adjust its opacity. Reading surfaces stay clear. Native glass currently requires **Windows 11 22H2 or later**. macOS, Linux, unsupported Windows versions and high-contrast mode currently use a solid background; your appearance preference is retained. @@ -93,29 +99,34 @@ solid background; your appearance preference is retained. *“mountains reflected in calm water” searches the pictures themselves, so a useful filename isn't required.* +The screenshots show the current compact and expanded interface, rendered +against the real local search engine; native glass depends on the operating system. + Images open in the preview. Audio and video have playback controls, and matching media segments can jump to their approximate location. Nothing autoplays when you select a result. ## Get searchi -**Windows x64 version 0.1.7 is available.** +**Windows x64 version 0.1.8 is available.** -- [Download the Windows installer](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.7/searchi_0.1.7_x64-setup.exe) — about 100 MB. -- [Checksums](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.7/searchi_0.1.7_x64-setup.exe.sha256) · [Release notes](https://tea.karlcom.de/karl/searchi/releases/tag/v0.1.7). +- [Download the Windows installer](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.8/searchi_0.1.8_x64-setup.exe) — about 100 MB. +- [Checksums](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.8/searchi_0.1.8_x64-setup.exe.sha256) · [Release notes](https://tea.karlcom.de/karl/searchi/releases/tag/v0.1.8). -Already installed? Choose Quit from the tray before updating. Version 0.1.7 adds a resident tray launcher alongside experimental local speech search, exact music artist/title filters and retains the fixes for -archived files dated before 1970. -It includes isolated PDF/DOCX processing and bounded media previews. +Already installed? Choose Quit from the tray before updating. Version 0.1.8 +adds the floating launcher, installed-application search and automatic background +file discovery. Explicit media requests no longer fall back to unrelated media +channels. It retains local speech search, exact artist/title filters, isolated +PDF/DOCX processing and bounded media previews. The installer creates a launcher and sets up WebView2 if needed. This initial release is unsigned. A Linux desktop preview is also available; macOS source build instructions are available now. You can also let your local AI agent handle setup. -**Ubuntu 24.04 x64 desktop preview:** [Download the Debian package](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.7/searchi_0.1.4_amd64.deb) -and install with `sudo apt install ./searchi_0.1.4_amd64.deb`. -[Checksums](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.7/LINUX-DESKTOP-SHA256SUMS-0.1.4.txt). -Startup was tested in WSL using CPU; native graphical interaction and other +**Ubuntu 24.04 x64 desktop preview 0.1.8:** [Download the Debian package](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.8/searchi_0.1.8_amd64.deb) +and install with `sudo apt install ./searchi_0.1.8_amd64.deb`. +[Checksum](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.8/searchi_0.1.8_amd64.deb.sha256). +Packaged startup was tested on WSLg using CPU; native graphical interaction and other distributions still need testing. [Compatibility and evidence](docs/LINUX-DESKTOP.md). **Running a Linux server?** The optional @@ -166,10 +177,11 @@ or permission prevents setup, explain precisely what is missing; don't report a working installation until it is verified. Ask for my help only when a password, system approval or other manual action is required. -Let me choose which folders to index. Keep search local and optional cloud -summaries disabled. On desktop, show me how to add a folder; on headless Linux, -prepare the chosen folder through the CLI. Finish with a filename search -followed by a search in my own words. +On desktop, let automatic local file discovery run in the background. Show me +the floating search bar, an application search and the indexing setting. Keep +search local and optional cloud summaries disabled. On headless Linux, ask which +folder to prepare through the CLI. Finish with a filename search followed by +a search in my own words. Do not claim every file is ready while indexing runs. ``` Your agent needs internet access and permission to install software locally. @@ -189,23 +201,26 @@ Prefer to set it up yourself? See the Once searchi is running: -1. Choose **Add folder**, browse to the folder you want to search, then choose - **Index folder**. -2. Start with a filename or words you remember. These searches become - available as files are indexed. -3. Let the local search model prepare in the background. First setup downloads - approximately **865 MB** and needs an internet connection. Document and code - search by meaning can begin once the smaller text model and text index are - ready, while the larger media model continues downloading. The app shows - progress and keeps text search available if media setup needs a retry. -4. Describe what you're looking for, select a result and inspect the preview. - Press **Enter** to open the selected file in its usual application. +1. Press **Ctrl + Shift + Space** on Windows. A compact search bar floats in + the center of your screen; results and previews appear when you type. +2. Try `VS Code` to find and launch an installed application. App search is + available independently of file indexing and the local model download. +3. searchi automatically discovers accessible local files in the background. + System folders, caches, build outputs, common credential stores and its own + data are excluded. Common + personal folders are prepared first; a large collection takes longer. +4. Search a filename or words you remember as files become available. The local + Google model prepares in the background for searches by meaning. First setup + downloads approximately **865 MB**; the smaller text model becomes usable + before the larger media assets finish downloading. +5. Select a result to inspect its passage or media preview, then press **Enter** + to open it. Escape hides the launcher; the tray keeps it available. -How quickly a folder becomes searchable depends on its size and contents. -The desktop keeps its search model warm for subsequent searches. -searchi watches your chosen folders and updates search in the background as -files change. You can also use **Reindex** to check a folder again, for example -after reconnecting an external drive. +In **Settings**, you can turn automatic discovery off or add a specific folder, +including a network folder. Existing indexed results remain searchable when +automatic discovery is off; an already running scan may finish. Files are +updated in the background, and **Reindex** checks a selected folder again. +The desktop keeps the real Google query model warm for subsequent searches. searchi automatically chooses supported graphics acceleration when available and falls back to CPU when it cannot use it. You do not need a graphics @@ -213,8 +228,9 @@ development toolkit. Hardware and release validation are still in progress. ## Private by design -You choose the folders to index. By default, file contents, previews and search -processing stay local. Ordinary searches aren't sent to a cloud AI service. +Desktop file discovery runs automatically across accessible local storage. +You can change it in Settings. File contents, previews and ordinary search +processing stay local; searches are not sent to a cloud AI service. If you enable optional AI answers, an explicit **Summarize** or **Ask** action sends the previewed document excerpt to the endpoint you choose. Ordinary @@ -290,8 +306,8 @@ files contain private information. For contributors: [building searchi and the headless Linux CLI](docs/BUILDING.md) · [roadmap](docs/ROADMAP.md) · [search-quality evaluation](docs/MEDIA-QUALITY.md). -The screenshots show the working interface searching real example files with -the local Google model. They are browser captures of the app interface; -operating-system window borders are omitted. [Photo credits](fixtures/media/README.md). +The screenshots show the current interface searching real example files with +the local Google model. They are isolated software-test browser captures; +operating-system integration and native glass are not shown. [Photo credits](fixtures/media/README.md). Made by Karl. Open source under the [MIT license](LICENSE). diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 1413806..e804f2f 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "searchi-desktop", - "version": "0.1.7", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "searchi-desktop", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@fluentui/svg-icons": "1.1.344", "@fontsource-variable/inter": "5.3.0", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2fc3b03..9d58efb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "searchi-desktop", - "version": "0.1.7", + "version": "0.1.8", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index d2cf360..90241e5 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -3799,7 +3799,7 @@ dependencies = [ [[package]] name = "searchi-desktop" -version = "0.1.7" +version = "0.1.8" dependencies = [ "anyhow", "image", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 2f69b6a..7066ae6 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "searchi-desktop" -version = "0.1.7" +version = "0.1.8" edition = "2021" description = "Local Searchi desktop shell" license = "MIT" diff --git a/apps/desktop/src-tauri/src/applications.rs b/apps/desktop/src-tauri/src/applications.rs new file mode 100644 index 0000000..7b4574e --- /dev/null +++ b/apps/desktop/src-tauri/src/applications.rs @@ -0,0 +1,539 @@ +//! Bounded native application discovery. The webview receives opaque catalog +//! identifiers, never executable paths or command lines. +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashSet, + fs, + path::{Path, PathBuf}, +}; +#[cfg(not(target_os = "linux"))] +use tauri_plugin_opener::OpenerExt; + +const MAX_ENTRIES: usize = 8192; +const MAX_APPS: usize = 4096; +const MAX_DEPTH: usize = 8; + +#[derive(Clone, Debug, Serialize)] +pub struct ApplicationResult { + pub id: String, + pub name: String, +} +struct Application { + result: ApplicationResult, + normalized_name: String, + compact_name: String, + path: PathBuf, +} +pub struct ApplicationCatalog { + apps: Vec, + #[cfg(windows)] + registered: std::sync::Arc>>, +} +impl ApplicationCatalog { + pub fn discover() -> Self { + let catalog = Self::from_roots(application_roots()); + #[cfg(windows)] + { + let destination = catalog.registered.clone(); + // Store/UWP enumeration is optional background work. Startup and + // searches never wait for PowerShell or model setup. + std::thread::spawn(move || { + if let Some(apps) = registered_applications() { + if let Ok(mut destination) = destination.lock() { + *destination = apps; + } + } + }); + } + catalog + } + fn from_roots(roots: Vec) -> Self { + let mut apps = Vec::new(); + let mut visited = 0; + let mut names = HashSet::new(); + #[cfg(target_os = "linux")] + let mut desktop_ids = HashSet::new(); + for root in roots { + let Ok(root) = root.canonicalize() else { + continue; + }; + let mut pending = vec![(root.clone(), 0)]; + while let Some((directory, depth)) = pending.pop() { + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + if visited >= MAX_ENTRIES || apps.len() >= MAX_APPS { + break; + } + visited += 1; + let Ok(kind) = entry.file_type() else { + continue; + }; + // No directory symlink traversal or external catalog paths. + if kind.is_symlink() { + continue; + } + let path = entry.path(); + if kind.is_dir() && !is_app_bundle(&path) { + if depth < MAX_DEPTH { + pending.push((path, depth + 1)); + } + continue; + } + #[cfg(target_os = "linux")] + if extension_is(&path, "desktop") { + // User entries (including Hidden=true) shadow the same + // desktop ID in system roots, as required by XDG. + let relative = path.strip_prefix(&root).ok().map(|path| { + path.components() + .map(|part| part.as_os_str().to_string_lossy()) + .collect::>() + .join("-") + }); + if relative.is_some_and(|id| !desktop_ids.insert(id)) { + continue; + } + } + let Some(name) = application_name(&path) else { + continue; + }; + let Ok(path) = path.canonicalize() else { + continue; + }; + if !path.starts_with(&root) { + continue; + } + let normalized_name = name.to_lowercase(); + if !names.insert(normalized_name.clone()) { + continue; + } + let id = format!( + "app:{:x}", + Sha256::digest(path.to_string_lossy().as_bytes()) + ); + apps.push(Application { + result: ApplicationResult { id, name }, + compact_name: compact_name(&normalized_name), + normalized_name, + path, + }); + } + if visited >= MAX_ENTRIES || apps.len() >= MAX_APPS { + break; + } + } + } + apps.sort_by(|a, b| a.normalized_name.cmp(&b.normalized_name)); + Self { + apps, + #[cfg(windows)] + registered: Default::default(), + } + } + + pub fn search(&self, query: &str, limit: usize) -> Vec { + let query = query.trim().to_lowercase(); + if query.is_empty() || query.len() > 512 { + return Vec::new(); + } + let words: Vec<_> = query.split_whitespace().collect(); + let compact_query = compact_name(&query); + let mut matches: Vec<_> = self + .apps + .iter() + .filter_map(|app| { + let rank = name_match_rank( + &app.normalized_name, + &app.compact_name, + &query, + &compact_query, + &words, + )?; + Some((rank, app)) + }) + .map(|(rank, app)| (rank, app.normalized_name.clone(), app.result.clone())) + .collect(); + #[cfg(windows)] + if let Ok(registered) = self.registered.try_lock() { + for app in registered.iter() { + if matches + .iter() + .any(|(_, name, _)| name == &app.normalized_name) + { + continue; + } + let name = &app.normalized_name; + let Some(rank) = + name_match_rank(name, &app.compact_name, &query, &compact_query, &words) + else { + continue; + }; + matches.push((rank, name.clone(), app.result.clone())); + } + } + matches.sort_by(|(ra, a, _), (rb, b, _)| ra.cmp(rb).then_with(|| a.cmp(b))); + matches + .into_iter() + .take(limit.min(32)) + .map(|(_, _, result)| result) + .collect() + } + + pub fn launch(&self, app: &tauri::AppHandle, id: &str) -> Result<(), String> { + #[cfg(windows)] + if let Some(app_id) = self.registered.lock().ok().and_then(|apps| { + apps.iter() + .find(|entry| entry.result.id == id) + .map(|entry| entry.app_id.clone()) + }) { + // Explorer invokes the registered Windows application ID. It is a + // single argument from the catalog, never frontend command text. + std::process::Command::new("explorer.exe") + .arg(format!("shell:AppsFolder\\{app_id}")) + .spawn() + .map_err(|error| format!("Could not launch application: {error}"))?; + return Ok(()); + } + let entry = self + .apps + .iter() + .find(|entry| entry.result.id == id) + .ok_or_else(|| "Application is not in the local catalog.".to_owned())?; + if !entry.path.exists() { + return Err( + "Application is no longer installed. Restart searchi to refresh applications." + .into(), + ); + } + #[cfg(target_os = "linux")] + { + // GIO implements the freedesktop Desktop Entry launch semantics, + // including quoting/field codes. Never interpret Exec as shell text. + let status = std::process::Command::new("gio") + .arg("launch") + .arg(&entry.path) + .status() + .map_err(|error| format!("Could not launch application using GIO: {error}"))?; + if !status.success() { + return Err("The operating system could not launch this application.".into()); + } + let _ = app; + Ok(()) + } + #[cfg(not(target_os = "linux"))] + app.opener() + .open_path(entry.path.to_string_lossy().into_owned(), None::<&str>) + .map_err(|error| error.to_string()) + } +} + +fn compact_name(name: &str) -> String { + name.chars() + .filter(|character| !character.is_whitespace()) + .collect() +} +fn name_match_rank( + name: &str, + compact: &str, + query: &str, + compact_query: &str, + words: &[&str], +) -> Option { + if name == query { + Some(0) + } else if name.starts_with(query) { + Some(1) + } else if name.contains(query) { + Some(2) + } else if words.iter().all(|word| name.contains(word)) { + Some(3) + } else if compact.contains(compact_query) + || (compact.starts_with("visualstudiocode") && compact_query == "vscode") + { + Some(4) + } else { + None + } +} + +fn application_roots() -> Vec { + #[cfg(windows)] + return ["APPDATA", "PROGRAMDATA"] + .into_iter() + .filter_map(std::env::var_os) + .map(|root| PathBuf::from(root).join("Microsoft/Windows/Start Menu/Programs")) + .collect(); + #[cfg(target_os = "macos")] + { + let mut roots = vec![ + PathBuf::from("/Applications"), + PathBuf::from("/System/Applications"), + ]; + if let Some(home) = std::env::var_os("HOME") { + roots.insert(0, PathBuf::from(home).join("Applications")); + } + roots + } + #[cfg(not(any(windows, target_os = "macos")))] + { + let mut roots = Vec::new(); + if let Some(home) = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")) + }) + { + roots.push(home.join("applications")); + } + let directories = std::env::var_os("XDG_DATA_DIRS") + .unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + roots.extend(std::env::split_paths(&directories).map(|root| root.join("applications"))); + roots + } +} + +#[cfg(windows)] +struct RegisteredApplication { + result: ApplicationResult, + normalized_name: String, + compact_name: String, + app_id: String, +} + +#[cfg(windows)] +fn registered_applications() -> Option> { + use std::{ + io::{Read, Seek, SeekFrom}, + os::windows::process::CommandExt, + time::{Duration, Instant}, + }; + #[derive(serde::Deserialize)] + struct Entry { + #[serde(rename = "Name")] + name: String, + #[serde(rename = "AppID")] + app_id: String, + } + let mut output = tempfile::tempfile().ok()?; + let script = "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); Get-StartApps | Select-Object -First 4096 Name,AppID | ConvertTo-Json -Compress"; + let mut child = std::process::Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .creation_flags(0x08000000) + .stdin(std::process::Stdio::null()) + .stdout(output.try_clone().ok()?) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => break, + Ok(Some(_)) => return None, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + if output.metadata().ok()?.len() > 4 * 1024 * 1024 { + return None; + } + output.seek(SeekFrom::Start(0)).ok()?; + let mut bytes = Vec::new(); + output.read_to_end(&mut bytes).ok()?; + let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + let values = match value { + serde_json::Value::Array(values) => values, + value => vec![value], + }; + Some( + values + .into_iter() + .take(MAX_APPS) + .filter_map(|value| { + let entry: Entry = serde_json::from_value(value).ok()?; + if !valid_registered_id(&entry.app_id) + || entry.name.trim().is_empty() + || entry.name.len() > 512 + { + return None; + } + let normalized_name = entry.name.to_lowercase(); + let id = format!( + "app:registered:{:x}", + Sha256::digest(entry.app_id.as_bytes()) + ); + Some(RegisteredApplication { + result: ApplicationResult { + id, + name: entry.name, + }, + compact_name: compact_name(&normalized_name), + normalized_name, + app_id: entry.app_id, + }) + }) + .collect(), + ) +} + +#[cfg(any(windows, test))] +fn valid_registered_id(id: &str) -> bool { + id.len() <= 512 + && id.contains('!') + && id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-!".contains(&byte)) +} +fn is_app_bundle(path: &Path) -> bool { + cfg!(target_os = "macos") && extension_is(path, "app") +} +fn extension_is(path: &Path, expected: &str) -> bool { + path.extension() + .is_some_and(|extension| extension.to_string_lossy().eq_ignore_ascii_case(expected)) +} +fn application_name(path: &Path) -> Option { + #[cfg(windows)] + if extension_is(path, "lnk") { + return path + .file_stem() + .map(|name| name.to_string_lossy().into_owned()); + } + #[cfg(target_os = "macos")] + if is_app_bundle(path) { + return path + .file_stem() + .map(|name| name.to_string_lossy().into_owned()); + } + #[cfg(not(any(windows, target_os = "macos")))] + if extension_is(path, "desktop") { + if fs::metadata(path).ok()?.len() > 128 * 1024 { + return None; + } + return desktop_name(&fs::read_to_string(path).ok()?); + } + None +} + +#[cfg(any(target_os = "linux", test))] +fn desktop_name(contents: &str) -> Option { + let mut in_entry = false; + let mut name = None; + let mut application = false; + let mut executable = false; + for line in contents.lines().map(str::trim) { + if line.starts_with('[') { + in_entry = line == "[Desktop Entry]"; + continue; + } + if !in_entry || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + match key { + "Hidden" | "NoDisplay" if value == "true" => return None, + "Type" => application = value == "Application", + "Exec" => executable = !value.trim().is_empty(), + "Name" => name = Some(value.trim().to_owned()), + _ => {} + } + } + let name = name?; + (application && executable && !name.is_empty() && name.len() <= 512).then_some(name) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn registered_ids_reject_paths_and_command_text() { + assert!(valid_registered_id( + "Microsoft.WindowsCalculator_8wekyb3d8bbwe!App" + )); + for value in [ + "C:\\program.exe", + "app!id & bad", + "app!id/other", + "", + "app!id\n", + ] { + assert!(!valid_registered_id(value)); + } + } + #[test] + #[cfg(windows)] + #[ignore = "Reads the actual Windows registered-app catalog; launches nothing"] + fn real_windows_application_catalog_is_searchable() { + let catalog = ApplicationCatalog::discover(); + assert!( + !catalog.apps.is_empty(), + "Expected installed desktop shortcuts" + ); + let apps = registered_applications().expect("Windows Get-StartApps inventory"); + assert!(!apps.is_empty(), "Expected registered Windows apps"); + let query = apps[0].result.name.clone(); + *catalog.registered.lock().unwrap() = apps; + assert!(!catalog.search(&query, 10).is_empty()); + eprintln!( + "Native shortcuts: {}; registered applications: {}", + catalog.apps.len(), + catalog.registered.lock().unwrap().len() + ); + } + #[test] + fn desktop_entries_require_visible_executable_applications() { + let valid = "[Desktop Entry]\nType=Application\nName=Text Editor\nExec=editor %F\n"; + assert_eq!(desktop_name(valid).as_deref(), Some("Text Editor")); + for invalid in [ + format!("{valid}Hidden=true\n"), + format!("{valid}NoDisplay=true\n"), + "[Other]\nType=Application\nName=Fake\nExec=bad\n".into(), + "[Desktop Entry]\nType=Link\nName=Website\nURL=https://example.org\n".into(), + ] { + assert!(desktop_name(&invalid).is_none()); + } + } + #[test] + fn names_match_without_exposing_launch_paths() { + let catalog = ApplicationCatalog { + #[cfg(windows)] + registered: Default::default(), + apps: ["Calculator", "Visual Studio Code", "Code"] + .into_iter() + .enumerate() + .map(|(i, name)| Application { + result: ApplicationResult { + id: format!("app:{i}"), + name: name.into(), + }, + normalized_name: name.to_lowercase(), + compact_name: compact_name(&name.to_lowercase()), + path: PathBuf::from("private"), + }) + .collect(), + }; + assert_eq!(catalog.search("CODE", 10)[0].name, "Code"); + assert_eq!(catalog.search("VS Code", 10)[0].name, "Visual Studio Code"); + assert_eq!(catalog.search("vscode", 10)[0].name, "Visual Studio Code"); + assert_eq!( + catalog.search("visualstudiocode", 10)[0].name, + "Visual Studio Code" + ); + assert_eq!( + catalog.search("visual code", 10)[0].name, + "Visual Studio Code" + ); + assert!(catalog.search("", 10).is_empty()); + assert!(catalog.search("not installed", 10).is_empty()); + assert!(catalog.search("code", 0).is_empty()); + assert!(!serde_json::to_string(&catalog.search("code", 10)) + .unwrap() + .contains("private")); + } +} diff --git a/apps/desktop/src-tauri/src/automatic_indexing.rs b/apps/desktop/src-tauri/src/automatic_indexing.rs new file mode 100644 index 0000000..c3df6c6 --- /dev/null +++ b/apps/desktop/src-tauri/src/automatic_indexing.rs @@ -0,0 +1,189 @@ +//! Platform discovery for automatic local search. No network shares are mounted. +use std::path::PathBuf; + +pub fn roots(custom: &[PathBuf], enabled: bool) -> Vec { + let mut candidates = custom.to_vec(); + if enabled && std::env::var("SEARCHI_DISABLE_AUTOMATIC_INDEXING").as_deref() != Ok("1") { + candidates.extend(local_roots()); + } + let mut normalized: Vec = candidates + .into_iter() + .filter_map(|path| std::fs::canonicalize(path).ok()) + .filter(|path| path.is_dir()) + .collect(); + normalized.sort(); + normalized.dedup(); + let mut result: Vec = Vec::new(); + for path in normalized { + if !result.iter().any(|parent| path.starts_with(parent)) { + result.push(path); + } + } + result.truncate(64); + result +} + +#[cfg(windows)] +fn local_roots() -> Vec { + use windows_sys::Win32::Storage::FileSystem::{GetDriveTypeW, GetLogicalDrives}; + let mask = unsafe { GetLogicalDrives() }; + (0..26) + .filter_map(|letter| { + if mask & (1 << letter) == 0 { + return None; + } + let path = format!("{}:\\", char::from(b'A' + letter)); + let wide: Vec = path.encode_utf16().chain(Some(0)).collect(); + // DRIVE_FIXED=3; DRIVE_REMOVABLE=2. Skip network, optical and absent media. + matches!(unsafe { GetDriveTypeW(wide.as_ptr()) }, 2 | 3).then(|| PathBuf::from(path)) + }) + .collect() +} + +#[cfg(not(windows))] +fn local_roots() -> Vec { + vec![PathBuf::from("/")] +} + +pub fn exclusions() -> Vec { + #[cfg(windows)] + let mut paths: Vec = ["WINDIR", "ProgramFiles", "ProgramFiles(x86)", "ProgramData"] + .iter() + .filter_map(std::env::var_os) + .map(PathBuf::from) + .collect(); + #[cfg(not(windows))] + let mut paths: Vec = [ + "/proc", "/sys", "/dev", "/run", "/tmp", "/var", "/usr", "/etc", "/boot", "/snap", + ] + .iter() + .map(PathBuf::from) + .collect(); + #[cfg(windows)] + if let Some(local) = std::env::var_os("LOCALAPPDATA") { + paths.push(PathBuf::from(local).join("Programs")); + } + if let Some(temp) = std::env::temp_dir().is_absolute().then(std::env::temp_dir) { + paths.push(temp); + } + paths + .into_iter() + .filter_map(|path| super::data_directory::canonical_exclusion(&path).ok()) + .collect() +} + +pub const EXCLUDED_DIRECTORY_NAMES: &[&str] = &[ + ".git", + "node_modules", + "target", + ".searchi", + ".cache", + ".cargo", + ".rustup", + ".venv", + "__pycache__", + "Cache", + "Caches", + "CachedData", + "Code Cache", + "GPUCache", + "$RECYCLE.BIN", + "System Volume Information", + ".ssh", + ".gnupg", + ".codex", + ".agents", + ".aws", + ".azure", + ".kube", + "secrets", + ".secrets", +]; + +pub fn excluded_names() -> Vec { + EXCLUDED_DIRECTORY_NAMES + .iter() + .map(|name| (*name).to_owned()) + .collect() +} + +/// Commit common personal folders separately before the first whole-volume +/// reconciliation so keyword search becomes useful while the larger walk runs. +pub fn first_use_scopes(root: &std::path::Path) -> Vec { + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }) + .and_then(|value| std::fs::canonicalize(PathBuf::from(value)).ok()); + let Some(home) = home.filter(|home| home.starts_with(root)) else { + return Vec::new(); + }; + [ + "Desktop", + "Documents", + "Downloads", + "Pictures", + "Music", + "Videos", + ] + .iter() + .map(|name| home.join(name)) + .filter(|path| path.is_dir()) + .filter_map(|path| path.canonicalize().ok()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn custom_roots_are_canonical_nonoverlapping_and_missing_roots_are_skipped() { + let dir = tempfile::tempdir().unwrap(); + let child = dir.path().join("child"); + std::fs::create_dir(&child).unwrap(); + assert_eq!( + roots( + &[child, dir.path().to_owned(), dir.path().join("absent")], + false + ), + vec![dir.path().canonicalize().unwrap()] + ); + } + #[test] + fn exclusions_do_not_exclude_user_documents_by_name() { + let names = excluded_names(); + assert!(names.iter().any(|name| name == "Cache")); + assert!(!names + .iter() + .any(|name| name == "Documents" || name == "AppData")); + assert!(exclusions().iter().all(|path| path.is_absolute())); + } + + #[test] + fn automatic_scan_keeps_documents_and_prunes_credential_stores() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("Documents")).unwrap(); + std::fs::create_dir(dir.path().join("secrets")).unwrap(); + std::fs::write( + dir.path().join("Documents/notes.txt"), + "visible fixture document", + ) + .unwrap(); + std::fs::write( + dir.path().join("secrets/credentials.json"), + "{\"test_only\":true}", + ) + .unwrap(); + let db = tempfile::tempdir().unwrap(); + let mut index = searchi_core::SearchIndex::open(db.path().join("index.sqlite")).unwrap(); + index + .index_folder_with_options( + dir.path(), + &searchi_core::IndexOptions { + excluded_directory_names: excluded_names(), + excluded_paths: Vec::new(), + }, + ) + .unwrap(); + assert_eq!(index.stats().unwrap().files, 1); + assert!(!index.search("notes.txt", 10).unwrap().is_empty()); + assert!(index.search("credentials.json", 10).unwrap().is_empty()); + } +} diff --git a/apps/desktop/src-tauri/src/instance.rs b/apps/desktop/src-tauri/src/instance.rs index 41549fa..5d0f7ec 100644 --- a/apps/desktop/src-tauri/src/instance.rs +++ b/apps/desktop/src-tauri/src/instance.rs @@ -90,6 +90,61 @@ pub fn hide_search(app: tauri::AppHandle) -> Result<(), String> { .map_err(|error| error.to_string()) } +/// A launcher grows only when it has content, staying inside its current display. +#[tauri::command] +pub fn set_search_layout(app: tauri::AppHandle, expanded: bool) -> Result<(), String> { + let window = app + .get_webview_window("main") + .ok_or_else(|| "Search window is unavailable.".to_owned())?; + let monitor = window + .current_monitor() + .map_err(|error| error.to_string())? + .or(window + .primary_monitor() + .map_err(|error| error.to_string())?); + let (width, height) = if expanded { + (1080.0, 700.0) + } else { + (720.0, 110.0) + }; + if let Some(monitor) = monitor { + let scale = monitor.scale_factor(); + let area = monitor.work_area(); + let size = &area.size; + let width = (width * scale) + .min(f64::from(size.width).max(80.0) - 48.0) + .max(32.0) as u32; + let height = (height * scale) + .min(f64::from(size.height).max(80.0) - 80.0) + .max(32.0) as u32; + window + .set_size(tauri::PhysicalSize::new(width, height)) + .map_err(|error| error.to_string())?; + let origin = &area.position; + window + .set_position(tauri::PhysicalPosition::new( + origin.x + ((size.width - width) / 2) as i32, + origin.y + ((size.height - height) / 2) as i32, + )) + .map_err(|error| error.to_string())?; + } else { + window + .set_size(tauri::LogicalSize::new(width, height)) + .map_err(|error| error.to_string())?; + window.center().map_err(|error| error.to_string())?; + } + Ok(()) +} + +/// Dragging is limited to our launcher; no general window access is exposed. +#[tauri::command] +pub fn drag_search(app: tauri::AppHandle) -> Result<(), String> { + app.get_webview_window("main") + .ok_or_else(|| "Search window is unavailable.".to_owned())? + .start_dragging() + .map_err(|error| error.to_string()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 8eb838f..0002bb7 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -5,6 +5,8 @@ compile_error!("Release desktop builds require --features custom-protocol to emb mod ai_commands; mod appearance; +mod applications; +mod automatic_indexing; mod data_directory; mod instance; mod media_preview; @@ -36,6 +38,7 @@ use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; use tauri_plugin_opener::OpenerExt; struct AppState { + applications: applications::ApplicationCatalog, appearance: Arc>, ai: Arc, database: PathBuf, @@ -119,6 +122,38 @@ fn desktop_status(state: State<'_, AppState>) -> Result { }) } +#[tauri::command] +fn search_applications( + query: String, + state: State<'_, AppState>, +) -> Vec { + state.applications.search(&query, 20) +} + +#[tauri::command] +fn launch_application( + id: String, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + state.applications.launch(&app, &id) +} + +#[tauri::command] +fn set_automatic_indexing(enabled: bool, state: State<'_, AppState>) -> Result { + let mut store = state + .settings + .lock() + .map_err(|_| "Settings are unavailable.")?; + let mut next = store.value.clone(); + next.automatic_indexing = enabled; + store.save(next)?; + state + .watching + .replace_roots(automatic_indexing::roots(&store.value.roots, enabled))?; + Ok(store.value.clone()) +} + #[tauri::command] fn set_cpu_threads(threads: usize, state: State<'_, AppState>) -> Result { if state.indexing.load(Ordering::Acquire) { @@ -512,6 +547,21 @@ fn start_folder_watching( let result = (|| -> Result { let mut index = SearchIndex::open(&database).map_err(|error| error.to_string())?; index.set_document_worker(document_worker.clone()); + if request.paths.is_none() + && index.stats().map_err(|error| error.to_string())?.files == 0 + { + for scope in automatic_indexing::first_use_scopes(&request.root) { + index + .index_paths_cancellable( + &request.root, + &[scope], + &options, + &request.cancelled, + ) + .map_err(|error| error.to_string())?; + let _ = app.emit("search-index-changed", ()); + } + } let report = match request.paths { Some(paths) => index.index_paths_cancellable( &request.root, @@ -613,6 +663,9 @@ fn main() { "searchi-extract-worker" })); let lexical = Arc::new(Mutex::new(SearchIndex::open(&database)?)); + // Install semantic schema before automatic scans acquire the writer. + // Query/model preparation can then open current stores as readers. + searchi_service::HybridSearch::prepare_database(&database)?; let warm_database = database.clone(); let warm_target = lexical.clone(); tauri::async_runtime::spawn_blocking(move || { @@ -676,20 +729,21 @@ fn main() { } }); let mut ignored = vec![directory.clone(), model_directory.clone()]; + ignored.extend(automatic_indexing::exclusions()); if let Some(parent) = speech_worker.parent().filter(|path| path.is_absolute()) { ignored.push(parent.to_path_buf()); } if let Some(parent) = worker.parent().filter(|path| path.is_absolute()) { ignored.push(parent.to_path_buf()); } let index_options = searchi_core::IndexOptions { + excluded_directory_names: automatic_indexing::excluded_names(), excluded_paths: ignored.iter() .map(|path| data_directory::canonical_exclusion(path)) .collect::>>()?, - ..Default::default() }; let indexing = Arc::new(AtomicBool::new(false)); let watching = Arc::new(start_folder_watching( app.handle().clone(), database.clone(), lexical.clone(), indexing.clone(), - settings.value.roots.clone(), index_options.clone(), document_worker.clone(), + automatic_indexing::roots(&settings.value.roots, settings.value.automatic_indexing), index_options.clone(), document_worker.clone(), ).map_err(std::io::Error::other)?); let semantic = Arc::new( SemanticState::new(model_directory, worker, media_tools) @@ -715,6 +769,7 @@ speech_worker, directory.join("SpeechModels")), let window = app.get_webview_window("main").ok_or_else(|| std::io::Error::other("Main window is unavailable."))?; let appearance = appearance::apply(&window, settings.value.background_material, settings.value.background_opacity, false); app.manage(AppState { + applications: applications::ApplicationCatalog::discover(), appearance: Arc::new(Mutex::new(appearance)), ai: Arc::new(AiSession::default()), database, @@ -749,6 +804,11 @@ speech_worker, directory.join("SpeechModels")), }) .invoke_handler(tauri::generate_handler![ instance::hide_search, + instance::set_search_layout, + instance::drag_search, + search_applications, + launch_application, + set_automatic_indexing, prepare_ai_source, generate_ai, search, diff --git a/apps/desktop/src-tauri/src/settings.rs b/apps/desktop/src-tauri/src/settings.rs index 3b2b56e..87b2e8d 100644 --- a/apps/desktop/src-tauri/src/settings.rs +++ b/apps/desktop/src-tauri/src/settings.rs @@ -23,10 +23,16 @@ fn default_background_opacity() -> u8 { const SETTINGS_VERSION: u32 = 2; const MAX_SETTINGS_BYTES: u64 = 128 * 1024; +fn default_automatic_indexing() -> bool { + true +} + #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct Settings { pub version: u32, + #[serde(default = "default_automatic_indexing")] + pub automatic_indexing: bool, #[serde(default)] pub background_material: BackgroundMaterial, #[serde(default = "default_background_opacity")] @@ -43,6 +49,7 @@ impl Default for Settings { fn default() -> Self { Self { version: SETTINGS_VERSION, + automatic_indexing: true, background_material: BackgroundMaterial::default(), background_opacity: default_background_opacity(), roots: Vec::new(), @@ -57,8 +64,8 @@ impl Settings { if self.version != SETTINGS_VERSION { return Err(format!("Unsupported settings version {}", self.version)); } - if !(70..=100).contains(&self.background_opacity) { - return Err("Background opacity must be between 70 and 100 percent.".into()); + if !(40..=100).contains(&self.background_opacity) { + return Err("Background opacity must be between 40 and 100 percent.".into()); } if !(1..=8).contains(&self.cpu_threads) { return Err("CPU threads must be between 1 and 8.".into()); @@ -265,11 +272,11 @@ mod appearance_tests { assert_eq!(store.value.background_opacity, 90); let mut next = store.value.clone(); next.background_material = BackgroundMaterial::Glass; - next.background_opacity = 70; + next.background_opacity = 40; store.save(next.clone()).unwrap(); assert_eq!(SettingsStore::load(path.clone()).unwrap().value, next); let bytes = fs::read(&path).unwrap(); - for opacity in [0, 69, 101, 255] { + for opacity in [0, 39, 101, 255] { let mut invalid = next.clone(); invalid.background_opacity = opacity; assert!(store.save(invalid).is_err()); diff --git a/apps/desktop/src-tauri/src/watching.rs b/apps/desktop/src-tauri/src/watching.rs index e298b8e..f7f7b69 100644 --- a/apps/desktop/src-tauri/src/watching.rs +++ b/apps/desktop/src-tauri/src/watching.rs @@ -243,10 +243,9 @@ fn validate(roots: &[PathBuf]) -> Result<(), String> { fn excluded(path: &Path, shared: &Shared) -> bool { shared.ignored.iter().any(|p| path.starts_with(p)) || path.components().any(|c| { - matches!( - c.as_os_str().to_str(), - Some(".git" | "node_modules" | "target") - ) + super::automatic_indexing::EXCLUDED_DIRECTORY_NAMES + .iter() + .any(|name| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(name)) }) } fn hint(shared: &Shared, event: notify::Result) { @@ -465,6 +464,51 @@ fn run(shared: Arc, callback: impl Fn(ScanRequest) -> ScanOutcome) { mod tests { use super::*; use std::sync::mpsc; + #[test] + fn excluded_cache_events_do_not_dirty_root_but_documents_do() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().to_owned(); + let mut root = Root::new(Instant::now()); + root.full = false; + root.first = None; + let shared = Shared { + state: Mutex::new(State { + roots: BTreeMap::from([(path.clone(), root)]), + stop: false, + }), + changed: Condvar::new(), + ignored: Vec::new(), + timing: Timing::default(), + cancelled: Arc::new(AtomicBool::new(false)), + }; + for relative in [ + ".cargo/registry/change.bin", + "Cache/change.bin", + "cAcHe/change.bin", + ] { + hint( + &shared, + Ok(Event::new(EventKind::Any).add_path(path.join(relative))), + ); + let state = shared.state.lock().unwrap(); + let root = &state.roots[&path]; + assert!(root.first.is_none()); + assert!(root.paths.is_empty()); + assert!(!root.media_hint); + assert!(!root.full); + } + let document = path.join("Documents/change.txt"); + hint( + &shared, + Ok(Event::new(EventKind::Any).add_path(document.clone())), + ); + let state = shared.state.lock().unwrap(); + let root = &state.roots[&path]; + assert!(root.first.is_some()); + assert_eq!(root.paths, BTreeSet::from([document])); + assert!(root.media_hint); + assert!(!root.full); + } fn fast() -> Timing { Timing { debounce: Duration::from_millis(40), diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index b6ba003..1df9cde 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "searchi", - "version": "0.1.7", + "version": "0.1.8", "identifier": "dev.searchi.desktop", "build": { "beforeDevCommand": "npm run dev", @@ -13,10 +13,15 @@ "windows": [ { "title": "searchi", - "width": 1080, - "height": 760, - "minWidth": 680, - "minHeight": 540 + "width": 720, + "height": 110, + "center": true, + "decorations": false, + "resizable": false, + "transparent": true, + "shadow": true, + "skipTaskbar": true, + "alwaysOnTop": true } ], "security": { diff --git a/apps/desktop/src/appearance-state.ts b/apps/desktop/src/appearance-state.ts index db4d0d4..975ebbb 100644 --- a/apps/desktop/src/appearance-state.ts +++ b/apps/desktop/src/appearance-state.ts @@ -19,7 +19,7 @@ export function appearanceView( const material: BackgroundMaterial = !highContrast && valid ? status.effective_material : "solid"; const opacity = Number.isFinite(status?.opacity) - ? Math.max(70, Math.min(100, status!.opacity)) + ? Math.max(40, Math.min(100, status!.opacity)) : 90; const message = highContrast ? "High contrast uses a solid background. Your appearance preference is retained." diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 1cacaaf..73165d9 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -88,6 +88,7 @@ interface Settings { version: number; roots: string[]; cpu_threads: number; + automatic_indexing?: boolean; background_material?: BackgroundMaterial; background_opacity?: number; } @@ -160,19 +161,86 @@ interface SearchRequest { } document.querySelector("#app")!.innerHTML = ` -
searchi
Loading your index…
${icon("filter")}All files${icon("chevron")}

Filter your search

${icon("settings")}

Settings

Appearance

90%

A subtle window background. Search results and passages stay clear.

Folders & resources

Search automatically uses supported acceleration with CPU fallback. Two or more threads let search run alongside indexing; one thread can delay semantic search during indexing.

    Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.

    Optional AI answers

    Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.

    Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.

    +
    searchi
    Loading your index…
    ${icon("filter")}All files${icon("chevron")}

    Filter your search

    ${icon("settings")}

    Settings

    Appearance

    90%

    A subtle window background. Search results and passages stay clear.

    Folders & resources

    Your local drives are indexed in the background. Existing results remain searchable when this is off.

    Search automatically uses supported acceleration with CPU fallback. Two or more threads let search run alongside indexing; one thread can delay semantic search during indexing.

      Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.

      Optional AI answers

      Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.

      Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.

      ${icon("sparkles")}Preparing local semantic search…Local model${icon("chevron")}
      Google EmbeddingGemma 2

      The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.

      -

      READY WHEN YOU ARE

        ${icon("search")}

        Find what matters. Locally.

        Index a folder, then search a filename, phrase, or what you remember.

        -
        ${icon("lock")}Private search. Everything stays on your device.Local${icon("arrowUp")}${icon("arrowDown")}NavigateEnterOpenEscClear
        `; +

        READY WHEN YOU ARE

          ${icon("search")}

          Find what matters. Locally.

          Your device is indexed in the background. Search an application, filename, phrase, or what you remember.

          +
          ${icon("lock")}Private search. Everything stays on your device.Local${icon("arrowUp")}${icon("arrowDown")}NavigateEnterOpenEsc${isNativeDesktop() ? "Hide" : "Clear"}
          `; const element = (id: string) => document.getElementById(id) as T; const query = element("query"); const list = element("results"); const notice = element("notice"); +// Window sizing never delays a search request. Serialize changes so rapid typing +// cannot leave the native window in an older compact/expanded state. +let requestedExpansion = false; +let nativeExpansion: boolean | undefined; +let resizingLauncher = false; +function syncLauncher() { + let filtered = false; + try { + filtered = hasSearchInput(query.value, captureFilters()); + } catch { + /* Invalid filters stay editable. */ + } + requestedExpansion = + filtered || + document.querySelector(".popover[open]") !== null || + element("semantic-panel").open || + !element("folder-form").hidden; + document.documentElement.dataset.launcherLayout = requestedExpansion + ? "expanded" + : "compact"; + if (isNativeDesktop() && !resizingLauncher) void resizeLauncher(); +} +async function resizeLauncher() { + resizingLauncher = true; + try { + while (nativeExpansion !== requestedExpansion) { + const expanded = requestedExpansion; + await invoke("set_search_layout", { expanded }); + nativeExpansion = expanded; + } + } catch (error) { + showError(error); + } finally { + resizingLauncher = false; + } +} +element("launcher-header").addEventListener("pointerdown", (event) => { + if ( + isNativeDesktop() && + event.button === 0 && + !(event.target as Element).closest("button,summary,input,select,details") + ) { + void invoke("drag_search").catch(showError); + } +}); +element("hide-launcher").addEventListener("click", () => { + if (isNativeDesktop()) void invoke("hide_search").catch(showError); + else { + query.value = ""; + void search(); + query.focus(); + } +}); +element("setup-details").addEventListener("click", () => { + const panel = element("semantic-panel"); + panel.open = !panel.open; +}); +document + .querySelectorAll(".popover, #semantic-panel") + .forEach((panel) => { + panel.addEventListener("toggle", syncLauncher); + }); +new MutationObserver(syncLauncher).observe(element("folder-form"), { + attributes: true, + attributeFilter: ["hidden"], +}); + try { element("performance-toggle").checked = localStorage.getItem("searchi-performance-details") === "true"; @@ -180,6 +248,130 @@ try { /* Diagnostics remain opt-in when storage is unavailable. */ } let results: SearchResult[] = []; +interface ApplicationResult { + id: string; + name: string; +} +let applications: ApplicationResult[] = []; +let selectedApplication = -1; +let applicationRevision = 0; +let settledApplicationRevision = -1; +let applicationQuery = ""; +function selectApplication(index: number, focus = false) { + if (!applications.length) return; + selectedApplication = Math.max(0, Math.min(index, applications.length - 1)); + const app = applications[selectedApplication]; + clearMedia(); + element("preview").hidden = true; + element("application-preview").hidden = false; + element("application-name").textContent = app.name; + list.querySelectorAll("button").forEach((button) => { + button.classList.remove("selected"); + button.setAttribute("aria-current", "false"); + }); + element("application-results") + .querySelectorAll("button") + .forEach((button, i) => { + button.classList.toggle("selected", i === selectedApplication); + button.setAttribute("aria-current", String(i === selectedApplication)); + button.tabIndex = i === selectedApplication ? 0 : -1; + if (focus && i === selectedApplication) button.focus(); + }); +} +async function launchApplication() { + const app = applications[selectedApplication]; + if ( + !app || + !isNativeDesktop() || + applicationQuery !== query.value.trim() || + settledApplicationRevision !== applicationRevision + ) + return; + try { + await invoke("launch_application", { id: app.id }); + await invoke("hide_search"); + } catch (error) { + showError(error); + } +} +async function refreshApplications() { + if (!isNativeDesktop()) return; + const value = query.value.trim(); + const revision = ++applicationRevision; + const changed = value !== applicationQuery; + applicationQuery = value; + if (changed) selectedApplication = -1; + let filtersActive = false; + try { + const filters = captureFilters(); + filtersActive = Boolean(filters.code_only) || hasSearchInput("", filters); + } catch { + filtersActive = true; + } + const found = + value && !filtersActive + ? await invoke("search_applications", { + query: value, + }) + : []; + if (revision !== applicationRevision || query.value.trim() !== value) return; + settledApplicationRevision = revision; + applications = found; + const appList = element("application-results"); + appList.replaceChildren(); + appList.hidden = !found.length; + found.forEach((app, i) => { + const row = document.createElement("li"); + const button = document.createElement("button"); + button.className = "application-result"; + button.type = "button"; + const label = document.createElement("strong"); + label.textContent = app.name; + const kind = document.createElement("span"); + kind.className = "kind"; + kind.textContent = "Application"; + button.append(label, kind); + button.addEventListener("click", () => selectApplication(i)); + button.addEventListener("dblclick", () => { + selectApplication(i); + void launchApplication(); + }); + row.append(button); + appList.append(row); + }); + if (found.length && (changed || selectedApplication >= 0)) + selectApplication(selectedApplication < 0 ? 0 : selectedApplication); + else if (!found.length) { + selectedApplication = -1; + element("application-preview").hidden = true; + } + element("empty").hidden = Boolean(found.length || results.length); + const summary = element("result-summary"); + const fileSummary = (summary.textContent ?? "").replace( + /^\d+ APPLICATIONS? � /, + "", + ); + summary.textContent = found.length + ? `${found.length} APPLICATION${found.length === 1 ? "" : "S"} � ${fileSummary}` + : fileSummary; +} +element("launch-application").addEventListener( + "click", + () => void launchApplication(), +); +element("automatic-indexing").addEventListener("change", async () => { + const checkbox = element("automatic-indexing"); + checkbox.disabled = true; + try { + await invoke("set_automatic_indexing", { enabled: checkbox.checked }); + await refreshDesktopStatus(); + } catch (error) { + showError(error); + } finally { + checkbox.disabled = !isNativeDesktop(); + } +}); + let displayedLiteralQuery = literalQuery(""); let displayedCodeScope = false; let explainedSelection = ""; @@ -318,7 +510,13 @@ const resultRows = new Map< const automaticIndex = new AutomaticIndexGate(); function refreshEmptySearchState() { - if (!latestSearch || latestSearch.id !== requestId || results.length) return; + if ( + !latestSearch || + latestSearch.id !== requestId || + results.length || + applications.length + ) + return; if (!latestSearch.query.trim()) { element("empty").querySelector("h2")!.textContent = "No matching music tags."; @@ -439,7 +637,8 @@ async function refreshSemanticStatus() { else if (semanticAvailable(status) && status.chunks > 0) message = `Semantic search ready · ${status.chunks.toLocaleString()} content segments${status.stale ? " · Updates pending" : ""}${status.media_failures?.length ? ` · ${status.media_failures.length} files could not be indexed` : ""}`; else if (status.ready) - message = "Google model ready. Index a folder to search by meaning."; + message = + "Google model ready. Your files become searchable by meaning as background indexing completes."; else message = "Preparing local semantic search… Keyword search remains available."; @@ -592,8 +791,15 @@ element("background-material").addEventListener("change", () => { void saveAppearance(); }); element("background-opacity").addEventListener("input", () => { - element("background-opacity-value").textContent = - `${element("background-opacity").value}%`; + const opacity = element("background-opacity").value; + element("background-opacity-value").textContent = `${opacity}%`; + // Preview only an already confirmed compositor material. Persist on release. + if (!highContrast.matches && lastAppearance?.native_effect) { + document.documentElement.style.setProperty( + "--window-opacity", + String(Number(opacity) / 100), + ); + } }); element("background-opacity").addEventListener("change", () => { void saveAppearance(); @@ -616,6 +822,9 @@ async function refreshDesktopStatus(restoreAppearanceControls = false) { restoreAppearanceControls, ); indexingActive = status.indexing; + element("automatic-indexing").checked = + status.settings.automatic_indexing ?? true; + element("automatic-indexing").disabled = !isNativeDesktop(); element("shortcut-status").textContent = status.shortcut_status; element("focus-shortcut").textContent = status.shortcut_status.includes( "Super", @@ -671,7 +880,7 @@ async function refreshDesktopStatus(restoreAppearanceControls = false) { } if (status.indexing) element("index-status").textContent = - "Indexing active · Search remains available"; + "Searching your device · Ready to search"; return status; } @@ -885,6 +1094,14 @@ element("ai-citation").addEventListener("click", () => { }); function select(index: number, focus = false, userChoice = true) { if (!results.length) return; + selectedApplication = -1; + element("application-preview").hidden = true; + element("application-results") + .querySelectorAll("button") + .forEach((button) => { + button.classList.remove("selected"); + button.setAttribute("aria-current", "false"); + }); selected = Math.max(0, Math.min(index, results.length - 1)); const result = results[selected]; if (userChoice) resultSelection.choose(result.path); @@ -992,7 +1209,7 @@ function render() { resultRows.delete(key); } } - element("empty").hidden = results.length > 0; + element("empty").hidden = results.length > 0 || applications.length > 0; if (!results.length) { element("preview").hidden = true; previewPath = null; @@ -1120,7 +1337,8 @@ function applyResults( const preserved = resultSelection.resolve( results.map((result) => result.path), ); - if (preserved >= 0) select(preserved, resultsHadFocus, false); + if (preserved >= 0 && selectedApplication < 0) + select(preserved, resultsHadFocus, false); if (!found.length) refreshEmptySearchState(); request.measurement.displayed( lane, @@ -1330,6 +1548,8 @@ function invalidateSearch() { } async function search(inputAt = performance.now()) { + syncLauncher(); + void refreshApplications().catch(showError); invalidateSearch(); querySemanticFailed = false; shownLane = "lexical"; @@ -1348,7 +1568,7 @@ async function search(inputAt = performance.now()) { element("empty").querySelector("h2")!.textContent = "Find what matters. Locally."; element("empty").querySelector("p")!.textContent = - "Index a folder, then search a filename, phrase, or what you remember."; + "Your device is indexed in the background. Search an application, filename, phrase, or what you remember."; return; } resultSelection.begin(JSON.stringify([value, filters])); @@ -1384,6 +1604,13 @@ async function refreshStats() { } query.addEventListener("input", () => { + ++applicationRevision; + selectedApplication = -1; + applications = []; + element("application-results").replaceChildren(); + element("application-results").hidden = true; + element("application-preview").hidden = true; + syncLauncher(); const inputAt = performance.now(); invalidateSearch(); timer = setTimeout(() => void search(inputAt), 60); @@ -1579,7 +1806,35 @@ document.addEventListener("keydown", (event) => { void search(); query.focus(); } - if (event.target === query || list.contains(event.target as Node)) { + if ( + event.target === query || + list.contains(event.target as Node) || + element("application-results").contains(event.target as Node) + ) { + if (event.key === "Enter" && selectedApplication >= 0) { + event.preventDefault(); + void launchApplication(); + return; + } + if ( + (event.key === "ArrowDown" || event.key === "ArrowUp") && + applications.length + ) { + event.preventDefault(); + const step = event.key === "ArrowDown" ? 1 : -1; + const position = + selectedApplication >= 0 + ? selectedApplication + : applications.length + selected; + const next = Math.max( + 0, + Math.min(position + step, applications.length + results.length - 1), + ); + if (next < applications.length) selectApplication(next); + else select(next - applications.length); + query.focus(); + return; + } if ( event.key === "Enter" && results[selected] && @@ -1590,12 +1845,15 @@ document.addEventListener("keydown", (event) => { } if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); - select(selected + (event.key === "ArrowDown" ? 1 : -1), true); + select(selected + (event.key === "ArrowDown" ? 1 : -1)); + query.focus(); } } }); async function initializeSurface() { await initializeDesktopSurface(); + document.documentElement.dataset.nativeLauncher = String(isNativeDesktop()); + syncLauncher(); renderAppearance(); if (isNativeDesktop()) { appearanceUpdates.submit(highContrast.matches); diff --git a/apps/desktop/src/style.css b/apps/desktop/src/style.css index 5d26540..7016c9e 100644 --- a/apps/desktop/src/style.css +++ b/apps/desktop/src/style.css @@ -37,6 +37,7 @@ * { box-sizing: border-box; } +/* The resident launcher starts as a search bar and expands for useful content. */ body { margin: 0; } @@ -401,7 +402,7 @@ summary::-webkit-details-marker { overflow: hidden; background: var(--surface); } -.workspace:has(aside[hidden]) { +.workspace:has(#preview[hidden]):has(#application-preview[hidden]) { grid-template-columns: 1fr; } .results-pane { @@ -960,12 +961,7 @@ aside pre.code-passage { background: rgba(248, 250, 252, var(--window-opacity, 0.9)); } :root[data-background-material="frosted"] #app { - background: rgba( - 248, - 250, - 252, - calc(0.35 + var(--window-opacity, 0.9) * 0.65) - ); + background: rgba(248, 250, 252, var(--window-opacity, 0.9)); } .appearance-settings { padding-bottom: 16px; @@ -1008,12 +1004,7 @@ aside pre.code-passage { background: rgba(11, 15, 20, var(--window-opacity, 0.9)); } :root[data-background-material="frosted"] #app { - background: rgba( - 11, - 15, - 20, - calc(0.35 + var(--window-opacity, 0.9) * 0.65) - ); + background: rgba(11, 15, 20, var(--window-opacity, 0.9)); } } @media (forced-colors: active) { @@ -1032,3 +1023,185 @@ aside pre.code-passage { > summary { --muted: var(--ink); } +:root { + background: transparent; +} +#app { + min-height: 110px; + border: 1px solid var(--line); + border-radius: 18px; + background: var(--canvas); + overflow: hidden; +} +header { + padding: 6px 15px 4px; + min-height: 38px; + gap: 12px; +} +.brand-symbol { + width: 20px; + height: 20px; +} +.wordmark { + font-size: 14px; + letter-spacing: -0.3px; +} +.index-status { + border: 0; + padding: 0; + font-size: 11px; +} +.header-actions { + gap: 3px; +} +.header-actions summary, +.header-actions .icon-button { + padding: 5px; +} +.header-actions .icon svg { + width: 17px; + height: 17px; +} +main { + padding: 0 10px; +} +#search-form { + min-height: 60px; + border: 0; + border-radius: 11px; + box-shadow: none; + padding: 10px 13px; +} +#search-form:focus-within { + box-shadow: none; +} +#query { + font-size: 21px; +} +#focus-shortcut { + opacity: 0.7; +} +.workspace { + border-radius: 12px; + margin-bottom: 5px; +} +.workspace:has(#preview[hidden]):has(#application-preview[hidden]) { + grid-template-columns: 1fr; +} +.workspace:has(#application-preview:not([hidden])) { + grid-template-columns: minmax(0, 0.96fr) minmax(0, 1.04fr); +} +footer { + min-height: 38px; + padding: 8px 16px; +} +.popover-panel { + max-height: min(560px, 75dvh); + overflow: auto; +} +#application-results { + list-style: none; + margin: 0; + padding: 6px; + border-bottom: 1px solid var(--line); + flex-shrink: 0; + max-height: 45%; + overflow-y: auto; +} +.application-result { + display: flex; + justify-content: space-between; + width: 100%; + padding: 14px; + border: 0; + background: transparent; + text-align: left; +} +.application-result.selected { + background: var(--selected); +} +.application-detail { + padding: 48px 24px; + text-align: center; +} +.application-detail h2 { + font-size: 25px; + margin-top: 20px; +} +:root[data-launcher-layout="compact"] .workspace, +:root[data-launcher-layout="compact"] footer, +:root[data-launcher-layout="compact"] #semantic-panel, +:root[data-launcher-layout="compact"] #notice, +:root[data-launcher-layout="compact"] #performance-panel { + display: none; +} +:root[data-launcher-layout="expanded"] .workspace { + animation: launcher-reveal 140ms ease-out; +} +@keyframes launcher-reveal { + from { + opacity: 0.6; + transform: translateY(4px); + } + to { + opacity: 1; + transform: none; + } +} +:root[data-native-launcher="false"] #app { + max-width: 1080px; + margin: 0 auto; +} +:root[data-native-launcher="false"][data-launcher-layout="compact"] #app { + max-width: 720px; + height: 110px; + margin-top: min(20dvh, 120px); + box-shadow: var(--shadow); +} +@media (prefers-reduced-motion: reduce) { + :root[data-launcher-layout] .workspace { + animation: none; + } +} +@media (forced-colors: active) { + #app { + border: 1px solid CanvasText; + background: Canvas; + } +} + +/* At the lowest shell opacity, local reading tints still protect text against + an opposite-color desktop. The outer shell retains its selected opacity. */ +:root[data-background-material="glass"] #search-form, +:root[data-background-material="frosted"] #search-form, +:root[data-background-material="glass"] .header-actions, +:root[data-background-material="frosted"] .header-actions, +:root[data-background-material="glass"] .brand, +:root[data-background-material="frosted"] .brand, +:root[data-background-material="glass"] .index-status, +:root[data-background-material="frosted"] .index-status, +:root[data-background-material="glass"] footer, +:root[data-background-material="frosted"] footer { + background: rgba(248, 250, 252, 0.6); + border-radius: 8px; + --muted: var(--ink); +} +@media (prefers-color-scheme: dark) { + :root[data-background-material="glass"] #search-form, + :root[data-background-material="frosted"] #search-form, + :root[data-background-material="glass"] .header-actions, + :root[data-background-material="frosted"] .header-actions, + :root[data-background-material="glass"] .brand, + :root[data-background-material="frosted"] .brand, + :root[data-background-material="glass"] .index-status, + :root[data-background-material="frosted"] .index-status, + :root[data-background-material="glass"] footer, + :root[data-background-material="frosted"] footer { + background: rgba(11, 15, 20, 0.6); + } +} +@media (forced-colors: active) { + :root[data-background-material] #search-form { + background: Canvas; + } +} diff --git a/apps/desktop/tests/appearance-state.test.mjs b/apps/desktop/tests/appearance-state.test.mjs index ca1a12d..1808cd7 100644 --- a/apps/desktop/tests/appearance-state.test.mjs +++ b/apps/desktop/tests/appearance-state.test.mjs @@ -18,7 +18,7 @@ test('high contrast stays opaque without losing the preference', () => { test('fallback is honest and shell tint is bounded', () => { const fallback = {...glass(), effective_material:'solid', native_effect:false, fallback_reason:'Native effects unavailable.'}; assert.match(appearanceView(fallback, false).message, /Solid background/); - assert.equal(appearanceView({...glass(), opacity:5}, false).opacity, 70); + assert.equal(appearanceView({...glass(), opacity:5}, false).opacity, 40); assert.equal(appearanceView({...glass(), opacity:110}, false).opacity, 100); assert.equal(appearanceView({...glass(), opacity:NaN}, false).opacity, 90); }); diff --git a/crates/searchi-core/src/lib.rs b/crates/searchi-core/src/lib.rs index 99c124b..4184c75 100644 --- a/crates/searchi-core/src/lib.rs +++ b/crates/searchi-core/src/lib.rs @@ -645,6 +645,7 @@ impl SearchIndex { } Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => break, Err(error) => return Err(error).context("Inspect targeted path ancestor"), } } @@ -684,6 +685,11 @@ impl SearchIndex { check_index_cancelled(cancelled)?; match fs::symlink_metadata(scope) { Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + preserve_unreadable_scope(&transaction, &root_key, scope)?; + report.skipped += 1; + continue; + } Err(error) => return Err(error).context("Inspect reconciliation scope"), Ok(metadata) if metadata.file_type().is_symlink() => { bail!("Reconciliation scope became a symlink") @@ -731,8 +737,26 @@ impl SearchIndex { }) { check_index_cancelled(cancelled)?; - let entry = - entry.context("directory traversal failed; reconciliation rolled back")?; + let entry = match entry { + Ok(entry) => entry, + Err(error) + if error.io_error().is_some_and(|error| { + error.kind() == std::io::ErrorKind::PermissionDenied + }) && error.path().is_some() => + { + preserve_unreadable_scope( + &transaction, + &root_key, + error.path().expect("Checked traversal error path"), + )?; + report.skipped += 1; + continue; + } + Err(error) => { + return Err(error) + .context("directory traversal failed; reconciliation rolled back") + } + }; if !entry.file_type().is_file() { continue; } @@ -751,7 +775,18 @@ impl SearchIndex { let filename = entry.file_name().to_string_lossy().into_owned(); transaction.execute("INSERT INTO seen_paths(path) VALUES (?1)", [&path])?; report.discovered += 1; - let metadata = entry.metadata()?; + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(error) + if error.io_error().is_some_and(|error| { + error.kind() == std::io::ErrorKind::PermissionDenied + }) => + { + report.skipped += 1; + continue; + } + Err(error) => return Err(error.into()), + }; let size = i64::try_from(metadata.len()).context("file exceeds supported size")?; let mtime = searchi_media::unix_time_ns(metadata.modified()?)?; let extension = entry @@ -771,6 +806,10 @@ impl SearchIndex { // metadata is unchanged. This detects same-stat overwrites while // unsupported media/giant files retain the metadata-only fast path. let input = extraction::read_source(entry.path()); + if input.as_ref().is_err_and(permission_denied) { + report.skipped += 1; + continue; + } // A parser rejection may retain a filename-only record, but a // filesystem read failure cannot certify this reconciliation. if input @@ -781,7 +820,14 @@ impl SearchIndex { .with_context(|| format!("read {path}; reconciliation rolled back")); } if matches!(&input, Ok(Some(_))) { - let after = fs::metadata(entry.path())?; + let after = match fs::metadata(entry.path()) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + report.skipped += 1; + continue; + } + Err(error) => return Err(error.into()), + }; if after.len() != metadata.len() || after.modified()? != metadata.modified()? { bail!("Source changed during extraction read; retry indexing: {path}; reconciliation rolled back"); } @@ -840,7 +886,15 @@ impl SearchIndex { check_index_cancelled(cancelled)?; if matches!(extension.as_str(), "pdf" | "docx") { if let Some(hash) = &source_hash { - extraction::verify_document_snapshot(entry.path(), hash, &metadata)?; + if let Err(error) = + extraction::verify_document_snapshot(entry.path(), hash, &metadata) + { + if permission_denied(&error) { + report.skipped += 1; + continue; + } + return Err(error); + } } } if extracted.is_none() { @@ -1536,6 +1590,32 @@ fn substring_anchor(characters: &[char], frequencies: &[(i64, String)]) -> Strin characters[start..end].iter().collect() } +fn permission_denied(error: &anyhow::Error) -> bool { + error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|error| error.kind() == std::io::ErrorKind::PermissionDenied) + }) +} + +/// A denied walk is not proof of deletion. Keep prior records in exactly this +/// subtree without updating their fingerprints or certifying their freshness. +fn preserve_unreadable_scope( + transaction: &rusqlite::Transaction<'_>, + root: &str, + scope: &Path, +) -> Result<()> { + let key = scope.to_string_lossy(); + let prefix = format!("{}{}", key, std::path::MAIN_SEPARATOR); + transaction.execute( + "INSERT OR IGNORE INTO seen_paths(path) + SELECT path FROM files WHERE root=?1 + AND (?4 OR path=?2 OR substr(path,1,length(?3))=?3)", + params![root, key, prefix, key == root], + )?; + Ok(()) +} + fn check_index_cancelled(cancelled: Option<&AtomicBool>) -> Result<()> { if cancelled.is_some_and(|flag| flag.load(Ordering::Acquire)) { bail!("Index reconciliation cancelled; transaction rolled back"); @@ -1560,6 +1640,63 @@ fn validate_normalized_absolute(path: &Path) -> Result<()> { #[cfg(test)] mod tests { + #[test] + fn denied_scope_preserves_only_existing_records_with_component_boundary() -> anyhow::Result<()> + { + use super::*; + let directory = tempfile::tempdir()?; + let root = directory.path().canonicalize()?; + let denied = root.join("private"); + let sibling = root.join("private-other"); + fs::create_dir(&denied)?; + fs::create_dir(&sibling)?; + fs::write(denied.join("kept.txt"), "original exact source")?; + fs::write(sibling.join("removed.txt"), "sibling")?; + let mut index = SearchIndex::open(":memory:")?; + index.index_folder(&root)?; + let root_key = root.to_string_lossy().into_owned(); + let transaction = index.connection.transaction()?; + transaction.execute_batch("CREATE TEMP TABLE IF NOT EXISTS seen_paths(path TEXT PRIMARY KEY); DELETE FROM seen_paths;")?; + preserve_unreadable_scope(&transaction, &root_key, &denied)?; + let preserved: Vec = transaction + .prepare("SELECT path FROM seen_paths ORDER BY path")? + .query_map([], |row| row.get(0))? + .collect::>()?; + assert_eq!( + preserved, + vec![denied.join("kept.txt").to_string_lossy().into_owned()] + ); + assert_eq!(transaction.execute("DELETE FROM files WHERE root=?1 AND NOT EXISTS (SELECT 1 FROM seen_paths WHERE seen_paths.path=files.path)", [&root_key])?, 1); + let content: String = + transaction.query_row("SELECT content FROM files", [], |row| row.get(0))?; + assert_eq!(content, "original exact source"); + transaction.execute("DELETE FROM seen_paths", [])?; + preserve_unreadable_scope(&transaction, &root_key, &root)?; + assert_eq!( + transaction.query_row("SELECT count(*) FROM seen_paths", [], |row| row + .get::<_, i64>(0))?, + 1 + ); + transaction.commit()?; + Ok(()) + } + + #[test] + fn permission_skip_does_not_swallow_other_io_errors() { + let denied = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied)) + .context("source read"); + assert!(super::permission_denied(&denied)); + for kind in [ + std::io::ErrorKind::NotFound, + std::io::ErrorKind::UnexpectedEof, + std::io::ErrorKind::Interrupted, + ] { + assert!(!super::permission_denied(&anyhow::Error::new( + std::io::Error::from(kind) + ))); + } + } + #[test] fn legacy_metadata_migration_preserves_keyword_search_without_inventing_fields( ) -> anyhow::Result<()> { diff --git a/crates/searchi-service/src/lib.rs b/crates/searchi-service/src/lib.rs index 41c594e..a7e94cb 100644 --- a/crates/searchi-service/src/lib.rs +++ b/crates/searchi-service/src/lib.rs @@ -11,6 +11,7 @@ pub use speech::{speech_policy_key, BudgetedSpeechProvider, SpeechExcerpt, Speec mod prepared; pub use prepared::PreparedGraphs; mod pipeline; +mod relevance; mod routing; pub use routing::SemanticOrigin; mod store; @@ -280,6 +281,12 @@ pub struct HybridSearch { } impl HybridSearch { + /// Prepare storage before background discovery starts. No model is loaded. + pub fn prepare_database(db: impl AsRef) -> Result<()> { + let _ = SearchIndex::open(&db)?; + let _ = Store::open(db.as_ref(), GEMMA_DIMENSIONS)?; + Ok(()) + } /// Attach shared admission without replacing the warmed model or query LRU. pub fn with_budget( mut self, @@ -485,7 +492,11 @@ impl HybridSearch { /// Execute real query inference and load the active ANN generation before /// foreground use. Call on a background worker after assets are verified. pub fn warmup(&mut self) -> Result<()> { - self.lexical.prewarm()?; + if let Err(error) = self.lexical.prewarm() { + if !is_busy_sqlite_error(&error) { + return Err(error); + } + } let vector = self.embedder.embed_query("warm up local file search")?; let _ = self.store.search( &vector, @@ -580,8 +591,14 @@ impl HybridSearch { promote_identifiers(query.trim(), &mut lexical) }); let vector = self.query_vector(query, options.code_only, trace, parent)?; - let semantic = + let mut semantic = self.routed_semantic_traced(query, &vector, candidates, options, trace, parent)?; + trace.timed("hybrid.semantic_shortlist", parent, |trace, span| { + trace.counter(span, "candidates_before", semantic.len() as u64); + let removed = relevance::prune_semantic_tail(&mut semantic); + trace.counter(span, "tail_candidates_pruned", removed as u64); + trace.counter(span, "candidates_after", semantic.len() as u64); + }); let results = trace.timed("hybrid.rank_fusion", parent, |trace, span| { trace.counter(span, "lexical_files", lexical.len() as u64); trace.counter(span, "semantic_files", semantic.len() as u64); @@ -684,6 +701,11 @@ impl HybridSearch { } } +fn is_busy_sqlite_error(error: &anyhow::Error) -> bool { + matches!(error.downcast_ref::(), Some(rusqlite::Error::SqliteFailure(error, _)) + if matches!(error.code, rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked)) +} + fn bounded_title(title: &str) -> &str { let mut end = title.len().min(512); while !title.is_char_boundary(end) { @@ -995,3 +1017,62 @@ mod tests { #[cfg(test)] mod coverage_tests; + +#[cfg(test)] +mod startup_lock_tests { + use super::*; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + struct QueryProbe(Arc); + impl EmbeddingProvider for QueryProbe { + fn fingerprint(&self) -> &str { + "query-path-probe" + } + fn dimensions(&self) -> usize { + GEMMA_DIMENSIONS + } + fn split_windows(&mut self, _: &str, _: &str) -> Result> { + anyhow::bail!("unused") + } + fn embed_documents(&mut self, _: &[&str], _: &str) -> Result>> { + anyhow::bail!("unused") + } + fn embed_query(&mut self, _: &str) -> Result> { + self.0.fetch_add(1, Ordering::SeqCst); + anyhow::bail!("Reached query inference; no fabricated vector") + } + } + #[test] + fn optional_lexical_cache_busy_does_not_abort_query_preparation() -> Result<()> { + let temporary = tempfile::tempdir()?; + let db = temporary.path().join("index.db"); + HybridSearch::prepare_database(&db)?; + let mut writer = rusqlite::Connection::open(&db)?; + let transaction = + writer.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let calls = Arc::new(AtomicUsize::new(0)); + let mut service = HybridSearch::with_provider(&db, Box::new(QueryProbe(calls.clone())))?; + assert!(service + .warmup() + .unwrap_err() + .to_string() + .contains("Reached query inference")); + assert_eq!(calls.load(Ordering::SeqCst), 1); + transaction.rollback()?; + Ok(()) + } + #[test] + fn non_busy_sqlite_cache_errors_remain_visible() -> Result<()> { + let temporary = tempfile::tempdir()?; + let db = temporary.path().join("index.db"); + HybridSearch::prepare_database(&db)?; + let calls = Arc::new(AtomicUsize::new(0)); + let mut service = HybridSearch::with_provider(&db, Box::new(QueryProbe(calls.clone())))?; + rusqlite::Connection::open(&db)?.execute("DROP TABLE name_statistics_state", [])?; + assert!(service.warmup().is_err()); + assert_eq!(calls.load(Ordering::SeqCst), 0); + Ok(()) + } +} diff --git a/crates/searchi-service/src/relevance.rs b/crates/searchi-service/src/relevance.rs new file mode 100644 index 0000000..7d6e3be --- /dev/null +++ b/crates/searchi-service/src/relevance.rs @@ -0,0 +1,101 @@ +//! Hybrid result shortlisting, not a confidence or no-answer classifier. +use searchi_core::SearchResult; + +// Rounded upward from the largest adjacent gap (0.0650497) observed in the +// eight frozen calibration absence requests. A flat/noisy list stays intact; +// the strongest semantic candidate and every independent lexical match remain +// accessible. Raw semantic diagnostics are deliberately not changed. +const MIN_CLEAR_GAP: f64 = 0.07; +const NUMERIC_TIE: f64 = 2.0 * f32::EPSILON as f64; + +/// Keep the strongest semantic cluster only when one clear score gap separates +/// it from the tail. Inputs are already source-checked, filtered and file-deduped. +pub(super) fn prune_semantic_tail(results: &mut Vec) -> usize { + if results.len() < 2 + || results.iter().any(|result| !result.score.is_finite()) + || results.windows(2).any(|pair| pair[0].score < pair[1].score) + { + return 0; + } + let mut largest = 0.0; + let mut second = 0.0; + let mut boundary = results.len(); + for (index, pair) in results.windows(2).enumerate() { + let gap = pair[0].score - pair[1].score; + if gap > largest { + second = largest; + largest = gap; + boundary = index + 1; + } else if gap > second { + second = gap; + } + } + // Equal large gaps indicate no unique cluster boundary, e.g. a uniform + // staircase. Do not pick an arbitrary split due to f32 rounding. + if largest <= MIN_CLEAR_GAP || largest - second <= NUMERIC_TIE { + return 0; + } + let removed = results.len() - boundary; + results.truncate(boundary); + removed +} + +#[cfg(test)] +mod tests { + use super::*; + fn candidates(scores: &[f64]) -> Vec { + scores + .iter() + .enumerate() + .map(|(index, score)| SearchResult { + path: format!("candidate-{index}"), + filename: format!("candidate-{index}"), + score: *score, + snippet: String::new(), + match_kind: "semantic".into(), + source: None, + extraction_error: None, + }) + .collect() + } + #[test] + fn weak_correct_best_and_multiple_strong_candidates_survive() { + let mut weak = candidates(&[0.6766, 0.50, 0.49]); + assert_eq!(prune_semantic_tail(&mut weak), 2); + assert_eq!(weak[0].score, 0.6766); + let mut multiple = candidates(&[0.82, 0.79, 0.57, 0.55]); + assert_eq!(prune_semantic_tail(&mut multiple), 2); + assert_eq!(multiple.len(), 2); + } + #[test] + fn small_flat_and_ambiguous_lists_are_preserved() { + for scores in [ + &[][..], + &[0.55], + &[0.61, 0.60], + &[0.65, 0.64, 0.63], + &[0.9, 0.8, 0.7, 0.6], + &[0.61, 0.61001, 0.60], + &[0.60, 0.599999, 0.599998], + ] { + let mut results = candidates(scores); + assert_eq!(prune_semantic_tail(&mut results), 0, "{scores:?}"); + assert_eq!(results.len(), scores.len()); + } + let mut two = candidates(&[0.82, 0.52]); + assert_eq!(prune_semantic_tail(&mut two), 1); + assert_eq!(two.len(), 1); + } + #[test] + fn independent_exact_lexical_match_survives_semantic_tail_pruning() { + let mut semantic = candidates(&[0.82, 0.52]); + let mut exact = candidates(&[0.52]).remove(0); + exact.path = "candidate-1".into(); + exact.match_kind = "exact_filename".into(); + assert_eq!(prune_semantic_tail(&mut semantic), 1); + let results = crate::fuse(vec![exact], semantic, 10); + assert_eq!(results.len(), 2); + assert_eq!(results[0].path, "candidate-1"); + assert_eq!(results[0].match_kind, "exact_filename"); + } +} diff --git a/crates/searchi-service/src/routing.rs b/crates/searchi-service/src/routing.rs index df6307a..14a694a 100644 --- a/crates/searchi-service/src/routing.rs +++ b/crates/searchi-service/src/routing.rs @@ -24,7 +24,7 @@ impl SemanticOrigin { } /// A conservative intent hint, not a language understanding model. Explicit -/// document/visual/code cues veto it; ordinary topic and instrument queries do not +/// document/code cues veto it; ordinary topic and instrument queries do not /// silently restrict modality. Word boundaries avoid filename substrings. fn preferred_origin(query: &str) -> Option { if query.split_whitespace().count() == 1 && Path::new(query).extension().is_some() { @@ -37,18 +37,10 @@ fn preferred_origin(query: &str) -> Option { .collect(); let has = |values: &[&str]| words.iter().any(|word| values.contains(word)); if has(&[ - "image", - "photo", - "picture", - "screenshot", - "diagram", "document", "pdf", "text", "paper", - "bild", - "foto", - "screenshot", "dokument", "textdatei", "code", @@ -57,9 +49,33 @@ fn preferred_origin(query: &str) -> Option { "quellcode", "funktion", "implementierung", + "algorithm", + "algorithms", + "algorithmus", + "algorithmen", ]) { return None; } + // Explicit visual requests should not be answered by unrelated audio or + // document neighbors. Check these before speech/audio words: a photo of a + // speaker and a screenshot of a waveform still request visual evidence. + let visual = has(&[ + "image", + "photo", + "picture", + "screenshot", + "diagram", + "bild", + "foto", + "fotografie", + ]); + let video = has(&["video", "movie", "clip", "film"]); + if visual && video { + return None; + } + if visual { + return Some(SemanticOrigin::Image); + } if has(&[ "spoken", "speech", @@ -86,7 +102,6 @@ fn preferred_origin(query: &str) -> Option { if has(&[ "audio", "recording", - "sound", "music", "geräusch", "geraeusch", @@ -98,9 +113,13 @@ fn preferred_origin(query: &str) -> Option { "hoerbarer", "hoerbare", "tonaufnahme", - ]) { + ]) || (has(&["sound"]) && has(&["of", "effect", "effects"])) + { return Some(SemanticOrigin::Audio); } + if video { + return Some(SemanticOrigin::Video); + } None } @@ -130,7 +149,7 @@ impl HybridSearch { } else { preferred_origin(query) } { - trace.counter(parent, "audio_preference", 1); + trace.counter(parent, "explicit_source_channel", 1); let preferred = self.store.search_origin_traced( vector, &self.generation_fingerprint, @@ -157,6 +176,12 @@ impl HybridSearch { return Ok(raw); } } + // An empty requested channel is useful evidence. Falling back + // to every source type turns an absent recording/photo into an + // unrelated result; lexical filename/content hits remain in + // the independently fused lane. + trace.counter(parent, "requested_channel_empty", 1); + return Ok(Vec::new()); } trace.counter(parent, "general_lane", 1); self.store.search_origin_traced( @@ -226,7 +251,7 @@ mod tests { } #[test] - fn explicit_spoken_intent_is_conservative_and_bilingual() { + fn explicit_media_intent_is_conservative_and_bilingual() { for query in [ "where someone mentions a contract", "a speaker says the date", @@ -234,6 +259,7 @@ mod tests { "jemand erwähnt das Budget", "Wo spricht jemand über Kündigung?", "Gespräch über die neue Wohnung", + "video where someone mentions a contract", ] { assert_eq!( preferred_origin(query), @@ -241,6 +267,29 @@ mod tests { "{query}" ); } + for query in [ + "photo of someone speaking", + "Bild von einem Sprecher", + "a screenshot of an audio waveform", + "Foto mit Bergen", + ] { + assert_eq!( + preferred_origin(query), + Some(SemanticOrigin::Image), + "{query}" + ); + } + for query in [ + "video of a boat", + "Film mit einem Wasserfall", + "a movie with a blue car", + ] { + assert_eq!( + preferred_origin(query), + Some(SemanticOrigin::Video), + "{query}" + ); + } for query in [ "audio of a waterfall", "hörbarer Regen", @@ -256,17 +305,19 @@ mod tests { for query in [ "contract deadline", "violin solo", - "photo of someone speaking", - "Bild von einem Sprecher", "a document that someone mentioned", "speech.png", - "a screenshot of an audio waveform", "code that chooses audio search when a query mentions speech", "an implementation for identifying music", "a function that decodes audio samples", "Quellcode für die Suche nach gesprochenen Themen", "eine Funktion für die Verarbeitung von Musik", "Implementierung für die Erkennung von Geräuschen", + "photo or video of a boat", + "Bild oder Film vom Wasserfall", + "image compression algorithm", + "Algorithmus zur Bildkomprimierung", + "sound financial advice", ] { assert_eq!(preferred_origin(query), None, "{query}"); } diff --git a/crates/searchi-service/src/store.rs b/crates/searchi-service/src/store.rs index 6b6419b..0825ecf 100644 --- a/crates/searchi-service/src/store.rs +++ b/crates/searchi-service/src/store.rs @@ -18,6 +18,99 @@ use usearch::{Index, IndexOptions, MetricKind, ScalarKind}; pub(crate) const MAX_SCAN_CHUNKS: usize = 100_000; pub(crate) const MIN_COSINE: f32 = 0.25; +const SCHEMA_VERSION: i64 = 9; + +fn current_schema_ready(connection: &Connection) -> Result { + const TABLES: &[(&str, &[&str])] = &[ + ( + "semantic_meta", + &[ + "schema_version", + "active_generation", + "corpus_revision", + "dirty_floor", + ], + ), + ( + "semantic_generations", + &[ + "id", + "fingerprint", + "dimensions", + "corpus_revision", + "ann_blob", + "ann_sha256", + "incremental", + ], + ), + ( + "semantic_chunks", + &[ + "generation", + "passage_id", + "ordinal", + "path", + "kind", + "number", + "content_hash", + "start_byte", + "end_byte", + "snippet", + "vector", + "source_type", + "file_size", + "file_mtime", + "speech_end_ms", + "task_key", + "file_change_revision", + ], + ), + ("semantic_dirty_paths", &["path", "revision"]), + ("semantic_media_errors", &["generation", "path", "message"]), + ( + "semantic_media_metadata_revision", + &["singleton", "revision"], + ), + ( + "semantic_tasks", + &[ + "generation", + "task_key", + "path", + "source_type", + "source_hash", + "title_hash", + "policy", + "passage_id", + "state", + "attempts", + "expected_chunks", + "message", + ], + ), + ]; + for (table, required) in TABLES { + let mut query = connection.prepare("SELECT name FROM pragma_table_info(?1)")?; + let columns = query + .query_map([table], |row| row.get::<_, String>(0))? + .collect::>>()?; + if required.iter().any(|name| !columns.contains(*name)) { + return Ok(false); + } + } + let triggers: i64 = connection.query_row( + "SELECT count(*) FROM sqlite_schema WHERE type='trigger' AND name IN ('semantic_files_insert','semantic_files_update','semantic_files_delete','semantic_media_metadata_insert','semantic_media_metadata_update','semantic_media_metadata_delete')", [], |row| row.get(0), + )?; + let indexes: i64 = connection.query_row( + "SELECT count(*) FROM sqlite_schema WHERE type='index' AND name IN ('semantic_dirty_revision','semantic_chunk_path','semantic_task_reuse','semantic_chunk_task')", [], |row| row.get(0), + )?; + let metadata_row: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM semantic_media_metadata_revision WHERE singleton=1)", + [], + |row| row.get(0), + )?; + Ok(triggers == 6 && indexes == 4 && metadata_row) +} // Disjoint negative namespaces preserve existing document/raw-media IDs without a table rebuild. pub(crate) fn media_passage_id(file_id: i64, source_type: &str) -> Result { @@ -122,24 +215,46 @@ impl Store { let mut connection = Connection::open(db)?; connection.busy_timeout(std::time::Duration::from_secs(5))?; connection.execute_batch("PRAGMA foreign_keys=ON;")?; - let initialization = - connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; - initialization.execute_batch( + let has_meta: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='semantic_meta')", + [], |row| row.get(0), + )?; + let current_version = if has_meta { + let version: i64 = connection.query_row( + "SELECT schema_version FROM semantic_meta WHERE singleton=1", + [], + |row| row.get(0), + )?; + ensure!( + (1..=SCHEMA_VERSION).contains(&version), + "Unsupported semantic schema version {version}" + ); + Some(version) + } else { + None + }; + // WAL readers must not acquire a writer lock just to reopen an already + // prepared index. Some earlier schema-9 builds added columns without a + // version bump, so incomplete legacy shapes still run normal migration. + if current_version != Some(SCHEMA_VERSION) || !current_schema_ready(&connection)? { + let initialization = + connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + initialization.execute_batch( "CREATE TABLE IF NOT EXISTS semantic_meta ( singleton INTEGER PRIMARY KEY CHECK(singleton=1), schema_version INTEGER NOT NULL, active_generation INTEGER, corpus_revision INTEGER NOT NULL DEFAULT 0, dirty_floor INTEGER NOT NULL DEFAULT 0); INSERT OR IGNORE INTO semantic_meta(singleton,schema_version) VALUES(1,5);", )?; - let version: i64 = initialization.query_row( - "SELECT schema_version FROM semantic_meta WHERE singleton=1", - [], - |row| row.get(0), - )?; - ensure!( - (1..=9).contains(&version), - "Unsupported semantic schema version {version}" - ); - initialization.execute_batch(" + let version: i64 = initialization.query_row( + "SELECT schema_version FROM semantic_meta WHERE singleton=1", + [], + |row| row.get(0), + )?; + ensure!( + (1..=9).contains(&version), + "Unsupported semantic schema version {version}" + ); + initialization.execute_batch(" CREATE TABLE IF NOT EXISTS semantic_generations(id INTEGER PRIMARY KEY, fingerprint TEXT NOT NULL, dimensions INTEGER NOT NULL, corpus_revision INTEGER NOT NULL DEFAULT 0, ann_blob BLOB, ann_sha256 TEXT); CREATE TABLE IF NOT EXISTS semantic_chunks( generation INTEGER NOT NULL REFERENCES semantic_generations(id) ON DELETE CASCADE, @@ -148,8 +263,8 @@ impl Store { content_hash TEXT NOT NULL, start_byte INTEGER NOT NULL, end_byte INTEGER NOT NULL, snippet TEXT NOT NULL, vector BLOB NOT NULL, PRIMARY KEY(generation,passage_id,ordinal));")?; - if version < 5 { - for (table,column,alter) in [ + if version < 5 { + for (table,column,alter) in [ ("semantic_meta","corpus_revision","ALTER TABLE semantic_meta ADD COLUMN corpus_revision INTEGER NOT NULL DEFAULT 0"), ("semantic_meta","dirty_floor","ALTER TABLE semantic_meta ADD COLUMN dirty_floor INTEGER NOT NULL DEFAULT 0"), ("semantic_generations","dimensions","ALTER TABLE semantic_generations ADD COLUMN dimensions INTEGER NOT NULL DEFAULT 384"), @@ -160,10 +275,10 @@ impl Store { let exists:bool=initialization.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info(?1) WHERE name=?2)",params![table,column],|row| row.get(0))?; if !exists { initialization.execute_batch(alter)?; } } - initialization.execute_batch("DROP TRIGGER IF EXISTS semantic_files_insert; + initialization.execute_batch("DROP TRIGGER IF EXISTS semantic_files_insert; DROP TRIGGER IF EXISTS semantic_files_update; DROP TRIGGER IF EXISTS semantic_files_delete;")?; - } - initialization.execute_batch( + } + initialization.execute_batch( "CREATE TABLE IF NOT EXISTS semantic_dirty_paths(path TEXT PRIMARY KEY, revision INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS semantic_dirty_revision ON semantic_dirty_paths(revision); CREATE INDEX IF NOT EXISTS semantic_chunk_path ON semantic_chunks(generation,path); @@ -183,45 +298,50 @@ impl Store { ON CONFLICT(path) DO UPDATE SET revision=excluded.revision; END; UPDATE semantic_meta SET schema_version=6 WHERE singleton=1;", )?; - for (column, declaration) in [ - ("source_type", "TEXT NOT NULL DEFAULT 'text'"), - ("file_size", "INTEGER"), - ("file_mtime", "INTEGER"), - ] { - let exists: bool = initialization.query_row( + for (column, declaration) in [ + ("source_type", "TEXT NOT NULL DEFAULT 'text'"), + ("file_size", "INTEGER"), + ("file_mtime", "INTEGER"), + ] { + let exists: bool = initialization.query_row( "SELECT EXISTS(SELECT 1 FROM pragma_table_info('semantic_chunks') WHERE name=?1)", [column], |r| r.get(0), )?; - if !exists { - initialization.execute_batch(&format!( - "ALTER TABLE semantic_chunks ADD COLUMN {column} {declaration}" - ))?; + if !exists { + initialization.execute_batch(&format!( + "ALTER TABLE semantic_chunks ADD COLUMN {column} {declaration}" + ))?; + } } - } - initialization.execute_batch("CREATE TABLE IF NOT EXISTS semantic_media_errors(generation INTEGER NOT NULL REFERENCES semantic_generations(id) ON DELETE CASCADE,path TEXT NOT NULL,message TEXT NOT NULL,PRIMARY KEY(generation,path))")?; - let has_speech_end:bool=initialization.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info('semantic_chunks') WHERE name='speech_end_ms')",[],|row|row.get(0))?; - if !has_speech_end { - initialization.execute( - "ALTER TABLE semantic_chunks ADD COLUMN speech_end_ms INTEGER", - [], - )?; - } - // Probe publication changes filter eligibility, not source discovery identity. - // Keep its epoch separate so indexing does not invalidate its own corpus snapshot. - initialization.execute_batch("CREATE TABLE IF NOT EXISTS semantic_media_metadata_revision(singleton INTEGER PRIMARY KEY CHECK(singleton=1), revision INTEGER NOT NULL); + initialization.execute_batch("CREATE TABLE IF NOT EXISTS semantic_media_errors(generation INTEGER NOT NULL REFERENCES semantic_generations(id) ON DELETE CASCADE,path TEXT NOT NULL,message TEXT NOT NULL,PRIMARY KEY(generation,path))")?; + let has_speech_end:bool=initialization.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info('semantic_chunks') WHERE name='speech_end_ms')",[],|row|row.get(0))?; + if !has_speech_end { + initialization.execute( + "ALTER TABLE semantic_chunks ADD COLUMN speech_end_ms INTEGER", + [], + )?; + } + // Probe publication changes filter eligibility, not source discovery identity. + // Keep its epoch separate so indexing does not invalidate its own corpus snapshot. + initialization.execute_batch("CREATE TABLE IF NOT EXISTS semantic_media_metadata_revision(singleton INTEGER PRIMARY KEY CHECK(singleton=1), revision INTEGER NOT NULL); INSERT OR IGNORE INTO semantic_media_metadata_revision(singleton,revision) VALUES(1,0);")?; - for (name, event) in [ - ("insert", "INSERT"), - ("update", "UPDATE"), - ("delete", "DELETE"), - ] { - initialization.execute_batch(&format!("DROP TRIGGER IF EXISTS semantic_media_metadata_{name}; + for (name, event) in [ + ("insert", "INSERT"), + ("update", "UPDATE"), + ("delete", "DELETE"), + ] { + initialization.execute_batch(&format!("DROP TRIGGER IF EXISTS semantic_media_metadata_{name}; CREATE TRIGGER semantic_media_metadata_{name} AFTER {event} ON media_metadata BEGIN UPDATE semantic_media_metadata_revision SET revision=revision+1 WHERE singleton=1; END;"))?; + } + incremental::migrate(&initialization)?; + initialization.commit()?; + ensure!( + current_schema_ready(&connection)?, + "Semantic schema initialization incomplete" + ); } - incremental::migrate(&initialization)?; - initialization.commit()?; let canonical = db.canonicalize()?; let lock_path = PathBuf::from(format!("{}.semantic.lock", canonical.to_string_lossy())); Ok(Self { @@ -2068,3 +2188,59 @@ mod tests { #[cfg(test)] #[path = "ann_benchmark.rs"] mod ann_benchmark; + +#[cfg(test)] +mod reopen_tests { + use super::*; + #[test] + fn initialized_wal_store_reopens_during_an_owned_write_transaction() -> Result<()> { + let temporary = tempfile::tempdir()?; + let db = temporary.path().join("index.db"); + crate::HybridSearch::prepare_database(&db)?; + let mut writer = Connection::open(&db)?; + let transaction = + writer.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + transaction.execute( + "UPDATE semantic_meta SET corpus_revision=corpus_revision+1 WHERE singleton=1", + [], + )?; + let started = std::time::Instant::now(); + let reader = Store::open(&db, 768)?; + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + assert_eq!( + reader.connection.query_row( + "SELECT corpus_revision FROM semantic_meta WHERE singleton=1", + [], + |row| row.get::<_, i64>(0) + )?, + 0 + ); + transaction.rollback()?; + Ok(()) + } + #[test] + fn legacy_schema_nine_additions_are_repaired_and_future_versions_rejected() -> Result<()> { + let temporary = tempfile::tempdir()?; + let db = temporary.path().join("index.db"); + crate::HybridSearch::prepare_database(&db)?; + let connection = Connection::open(&db)?; + connection.execute_batch("ALTER TABLE semantic_chunks DROP COLUMN speech_end_ms; DROP TRIGGER semantic_media_metadata_update;")?; + assert!(!current_schema_ready(&connection)?); + drop(Store::open(&db, 768)?); + assert!(current_schema_ready(&connection)?); + connection.execute("UPDATE semantic_meta SET schema_version=10", [])?; + let error = match Store::open(&db, 768) { + Ok(_) => panic!("future schema accepted"), + Err(error) => error, + }; + assert!(error + .to_string() + .contains("Unsupported semantic schema version 10")); + assert_eq!( + connection.query_row("SELECT schema_version FROM semantic_meta", [], |row| row + .get::<_, i64>(0))?, + 10 + ); + Ok(()) + } +} diff --git a/crates/searchi-service/tests/relevance_google_cpu.rs b/crates/searchi-service/tests/relevance_google_cpu.rs new file mode 100644 index 0000000..6100e55 --- /dev/null +++ b/crates/searchi-service/tests/relevance_google_cpu.rs @@ -0,0 +1,122 @@ +//! Actual query retrieval against the owned, verified README fixture index. +use anyhow::{Context, Result}; +use searchi_service::{HybridSearch, SearchOptions}; +use std::path::PathBuf; + +fn required(name: &str) -> Result { + std::env::var_os(name) + .map(PathBuf::from) + .with_context(|| format!("Set {name}")) +} + +#[test] +#[ignore = "Requires verified owned README fixture database and actual Google CPU model/runtime"] +fn real_google_hybrid_shortlist_removes_unrelated_media_without_changing_raw_search() -> Result<()> +{ + assert_eq!(std::env::var("SEARCHI_FORCE_CPU")?, "1"); + let db = required("SEARCHI_TEST_RELEVANCE_DATABASE")?; + let model = required("SEARCHI_GEMMA_MODEL_DIR")?; + let worker = required("SEARCHI_GEMMA_WORKER")?; + let mut search = HybridSearch::open_query(db, model, worker, 1)?; + let options = SearchOptions::default(); + let contract = "cancel the supplier partnership"; + let raw = search.search_semantic(contract, 50, &options)?; + assert_eq!(raw[0].filename, "Supplier Agreement.pdf"); + assert!(raw + .iter() + .any(|result| result.filename == "Mountain Lake.jpg")); + let measured = search.search_measured(contract, 50, &options, true)?; + assert_eq!(measured.results[0].filename, "Supplier Agreement.pdf"); + assert!(measured.results.iter().all( + |result| result.filename != "Mountain Lake.jpg" && result.filename != "Night Pier.jpg" + )); + assert!(measured + .metrics + .trace + .as_ref() + .context("debug trace")? + .iter() + .any(|span| span.name == "hybrid.semantic_shortlist" + && span + .counters + .get("tail_candidates_pruned") + .is_some_and(|count| *count > 0))); + let raw_after = search.search_semantic(contract, 50, &options)?; + assert_eq!(raw_after.len(), raw.len()); + assert_eq!(raw_after[0].score, raw[0].score); + let image = search.search("mountains reflected in calm water", 50, &options)?; + assert_eq!(image[0].filename, "Mountain Lake.jpg"); + assert!(image + .iter() + .all(|result| result.match_kind == "semantic_image")); + assert_eq!( + search.search("Mountain Lake.jpg", 1, &options)?[0].match_kind, + "exact_filename" + ); + let pdf = SearchOptions { + extension: Some("pdf".into()), + ..Default::default() + }; + assert!(search + .search(contract, 50, &pdf)? + .iter() + .all(|result| result.filename == "Supplier Agreement.pdf")); + assert_eq!( + search.search(contract, 1, &options)?[0].filename, + "Supplier Agreement.pdf" + ); + let excluded = SearchOptions { + max_size: Some(1), + ..Default::default() + }; + assert!(search.search(contract, 50, &excluded)?.is_empty()); + eprintln!( + "actual raw contract candidates={}, hybrid={}, image={}", + raw.len(), + measured.results.len(), + image.len() + ); + Ok(()) +} + +#[test] +#[ignore = "Requires the owned verified README fixture database and real Google CPU runtime"] +fn real_google_query_warms_during_an_owned_wal_writer() -> Result<()> { + assert_eq!(std::env::var("SEARCHI_FORCE_CPU")?, "1"); + let db = required("SEARCHI_TEST_RELEVANCE_DATABASE")?; + let model = required("SEARCHI_GEMMA_MODEL_DIR")?; + let worker = required("SEARCHI_GEMMA_WORKER")?; + let mut writer = rusqlite::Connection::open(&db)?; + let old_dirty: i64 = writer.query_row( + "SELECT dirty FROM name_statistics_state WHERE singleton=1", + [], + |row| row.get(0), + )?; + writer.execute( + "UPDATE name_statistics_state SET dirty=1 WHERE singleton=1", + [], + )?; + let transaction = writer.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let outcome = (|| -> Result<()> { + let mut search = HybridSearch::open_query(&db, model, worker, 1)?; + search.warmup()?; + let hits = search.search_semantic( + "cancel the supplier partnership", + 1, + &SearchOptions::default(), + )?; + assert_eq!(hits[0].filename, "Supplier Agreement.pdf"); + assert_eq!( + search.worker_info().context("real worker info")?.device, + "cpu" + ); + eprintln!("Actual Google CPU query initialization, warmup and prepared retrieval succeeded with the WAL writer held"); + Ok(()) + })(); + transaction.rollback()?; + writer.execute( + "UPDATE name_statistics_state SET dirty=?1 WHERE singleton=1", + [old_dirty], + )?; + outcome +} diff --git a/crates/searchi-service/tests/routing_google_cpu.rs b/crates/searchi-service/tests/routing_google_cpu.rs index 60059d0..c6b86d3 100644 --- a/crates/searchi-service/tests/routing_google_cpu.rs +++ b/crates/searchi-service/tests/routing_google_cpu.rs @@ -3,6 +3,69 @@ use searchi_core::SearchIndex; use searchi_service::{FfmpegTools, HybridSearch, MediaLimits, SearchOptions, SemanticOrigin}; use std::{fs, path::PathBuf}; +#[test] +#[ignore = "Requires verified Google CPU worker/model and FFmpeg fixtures"] +fn explicit_media_requests_do_not_fall_back_to_unrelated_channels() -> Result<()> { + assert_eq!(std::env::var("SEARCHI_FORCE_CPU")?, "1"); + let model = std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Set model directory"); + let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Set worker path"); + let tools = FfmpegTools { + ffmpeg: std::env::var_os("SEARCHI_TEST_FFMPEG") + .expect("Set FFmpeg") + .into(), + ffprobe: std::env::var_os("SEARCHI_TEST_FFPROBE") + .expect("Set FFprobe") + .into(), + }; + let fixture = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/media/assets/image01.jpg"); + let temp = tempfile::tempdir()?; + let corpus = temp.path().join("corpus"); + fs::create_dir(&corpus)?; + fs::copy(fixture, corpus.join("cat.jpg"))?; + let database = temp.path().join("index.db"); + let mut lexical = SearchIndex::open(&database)?; + lexical.index_folder(&corpus)?; + let mut indexer = HybridSearch::open(&database, &model, &worker, 1)?; + let report = indexer.index_embeddings_with_media(&tools, &MediaLimits::default())?; + assert!(report.failures.is_empty(), "{report:?}"); + assert_eq!(report.media_files, 1); + drop(indexer); + let mut search = HybridSearch::open_query(&database, &model, &worker, 1)?; + let options = SearchOptions::default(); + // The unfiltered real model does return the nearest photo for these requests. + // The correction rejects its wrong channel, not its similarity score. + for query in [ + "music with violin", + "audio of thunder", + "someone mentions a contract", + "video of a boat", + "Musik mit Klavier", + "Film mit einem Wasserfall", + ] { + assert!( + !search + .search_semantic_with_origin(query, 10, &options, SemanticOrigin::All)? + .is_empty(), + "{query}" + ); + assert!( + search.search_semantic(query, 10, &options)?.is_empty(), + "{query}" + ); + } + for query in ["photo of a cat indoors", "Bild einer Katze", "cat indoors"] { + let hits = search.search_semantic(query, 10, &options)?; + assert_eq!(hits[0].filename, "cat.jpg", "{query}"); + assert_eq!(hits[0].match_kind, "semantic_image", "{query}"); + } + assert_eq!( + search.search("cat.jpg", 1, &options)?[0].match_kind, + "exact_filename" + ); + Ok(()) +} + #[test] #[ignore = "Requires verified Google CPU worker/model and FFmpeg fixtures"] fn explicit_code_intent_preserves_code_among_real_audio_candidates() -> Result<()> { diff --git a/docs/APPLICATION-SEARCH.md b/docs/APPLICATION-SEARCH.md new file mode 100644 index 0000000..522033b --- /dev/null +++ b/docs/APPLICATION-SEARCH.md @@ -0,0 +1,26 @@ +# Local application search + +Type an application's name in the desktop search window and open its application +result. Matching ignores case and spaces; `VS Code` and `vscode` also match +Visual Studio Code. Application search uses a small local catalog and works +without downloading an embedding model. + +Windows reads user/common Start Menu shortcuts. Registered Microsoft Store +applications are added in the background using the operating system's +`Get-StartApps` inventory, with a five-second timeout. The initial window and +searches do not wait for that inventory. Linux reads visible XDG `.desktop` +applications and launches them using GIO (`gio launch`); a missing GIO launcher +produces an explicit error. macOS discovery covers the standard Applications +directories; actual macOS execution remains untested. + +The catalog is refreshed when searchi starts. Restart it after installing an +application if it has not appeared. Launch commands accept only opaque IDs +already in this local catalog. Executable paths and command lines are never +accepted from the webview; Linux desktop-entry parsing is delegated to GIO. + +The Windows development machine's actual read-only inventory test passed: +92 Start Menu shortcuts and 25 registered applications. All four focused module +tests passed, including VS Code aliases, hidden desktop entries, rejected +registered-ID command text, and results that do not disclose launch paths. +This inventory check launches nothing; native application launch/keyboard +interaction must be verified separately in the final desktop check. diff --git a/docs/BRAND.md b/docs/BRAND.md index 6647ceb..3d42859 100644 --- a/docs/BRAND.md +++ b/docs/BRAND.md @@ -48,13 +48,15 @@ copy into unsupported performance or production-readiness claims. ## Window materials Appearance settings offer Solid, Glass and Frosted glass, with a bounded -70–100% tint-strength control (90% by default). Frosted glass layers an extra -35% milk tint beneath that control; Glass has the lighter direct tint. Both +40–100% background-opacity control (90% by default). Both materials use the selected opacity directly, without a second milk-tint +layer. The slider previews changes while dragging. Both currently share Windows native acrylic blur. Native compositor effects expose and soften the actual desktop; CSS transparency alone cannot blur other windows. If the platform cannot apply its native effect, use an honest opaque fallback. Forced colors always make the shell opaque while retaining the saved preference. -Search input, results, previews and settings keep their opaque reading surfaces; +The search input and shell labels use a local 60% reading tint, so the lowest +shell setting still yields at least 76% theme tint under text. Results, previews +and settings keep opaque reading surfaces; never lower text opacity or introduce decorative wallpaper simulations. Native acrylic currently requires Windows 11 22H2 or later with a compatible compositor and system transparency settings. macOS, Linux and older Windows diff --git a/docs/FLOATING-LAUNCHER.md b/docs/FLOATING-LAUNCHER.md new file mode 100644 index 0000000..f56bbf5 --- /dev/null +++ b/docs/FLOATING-LAUNCHER.md @@ -0,0 +1,53 @@ +# Floating search launcher + +The desktop opens as a centered search bar rather than a conventional document +window. The empty launcher requests 720 × 110 logical pixels. Typing or opening +settings, filters, local-model details or folder controls expands it to +1080 × 700. Native sizing stays inside the current monitor's work area, including +its scale factor and taskbar. A small display receives a smaller window. + +The window is frameless, above ordinary windows, and omitted from the taskbar. +Its header can be dragged; the dedicated hide button, Escape and the existing +tray/global shortcut retain the resident workflow. The rounded shell uses the +configured native glass/frosted material where supported and the existing +opaque/high-contrast fallback otherwise. No CSS blur is presented as native +desktop transparency. Brief content transitions respect reduced motion and do +not defer search requests. + +Installed applications have a separate list above file matches and a simple +launch preview. Arrow keys move across applications and files while preserving +typing focus; Enter launches the selected application or opens the selected +file. Application identities are opaque backend identifiers, not pretend file +paths or invented passages. Filters keep application suggestions out of scoped +file searches. Application requests are local lexical lookups and do not wait +for semantic inference. + +Input immediately invalidates the previous application selection. A launch +requires the current query and the settled request revision, so a late response +or an immediate Enter after editing cannot launch an older suggestion. File +search timing remains separately measured; the result summary includes visible +application counts, and application-only matches suppress the empty-file state. + +Automatic local-drive indexing is enabled by default. Its settings checkbox can +disable future automatic work while retaining searchable indexed content; +additional folder controls live inside settings. Existing active work is not +claimed to be instantly cancelled by the checkbox. Model progress and retry +remain available through local-search setup details. + +## Validation recorded so far + +- Frontend TypeScript/Vite build passed on desktop version 0.1.8. +- All 62 existing frontend tests passed; Prettier checks passed. +- Final Windows desktop checks passed 56 tests (one optional actual-catalog + check ignored); strict all-target Clippy passed. Linux passed 56 desktop tests. +- The background opacity control spans 40–100%, previews while dragging, and + applies directly to both glass materials. Local 60% reading tints protect + input, labels and controls; result and preview panes remain opaque. +- Twelve CPU browser composition checks passed across both themes, both glass + materials and 40/90/100% opacity. This verifies actual built CSS alpha, + independently of native compositor validation. + +These checks alone do not establish native pixels, dragging, tray interaction, +rounded compositor corners or application launching. Actual final-build UI +capture and keyboard interaction belong in +[the native validation report](NATIVE-FINAL-VALIDATION.md). diff --git a/docs/LINUX-DESKTOP.md b/docs/LINUX-DESKTOP.md index 92dfe88..2c0f928 100644 --- a/docs/LINUX-DESKTOP.md +++ b/docs/LINUX-DESKTOP.md @@ -1,9 +1,13 @@ # Linux desktop release build evidence -Current download: [Ubuntu 24.04 x64 desktop 0.1.4](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.4/searchi_0.1.4_amd64.deb). -See [current package checks](LINUX-0.1.4.md) and -[checksums](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.4/LINUX-DESKTOP-SHA256SUMS-0.1.4.txt). -The evidence below records the preceding 0.1.3 preparation. +Current download: [Ubuntu 24.04 x64 desktop 0.1.8](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.8/searchi_0.1.8_amd64.deb). +See [final package checks](NATIVE-FINAL-VALIDATION.md) and +[checksum](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.8/searchi_0.1.8_amd64.deb.sha256). +The current package includes the floating launcher, automatic local discovery, +application search and hybrid relevance cleanup. Native compilation, 56 desktop +tests, runtime provenance, loader checks and packaged CPU startup passed on +Ubuntu 24.04 WSL. Native graphical interaction and other distributions remain +unverified. The historical evidence below records preceding releases. Source: `a6bf091`, desktop 0.1.3, Ubuntu 24.04 WSL x86-64, kernel 6.18.54.1-microsoft-standard-WSL2, two virtual CPU cores (AMD Ryzen 7 7800X3D). diff --git a/docs/MEDIA-FINAL-QUALITY.md b/docs/MEDIA-FINAL-QUALITY.md new file mode 100644 index 0000000..fc87e23 --- /dev/null +++ b/docs/MEDIA-FINAL-QUALITY.md @@ -0,0 +1,136 @@ +# Bounded media relevance improvement, 2026-10-14 + +This pass keeps Google EmbeddingGemma 2 and the existing shared engine. It +corrects one practical source of irrelevant results without another model, +reindexing, an absolute similarity cutoff, or a new ranking framework. + +## Hybrid shortlist correction after the real screenshot review + +The real screenshot exposed an additional defect: reciprocal-rank fusion gave +every semantic nearest neighbour a result rank even when its raw similarity +was far below a strong match. A mountain photo consequently appeared beneath +the supplier agreement for a cancellation query. + +Hybrid search now keeps the strongest semantic cluster when the filtered, +file-deduplicated score list has one clearly separated tail: choose the largest +adjacent raw cosine gap, and prune after it only when that gap is **greater than +0.07**. Equal largest gaps within f32 rounding retain the complete list. The +0.07 gap is rounded upward from the maximum 0.0650497 observed among eight +previously frozen calibration absence requests; it is a relative decluttering +rule, **not an absolute cosine threshold or a confidence percentage**. +The [frozen policy](../tools/relevance-eval/policies/hybrid-tail-v1.json) records +the exact scope and calibration report hash. + +All independent lexical matches, exact filename/identifier priority and the +best semantic candidate survive. Explicit raw semantic search is unchanged. +Filters and source checks still run before candidate limits; no index or model +is regenerated. Debug traces report `hybrid.semantic_shortlist` with before, +after and pruned counts. + +Actual current Google CPU query results on the verified README fixture: + +| Query | Raw semantic scores | Hybrid result | +| --- | --- | --- | +| `cancel the supplier partnership` | Agreement 0.820912; onboarding 0.660648; procurement 0.626666; mountain photo 0.528253 | Agreement retained; unrelated photos removed; 7 → 1 results | +| `mountains reflected in calm water` | Lake 0.753141; pier 0.684482; travel document 0.537812 | Two image results retained; unrelated documents removed | + +The genuine query-role CPU integration passed in 7.61 seconds, covering raw API +preservation, actual PDF/photo retrieval, exact filename priority, PDF and size +filters, and limit one. Its ignored log `.searchi/qa/hybrid-tail-google-cpu.log` +has SHA-256 `bc920ba56bc5b4f89bb5ee8831420bf5c14964d2da5decb0b392482e12309d4e`. +The raw pre-change requests are preserved in +`.searchi/qa/readme-raw-relevance.json`, SHA-256 +`4a017d8fee0d3762a806628e38a848ca9ca9cb052e92c1892f639efda3fde62d`. + +A replay of existing genuine raw-score reports retained every previously +retrieved relevant top-five file in calibration, the previously evaluated +held-out report and the 61 ranked media-reference rows. Mean semantic +candidate counts changed from 11.6 to 5.0, 11.6 to 4.2 and 14.75 to 3.59, +respectively. This is score replay on known diagnostics, not newly independent +inference or a production relevance guarantee. The historical media reference +contains its documented audio preprocessing failure; no failed encoding is +silently credited as a positive. Forty text/code positive rows retain their +top semantic candidate by construction; **all 20 absence rows still retain a +neighbour**. The policy does not solve absence classification. + +Three focused shortlist unit tests passed (weak-but-correct 0.6766, multiple +strong matches, flat/uniform/tied lists, one/two candidates and independent exact +lexical preservation). The full service library passed 45 tests with its two +explicitly provisioned/benchmark tests ignored; strict all-target Clippy passed. + +## What changed + +Explicit photo/image requests use image evidence; explicit video/film requests +use video-frame evidence. Spoken-topic requests retain speech-first retrieval +and raw-audio fallback while recognition is unavailable. Explicit music/audio +requests use actual audio evidence. If the requested channel is empty, semantic +search now returns empty instead of selecting an unrelated photo/document. +The independent exact filename/keyword lane remains available in hybrid search. + +Ordinary subject queries stay general. Document/code/algorithm cues veto these +small intent hints. A mixed `photo or video` request stays general rather than +silently losing videos. `sound financial advice` is not an audio request. +The hints use word boundaries and English/German cues, not a claim of natural +language understanding. Debug traces report `explicit_source_channel` and +`requested_channel_empty`. + +## Actual Google CPU evidence + +The isolated integration fixture contains the existing licensed, SHA-locked +cat photo `fixtures/media/assets/image01.jpg`, copied into a temporary owned +index. It uses the normal native Google runtime with `SEARCHI_FORCE_CPU=1`, +one inference thread, actual image encoding, persisted ANN retrieval and the +query-role worker. No fabricated vectors or labels enter inference. + +| Check | Unrestricted real-model channel | Corrected automatic routing | +| --- | --- | --- | +| Six requests for absent audio/music/speech/video channels, EN/DE | 6/6 returned the unrelated photo | 0/6 returned a semantic result | +| Three photo/general cat requests, EN/DE | Actual image indexed | 3/3 retained the correct photo | +| Exact `cat.jpg` | Independent lexical lane | Exact filename retained | + +`explicit_media_requests_do_not_fall_back_to_unrelated_channels` passed in +82.63 seconds including indexing, model loading and all assertions. This is +functional correctness evidence, not a search-latency benchmark or a large +collection quality score. Raw log: ignored `.searchi/qa/media-final-routing.log`, +SHA-256 `14d452192315fb1ddfd0b0df1448ffc475876af57a767546b36f68f17771b83d`. + +The existing actual Google code-versus-licensed-audio regression also passed +in 81.72 seconds: an explicit code query retained its indexed implementation +while real audio remained an eligible independent channel. Its ignored log +`.searchi/qa/media-final-code-veto.log` has SHA-256 +`04d8f42ed8e872f22607c2fe4fb45e0d5149d4b42539b3346abd18ebb224e137`. +All three focused routing unit tests passed after the mixed-channel/algorithm/ +sound-adjective fixes, including the English/German ambiguity cases. + +Reproduce with the verified Google/model and FFmpeg environment described in +SKILLS.md: + +```powershell +$env:SEARCHI_FORCE_CPU = '1' +cargo test -p searchi-service --test routing_google_cpu explicit_media_requests_do_not_fall_back --locked -- --ignored --nocapture --test-threads=1 +cargo test -p searchi-service --lib routing::tests --locked +``` + +## Limits and music decision + +These changes reject wrong channels and trim clearly separated weak tails; +they are not calibrated detection that a particular +subject is absent within a populated channel. An indexed audio collection can +still return an unrelated nearest performance for an absent musical instrument; +photos/videos have the same limitation within their own channel. Speech without +recognized passages retains raw audio fallback. Do not advertise a confidence +percentage or perfect no-answer detection. + +Existing exact artist/title metadata filters remain the reliable identity path. +The previous independently frozen music comparison improved 8/14 to 11/14 first +hits using literal embedded tag fields, but regressed one positive, fell to 5/14 +with misleading tags, and failed all four no-answer cases. That unconditional +field-max policy remains unintegrated: it does not justify shipping a universal +music-recognition improvement. See MUSIC-METADATA.md and +fixtures/music-validation-v1/README.md. This pass adds no hosted music lookup, +fingerprinting service, generated labels, or extra model downloads. + +Existing image evidence and video scene sampling are retained. Spoken timing +and recovery are recorded separately in SPEECH-FINAL-QUALITY.md. Media remains +usable with explicit limits; this bounded pass does not invent a new overall +quality rating or extend the agreed release scope. diff --git a/docs/NATIVE-FINAL-VALIDATION.md b/docs/NATIVE-FINAL-VALIDATION.md new file mode 100644 index 0000000..3959087 --- /dev/null +++ b/docs/NATIVE-FINAL-VALIDATION.md @@ -0,0 +1,320 @@ +# Final native interaction and reinstall validation + +Renewed bounded goal, 2026-10-14: verify Windows/Linux interaction and the +Windows uninstall/reinstall path, alongside a modest media relevance improvement. +No intermediate release or push is permitted. This report distinguishes actual +native observations from process, source and automated frontend checks. + +Current delivery is 0.1.8. See the final startup-writer correction and corrected +Windows/Linux package sections below for authoritative hashes and checks. +Earlier local candidates are retained as historical prechecks and were not +published. Final native capture/input remains unavailable; process startup, +accessibility observation and software screenshots do not replace that gate. + +## Native access inventory + +The approved Computer Use `@oai/sky` client initialized successfully. Unlike the +previous handoff, native Windows screenshot and accessibility capture are now +available. `list_windows` initially omitted the hidden resident application. +The existing CPU-only local launcher brought its existing window forward; +the returned native window was then captured through the approved API. + +Observed installed Windows 0.1.7: process 29000 responding; native searchi +window, dark theme, Add folder, filters, settings, query field and readable +empty-state interface. Its accessibility tree reports zero files and +“Google model ready. Index a folder to search by meaning.” CPU status is visible. +This is actual native observation, not a search or keyboard interaction pass. + +The capture also showed an unrelated foreground Windows Security firewall +permission dialog for Electron. No input was attempted while it blocked the +workspace. The Computer Use guidance explicitly says: “Do not act on security +or privacy permission requests.” The human must dismiss that unrelated prompt +before dependent native interaction continues. The agent did not change any +security setting or bypass the prompt. + +Ubuntu 24.04 WSL starts successfully and advertises `DISPLAY=:0` and +`WAYLAND_DISPLAY=wayland-0`. Both WSLg X11 and Wayland sockets exist. Existing +relocated Linux desktop validation binaries remain available. The current +approved UI API only targets Windows applications; no Linux input/capture API +has been exposed. WSLg availability alone does not prove native Linux input. + +## Windows reinstall test procedure + +User authorized uninstalling/reinstalling the Windows dev installation. Root +agent coordinates execution after backing up the existing user state. + +1. Preserve external settings/index/model cache and local CPU wrapper/startup + shortcuts; save installer and installed file hashes. +2. Stop only searchi and its owned workers; wait for process exit. +3. Run installed `C:/Users/karl/Applications/searchi/uninstall.exe` silently. + Record exit code and verify executable/resources and installer registration + are removed. Record any remaining owned directory or shortcut separately. +4. Confirm external user settings/index/model cache survive unchanged. +5. Install the verified normal hardware-detecting package to the same location. + Record exit code, registration, installed executable/runtime hashes and + bundled extraction/media/speech resource verification. +6. Start with an isolated empty test profile under CPU-only VM flags to exercise + fresh settings/index creation. Existing downloaded assets may be reused; + this must not be described as a fresh operating-system or first-download test. +7. Restore the user profile and CPU launcher/startup shortcuts. Leave the final + installed application running and accessible. + +## Executed uninstall/reinstall checks + +Root executed the existing verified Windows 0.1.7 uninstall and reinstall. +Both processes returned exit code zero. The installed main executable was absent +after uninstall; external settings/index/model assets survived. Reinstall restored +the executable with its expected hash. Raw local records are +`.searchi/qa/final-uninstall.json` and `.searchi/qa/final-reinstall.json`; +the preserved state is in `.searchi/qa/windows-final-reinstall-backup`. + +An actual new empty profile at `.searchi/qa/fresh-reinstall-profile` was launched +without seeded settings or a copied model cache, using forced CPU inference and +software WebView flags. The approved native API returned its searchi window. +Initial monitor capture failed with `CreateForMonitor` error `0x80070057`; fresh +window selection and the single documented retry succeeded. The native +accessibility observation showed zero files and “Preparing Google semantic +search on your device…” with CPU status. This establishes installed empty-profile +startup, not completed model downloading or search readiness. No user interaction +was attempted while the unrelated security permission prompt remained pending. + +This is uninstall/reinstall on the existing dev OS, not a newly installed Windows +system. Final updated package validation and model setup remain separate checks. + +## Native Windows search interaction + +After a fresh approved capture showed that the unrelated permission dialog was +gone, native input was used on the isolated profile. Add folder opened the inline +folder controls. Entering the owned `.searchi/qa/native-final-corpus` directory +and clicking Index folder indexed three files with two extracted text documents. +The app displayed its indexing progress while keeping keyword search available. + +Setting the query to `supplier agreement` returned the correct document and +line-one passage. The native screenshot showed highlighted query words in the +result, filename and preview. The compact Why this result detail stayed collapsed. +Observed tiny-fixture timing was search 40 ms / shown 293 ms; this is one native +functional observation, not a latency benchmark. Ctrl+K visibly selected the +query text, confirming actual keyboard focus behavior. + +The next `type_text` operation failed to activate the captured window. After +refreshing the same returned window, monitor capture failed again with +`0x80070057`; input stopped rather than proceeding blindly. No semantic-query, +global-hotkey, tray or Enter-open success is inferred from those partial checks. +Read-only accessibility recovery subsequently succeeded and confirmed selected +query text. The single accessibility input retry returned `GetCursorPos failed: +Access denied (0x80070005)`. Native input is therefore currently blocked by the +interactive-session permission state; no shell input or capture workaround was +attempted. + +## Native acceptance checklist + +- [x] Native Windows screenshot/accessibility capture of the installed app. +- [x] Native Add folder/index action, keyword query, highlighted passage preview + and Ctrl+K query selection on the isolated owned corpus. +- [ ] Search query typing, arrow selection, passage/media preview and Enter open. +- [ ] Settings/theme/transparency interaction and readable preview. +- [ ] Escape and title-bar close hide; Ctrl+Shift+Space restores query focus. +- [ ] Tray Show/Quit and background/login startup behavior. +- [x] Existing Windows 0.1.7 uninstall/reinstall and fresh empty-profile startup. +- [ ] Final updated package uninstall/reinstall and completed fresh-profile setup. +- [ ] Native Linux keyboard/search/preview interaction through a supported UI API. + +Unchecked items are pending, not implied by compilation or unit tests. + +## Whole-disk permission handling + +The shared scanner now skips specifically permission-denied scopes, directory +walk entries and file reads/metadata/publication checks, increments its skipped +count and retains existing records in the affected subtree. It does not update +their source fingerprints or certify their freshness. Other filesystem failures +still abort and roll back reconciliation. Exact path boundaries protect sibling +directories from accidental preservation, including a whole-root special case. + +Two focused helper regressions cover preserved content/subtree boundaries and +classification of other I/O failures. Windows core unit checks passed 27 tests +with one separately built-worker test ignored, using serial execution. The first +concurrent run hit an existing extraction-worker deadline; the serial rerun +passed that test. Strict core library Clippy passed. These are structural checks; +no personal folders or operating-system ACLs were modified to fabricate a denied +directory fixture. + +## Final Linux 0.1.8 local package + +The exact final uncommitted workspace source plus both new Rust modules and the +built frontend were frozen into a 668-file snapshot before building. Snapshot +SHA-256: `bf864935e2f630d56836246e6c6a217f1d7313f4498c4fc19c3bac4d6296996d`. +Current extraction helper was rebuilt and its source provenance staged. Normal +GPU-capable Google and speech runtimes were verified; speech adds 29 resource +records. Native desktop release compilation passed on Ubuntu 24.04 WSL in +3m47s; the known vendored GLib lifetime warnings remain dependency warnings. + +The initial local Debian precheck package was +`.searchi/packages/searchi_0.1.8_amd64.deb`, 177,695,882 bytes, SHA-256 +`39bc495e65d7ab8c1592636b76aeade25de9fa56d5b139fc6f479fbcb9de0ee6`. +No intermediate package was published. Raw build records: +`.searchi/qa/linux-final-018-build.json` and its build log. + +The extracted package started on real WSLg X11 display `:0`, with CPU inference, +Mesa software rendering and an owned isolated profile. Automatic disk indexing +was explicitly disabled for this bounded test. `ldd` reported no missing desktop +dependencies; packaged speech resource verification passed. Its bundled genuine +Google worker was observed with `--device cpu --role query --threads 1`, and the +desktop remained alive. The approved Windows API returned the WSLg-hosted native +window titled `searchi (Ubuntu-24.04)`, proving it can be selected as a target. +Raw startup record: `.searchi/qa/linux-final-018-startup.json`. + +Compilation, loader checks and target inventory do not by themselves establish +Linux keyboard or preview interaction. Those checks remain separate. +The approved WSLg capture attempt failed with the same `CreateForMonitor` +`0x80070057` error as the Windows final candidate. Read-only accessibility exposed +only the remote-desktop host pane, not Linux application controls. No input was +attempted; the owned CPU startup process group is terminated by the test script. + +Current Linux desktop unit tests passed 54/54 in 6.33 seconds, including native +Linux filesystem create/update/rename/delete watcher behavior, application catalog +parsing and automatic-indexing guards. The release tests use `custom-protocol`; +an initial invocation without that required feature correctly failed compilation +and was corrected without source changes. Raw successful log: +`.searchi/qa/linux-final-018-unit-custom.log`. No Linux build/test process remains. + +### Earlier local package after safety and visual QA corrections + +Before final packaging, credential/cache exclusions were added to automatic +disk indexing. Actual UI QA also corrected a preview selector that incorrectly +stacked document previews. The final Linux source uses the safe snapshot +`9f48db1ae11272d51f2e832b0d08de2f850e6e2d456960fcf7435857c46a5eee` +plus recorded exact frontend/module deltas in +`.searchi/qa/linux-final-018-safe-final-delta.json`. The unchanged extraction +helper and normal runtimes were reused only after current provenance validation. + +Final cached Linux desktop compilation passed in 1m44s. The extracted final +Debian payload passed extraction provenance, all 29 speech resource records and +loader checks with no missing dependencies. The accepted package at +`.searchi/packages/searchi_0.1.8_amd64.deb` is **177,696,356 bytes**, SHA-256 +`cd69092b0b41403c4731e5e94a251067bc32e6a0b9f4338bc51bac0e8e2e85b0`. +Desktop executable SHA-256: +`d630e369c8823a3dfede5b8dad48a8c7d7dd196700743b0bac8418d6f23a5021`. +This earlier local package was preserved as an ignored precheck fixture and +was not published. Final report: +`.searchi/qa/linux-final-018-safe-final-build.json`. Native input was not retried +after its recorded capture/permission failure. No owned Linux process remains. + +### Accepted final Linux package + +The final frozen source includes application launching, first-use indexing and +watcher cache exclusions, hybrid relevance handling, the new regression tests +and current frontend appearance controls. Base snapshot has 677 files; the final +frontend/brand source archive SHA-256 is +`5650523467ed470355cd1bdb1faaf463bc551610e35ec741a542e228c4879fa8`. +The last icon reading-plate delta is recorded separately in +`.searchi/qa/linux-final-018-closed-icons-delta.json`. The final ELF was explicitly +checked to contain the latest `assets/index-D4fzQWUH.css` asset. + +Final native Linux desktop tests passed **56/56**, with zero ignored tests, in +4.61 seconds. Native release build and bundling passed. The extracted package +passed current extraction provenance, normal speech runtime verification (29 +records), and loader resolution without missing dependencies. No further native +UI input was attempted after the existing environment errors. + +The accepted `.searchi/packages/searchi_0.1.8_amd64.deb` is **177,694,032 bytes**, +SHA-256 `7ab0a3789028f2a974bbca7fc29220a4ee9327d08388370e20ed610688c13bb9`. +Desktop executable SHA-256: +`268d2632682d57917595e2f0f3062ff885a58e4eb5ea3ebe0f54e3c988eb2383`. +Previous local candidates were preserved as ignored evidence, not published. +Raw successful build/test log and report: +`.searchi/qa/linux-final-018-closed-build.log` and +`.searchi/qa/linux-final-018-closed-build.json`. All owned Linux processes finished. + +The **accepted final package bytes** (`7ab0a378…c13bb9`) were subsequently +extracted again and actually launched on WSLg `:0` from a new owned profile, +with automatic indexing disabled and CPU/software rendering enforced. Loader +resolution and all 29 packaged speech records passed. The exact bundled Google +worker was observed with `--device cpu --role query --threads 1` and the desktop +was alive. The bounded smoke exited zero and terminated its owned process group. +This is actual final-package startup evidence, not inferred from the earlier +candidate or a compilation. No UI input or inference benchmark was performed. +Raw report: `.searchi/qa/linux-final-018-closed-startup.json`. + +### Final package with indexing/startup database-lock fix + +Actual Windows startup exposed an interaction between whole-disk indexing and +semantic database preparation. The final shared service now prepares semantic +storage before watchers start, opens current-schema storage without a write +transaction, and treats only a busy optional lexical warmup as deferrable. The +current service source/tests plus formatted desktop startup were captured in +`.searchi/qa/linux-final-018-lockfix-source-delta.json` and rebuilt locally. + +Linux desktop tests again passed **56/56**, zero ignored, in 4.08 seconds. +Release compilation, final frontend asset presence, extraction provenance, +normal speech runtime verification (29 records), and loader resolution passed. +The preceding packages above are preserved local prechecks, not release artifacts. + +**Accepted current package:** `.searchi/packages/searchi_0.1.8_amd64.deb`, +177,702,404 bytes, SHA-256 +`d0fd15c4aa4ec0d7320b507a3bc4afa1ebb14bdffa39c3b5cd076df66ede997c`. +Desktop executable SHA-256: +`e4938a0a557dbe910431f69e51c2ea4a36451df80b6971aede1ad198b82deb7b`. +Actual startup of these exact extracted bytes passed on WSLg with an isolated +profile, automatic indexing disabled and CPU/software rendering enforced. Its +bundled Google process ran `--device cpu --role query --threads 1`; the desktop +was alive. The smoke exited zero and terminated the owned process group. +No UI retry or inference benchmark was performed. + +Final evidence: `.searchi/qa/linux-final-018-lockfix-build.json`, matching build +log, and `.searchi/qa/linux-final-018-lockfix-startup.json`. All owned Linux +build/test/startup processes have finished. No package was published here. + +## Final startup writer correction + +The preceding local 0.1.8 packages remained unpublished prechecks. An actual +Windows start with automatic whole-drive discovery exposed `database is locked` +during semantic query preparation. The current semantic store had unnecessarily +requested an immediate writer transaction on every open. Its optional lexical +statistics warmup could request the same writer while discovery was running. + +The final source prepares core/semantic storage before starting the watcher. +Complete current semantic schemas reopen without a migration write; legacy +additions still repair atomically and unsupported versions are rejected. Query +warmup tolerates only typed SQLite busy/locked errors from the optional lexical +cache, then still executes genuine model inference and ANN preparation. Other +cache errors remain errors. No model, ranking or extraction helper changed. + +Focused WAL-writer, legacy-schema, future-version and error propagation checks +passed. Final service unit checks passed 49 tests with two explicit ignored +fixtures; strict all-target Clippy passed. A genuine Google CPU query session +opened, warmed and retrieved the supplier agreement with an owned WAL writer +held throughout, in 13.32 seconds. This is functional contention evidence, +not a foreground latency benchmark or simulated embedding result. + +Before packaging, the corrected Windows release executable was tested with an +absent application profile and automatic indexing enabled. It exposed 27 Desktop +files including the owned validation text, database quick-check returned `ok`, +semantic schema was 9, and the actual normal worker remained resident with CPU, +query role and one thread. The returned native accessibility tree showed the +compact launcher without the earlier schema-lock error. The owned fresh-profile +processes were stopped. Raw evidence: `.searchi/qa/lean-final-runtime-smoke/`. + +A native query input attempt on that returned window still failed at +`GetCursorPos` with access denied `0x80070005`. No input success, final hotkey/ +tray interaction or native glass pixels are inferred from that attempt. Twelve +actual-built CSS composition checks separately passed; they are not native +compositor validation. Final installer hashes and installed-start verification +are recorded below. + +### Corrected Windows installer and installed startup + +The final Windows installer is 101,832,696 bytes, SHA-256 +`6dd415aeb8f7c4d357383f44eab099dcc1c945ef01427ff305999b3b1f616b0c`. +Installation exited zero; installed executable SHA-256 +`ab7eb3826b713be0f815f27415d2f9d0e9528029cdeee948d8e96cf557d96465` +matches the extracted installer exactly. Current extraction provenance passed. +Settings bytes were unchanged and a consistent SQLite backup was retained. +The VM-only CPU launcher and desktop/login shortcuts were restored. + +The actual installed package was launched with the original profile. Database +quick-check returned `ok`, semantic schema was 9 and 27 early files, including +the owned validation marker, remained searchable at the data layer. Its normal +Google query worker was observed on CPU with query role and one thread, alongside +bounded background workers. The app is left running for Karl; device discovery +continues in the background. No final native input pass is claimed. +Receipts: `.searchi/qa/lean-final-fixed-install/result.json` and `index-check.json`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4e8530c..cf40eaf 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,53 @@ # Production readiness roadmap -Status: Windows v0.1.7 published and installed with resident tray/global shortcut and login startup; Linux headless v0.1.6 and desktop v0.1.4 previews published. Broader production gates remain open. Windows VM tests use CPU only. +## Current bounded goal — 2026-10-14 + +Karl replaces the broad production goal with two deliverables. Work stays local; +there are no intermediate pushes or releases. Publish only the final checked version. + +- [ ] Verify native Windows/Linux interaction: launcher shortcut, tray, hide/show, + keyboard search, selection, preview and media seeking. Record unavailable UI + automation honestly rather than substituting headless tests. +- [x] Test Windows uninstall and reinstall, preserving existing user data and + using a fresh isolated application profile. Reinstalling on the development + VM does not prove missing prerequisites on a truly clean Windows image. +- [x] Establish bounded media baseline and improve practical image, music, + audio/video relevance, absent-content results and speech timestamps. Use real + Google EmbeddingGemma 2, retain source citations and report regressions. +- [ ] Run meaningful changed-code checks, build/install the final package, + update user documentation and publish that final version once. + +Perfect media recognition, macOS, real GPU qualification and the broader gates +below are outside this renewed scope. Stop experimentation when evidence shows +the practical limits; explain those limits and ship the verified improvement. + +Additional user steering in this same final local version: +- [x] Replace the normal desktop window with a centered floating search bar; + reveal results/preview on input and keep settings behind a small control. +- [x] Automatically index accessible local files in the background, with + system/cache/index exclusions and a settings switch; no first-use folder gate. +- [x] Search installed applications and launch catalogued results from the bar. + +Bounded evidence: uninstall/reinstall and isolated-profile Windows checks passed; +actual CPU Google media tests and the documented music/timing limits are recorded +in MEDIA-FINAL-QUALITY.md and SPEECH-FINAL-QUALITY.md. Launcher/app discovery and +automatic-indexing code passed Windows/Linux desktop tests. Current software +screenshots show actual retrieval and source highlights. Native final-build +hotkey/tray/glass interaction remains unchecked after capture/input failures; +do not infer it from unit tests or software screenshots. A final automatic-start +check exposed a schema writer conflict. Its correction passed held-writer +storage/warmup regressions and actual Google CPU retrieval. An empty Windows +profile with automatic indexing enabled then exposed 27 Desktop files and the +owned marker, with a resident CPU query-role worker and no schema-lock error. + +Final bounded delivery: Windows desktop 0.1.8 is installed and its corrected +installer verified; Ubuntu 24.04 desktop 0.1.8 passed packaged CPU startup. +These are the final release artifacts. README includes current working-search +images and a user-facing agent installation prompt. Headless Linux/Windows CLI +0.1.6 remain available through older releases. The native interaction checkbox +above stays open because final capture/input was denied; clean-machine, macOS +and real GPU gates below remain deferred. No further feature work belongs to +this bounded delivery. Gitea Actions remain disabled. Karl's release priority, 2026-10-13: publish a usable first Windows version and install it on DEV-WIN11 now. Extensive platform/GPU, clean-machine, signing and diff --git a/docs/SPEECH-FINAL-QUALITY.md b/docs/SPEECH-FINAL-QUALITY.md new file mode 100644 index 0000000..56f90a3 --- /dev/null +++ b/docs/SPEECH-FINAL-QUALITY.md @@ -0,0 +1,83 @@ +# Bounded speech and timeline verification + +2026-10-14, Windows development VM, CPU only (`SEARCHI_FORCE_CPU=1`). +This closes a focused reassessment of the existing speech implementation; +it does not introduce another ASR model or claim perfect speech recognition. +Google EmbeddingGemma 2 remains the retrieval model. The pinned local +whisper.cpp base model, Silero VAD and DTW supply recognized passages and +estimated source positions. + +## Practical improvement already retained + +The previous frozen publisher-timed diagnostic improved strict timestamp-start +hits from **4/8 to 6/8** with VAD plus DTW. Both remaining orange-soil queries +still missed their passage. See [the unchanged comparison](SPEECH-DTW.md). +That diagnostic is one English recording with correlated queries, not a current +end-to-end hybrid score. It cannot establish word-level precision. + +The shipped implementation uses bounded 30-second windows with two-second +overlap, source-preserving decoding, speech gating, and sentence locations from +first/last token points. It retains errors rather than inventing transcripts. +No new timing offsets, label-specific heuristics or larger model were added: +the remaining measured misses concern recognized content, and the available +evidence does not justify increasing indexing cost merely to move timestamps. + +## New genuine runtime check + +The exact 30-second PCM from the earlier failed long-source service run was +retested through the current normal distribution worker, forced onto CPU. +The existing Rust integration test copies the runtime and models into genuine +Unicode paths, verifies their inventory, transcribes twice, and requires stable, +nonempty, bounded passages and zero warm model-loading time. + +| Measurement | First call | Warm call | +| --- | ---: | ---: | +| Native VAD | 97.8375 ms | 113.021 ms | +| Native ASR | 47,312.2 ms | 48,474.65 ms | +| Native model loading | 235.8515 ms | 0 ms | +| Transport | 44.8708 ms | 48.6905 ms | +| CPU fallback | 0 ms | 0 ms | + +The test **passed** in 129.23 seconds including inventory verification, +relocation and cleanup. These are indexing timings, not search latency. +Successful isolated repetition does not explain or erase the earlier service +transport failure, which remains recorded in [speech integration](SPEECH-INTEGRATION.md). +The existing next-source recovery remains in place. + +| Verified input/artifact | SHA-256 | +| --- | --- | +| Exact previously failed PCM | `03ef72d2a4cb926b7d31fbaef757a6cb954a55d6d6961085ea6992728c8d9e37` | +| Normal speech worker | `9c34aa2b12a4c2ab876a1573045ef9a31e19e7dfff76a2b34efdf45555ad97aa` | +| Runtime inventory | `98ca365d4a298b23ca240967e005750db83613b09eaa89ed714afb711b42c87d` | + +Reproduction uses `crates/searchi-speech/tests/native_cpu.rs`, with +`SEARCHI_TEST_SPEECH_WORKER=.searchi/s-a3/runtime/searchi-speech-worker.exe`, +the pinned base/Silero files in `.searchi/speech/models`, and +`SEARCHI_TEST_SPEECH_PCM=.searchi/speech-runtime/nasa-exact-crash/input.f32`: + +```powershell +$env:SEARCHI_FORCE_CPU = '1' +cargo test -p searchi-speech --test native_cpu --locked -- --ignored --nocapture +``` + +## Actual media source-clock checks + +Both real FFmpeg integration tests passed in 3.53 seconds: + +- Audio beginning two seconds into a video decodes at its actual source origin; + a whole-file decode preserves the preceding silence. +- An internal timestamp gap, bounded seek and final tail preserve exact source + sample positions without shifting later speech towards the beginning. + +```powershell +$env:SEARCHI_FORCE_CPU = '1' +$env:SEARCHI_TEST_FFMPEG = 'C:/Users/karl/searchi/.searchi/media-runtime/bin/ffmpeg.exe' +$env:SEARCHI_TEST_FFPROBE = 'C:/Users/karl/searchi/.searchi/media-runtime/bin/ffprobe.exe' +cargo test -p searchi-service --test speech_stream_offset_cpu --locked -- --ignored --nocapture +``` + +These checks establish source-clock handling, not recognition accuracy. Speech +positions remain estimates; overlapping windows and imperfect transcripts can +still produce repeated or incomplete passages. The previously recorded tiny +English/German positive retrieval check and music/silence negative recognition +controls remain useful evidence, without a new broad media-quality rating. diff --git a/docs/screenshots/capture-manifest.json b/docs/screenshots/capture-manifest.json index 80d0107..459e371 100644 --- a/docs/screenshots/capture-manifest.json +++ b/docs/screenshots/capture-manifest.json @@ -1,9 +1,13 @@ { "kind": "browser-rendered-production-ui-real-cpu-engine", "source": { - "base_commit": "9870df7cb5b537ba516997a0d049b996bc30b08d", + "base_commit": "01279a6b25b545d0920889bf277abe00631a666d", "working_tree_dirty": true }, + "frontend_build": { + "version": "0.1.8", + "index_sha256": "8415df31094999f0d8bae5a6eb06db160132f1dc4657d1a329a24a6125defe0b" + }, "viewport": { "width": 1440, "height": 1000 @@ -33,11 +37,11 @@ { "query": "cancel the supplier partnership", "mode": "basic", - "result_count": 7, + "result_count": 1, "metrics": { "dropped_spans": 0, - "result_count": 7, - "total_ms": 2.0688999999999997, + "result_count": 1, + "total_ms": 73.3586, "trace": null }, "first_filename": "Supplier Agreement.pdf", @@ -48,17 +52,18 @@ "ui_checks": { "literal_query_marks": true, "actual_page_explanation": true, - "why_disclosure_collapsed_by_default": true + "why_disclosure_collapsed_by_default": true, + "unrelated_photos_removed": true } }, { "query": "mountains reflected in calm water", "mode": "basic", - "result_count": 7, + "result_count": 2, "metrics": { "dropped_spans": 0, - "result_count": 7, - "total_ms": 1.5953, + "result_count": 2, + "total_ms": 76.9483, "trace": null }, "first_filename": "Mountain Lake.jpg", @@ -67,40 +72,41 @@ "number": 0 }, "ui_checks": { - "meaning_only_image_has_no_text_marks": true + "meaning_only_image_has_no_text_marks": true, + "unrelated_documents_removed": true } }, { "query": "how can we cancel the supplier partnership", "mode": "debug", - "result_count": 7, + "result_count": 1, "metrics": { "dropped_spans": 0, - "result_count": 7, - "total_ms": 92.8279, + "result_count": 1, + "total_ms": 90.92160000000001, "trace": [ { "counters": {}, - "duration_ms": 92.80539999999999, + "duration_ms": 90.8989, "name": "hybrid.search", "parent": null, - "start_ms": 0.0106 + "start_ms": 0.0108 }, { "counters": {}, - "duration_ms": 2.6003, + "duration_ms": 1.8246, "name": "lexical.search", "parent": 0, - "start_ms": 0.027 + "start_ms": 0.0306 }, { "counters": { "query_bytes": 42 }, - "duration_ms": 0.0389, + "duration_ms": 0.0422, "name": "lexical.normalization", "parent": 1, - "start_ms": 0.0374 + "start_ms": 0.041100000000000005 }, { "counters": { @@ -108,148 +114,149 @@ "frequency_cache_misses": 8, "trigram_index": 1 }, - "duration_ms": 1.0081, + "duration_ms": 0.2968, "name": "lexical.candidate_plan", "parent": 1, - "start_ms": 0.08650000000000001 + "start_ms": 0.094 }, { "counters": {}, - "duration_ms": 0.2286, + "duration_ms": 0.3236, "name": "lexical.exact_filename", "parent": 1, - "start_ms": 1.1168 + "start_ms": 0.4067 }, { "counters": { "filters_active": 0, "rows": 0 }, - "duration_ms": 0.1837, + "duration_ms": 0.2841, "name": "lexical.filtered_sql", "parent": 4, - "start_ms": 1.1471 + "start_ms": 0.4364 }, { "counters": {}, - "duration_ms": 0.2688, + "duration_ms": 0.2009, "name": "lexical.filename_substring", "parent": 1, - "start_ms": 1.3663 + "start_ms": 0.7506999999999999 }, { "counters": { "filters_active": 0, "rows": 0 }, - "duration_ms": 0.2249, + "duration_ms": 0.17229999999999998, "name": "lexical.filtered_sql", "parent": 6, - "start_ms": 1.3906999999999998 + "start_ms": 0.7678999999999999 }, { "counters": {}, - "duration_ms": 0.2146, + "duration_ms": 0.1716, "name": "lexical.path_substring", "parent": 1, - "start_ms": 1.6556 + "start_ms": 0.9650000000000001 }, { "counters": { "filters_active": 0, "rows": 0 }, - "duration_ms": 0.175, + "duration_ms": 0.145, "name": "lexical.filtered_sql", "parent": 8, - "start_ms": 1.6802 + "start_ms": 0.9821000000000001 }, { "counters": {}, - "duration_ms": 0.2391, + "duration_ms": 0.229, "name": "lexical.content", "parent": 1, - "start_ms": 1.8861999999999999 + "start_ms": 1.1488 }, { "counters": { "filters_active": 0, "rows": 0 }, - "duration_ms": 0.2053, + "duration_ms": 0.2014, "name": "lexical.filtered_sql", "parent": 10, - "start_ms": 1.9049 + "start_ms": 1.1647 }, { "counters": {}, - "duration_ms": 0.3054, + "duration_ms": 0.27080000000000004, "name": "lexical.filename_terms", "parent": 1, - "start_ms": 2.1413 + "start_ms": 1.3904 }, { "counters": { "filters_active": 0, "rows": 0 }, - "duration_ms": 0.2773, + "duration_ms": 0.2424, "name": "lexical.filtered_sql", "parent": 12, - "start_ms": 2.1576999999999997 + "start_ms": 1.4071 }, { "counters": {}, - "duration_ms": 0.146, + "duration_ms": 0.162, "name": "lexical.media_metadata", "parent": 1, - "start_ms": 2.4598 + "start_ms": 1.675 }, { "counters": { "filters_active": 0, "rows": 0 }, - "duration_ms": 0.112, + "duration_ms": 0.1344, "name": "lexical.filtered_sql", "parent": 14, - "start_ms": 2.4749 + "start_ms": 1.6907999999999999 }, { "counters": {}, - "duration_ms": 0.0155, + "duration_ms": 0.015000000000000001, "name": "hybrid.identifier_priority", "parent": 0, - "start_ms": 2.6382 + "start_ms": 1.8684999999999998 }, { "counters": { + "code_task": 0, "failed": 0, "query_cache_hit": 0, "query_cache_miss": 1, - "request_encode_us": 18, - "request_write_us": 58, - "response_validate_us": 69, - "worker_batch_prepare_us": 19, - "worker_inference_us": 87311, - "worker_pool_normalize_us": 14, + "request_encode_us": 25, + "request_write_us": 197, + "response_validate_us": 97, + "worker_batch_prepare_us": 21, + "worker_inference_us": 86213, + "worker_pool_normalize_us": 19, "worker_token_count": 16, - "worker_tokenization_us": 173, - "worker_wait_and_response_decode_us": 88454 + "worker_tokenization_us": 179, + "worker_wait_and_response_decode_us": 87455 }, - "duration_ms": 89.1821, + "duration_ms": 87.9572, "name": "semantic.query_embedding", "parent": 0, - "start_ms": 2.6649 + "start_ms": 1.8927 }, { "counters": { "general_lane": 1 }, - "duration_ms": 0.8757, + "duration_ms": 0.9468, "name": "semantic.routing", "parent": 0, - "start_ms": 91.8792 + "start_ms": 89.89030000000001 }, { "counters": { @@ -257,99 +264,110 @@ "filesystem_checks": 2, "result_files": 7 }, - "duration_ms": 0.8334, + "duration_ms": 0.8935, "name": "semantic.retrieval", "parent": 18, - "start_ms": 91.90950000000001 + "start_ms": 89.93039999999999 }, { "counters": {}, - "duration_ms": 0.1384, + "duration_ms": 0.14650000000000002, "name": "semantic.generation_snapshot", "parent": 19, - "start_ms": 91.9247 + "start_ms": 89.953 }, { "counters": { "cache_hit": 1 }, - "duration_ms": 0.0115, + "duration_ms": 0.0167, "name": "semantic.ann_load", "parent": 19, - "start_ms": 92.07310000000001 + "start_ms": 90.1134 }, { "counters": {}, - "duration_ms": 0.011, + "duration_ms": 0.0167, "name": "semantic.filter_normalization", "parent": 19, - "start_ms": 92.0933 + "start_ms": 90.14410000000001 }, { "counters": { "cache_hit": 1, "eligible_chunks": 8 }, - "duration_ms": 0.0252, + "duration_ms": 0.034999999999999996, "name": "semantic.eligibility", "parent": 19, - "start_ms": 92.1129 + "start_ms": 90.1714 }, { "counters": { "requested_chunks": 8 }, - "duration_ms": 0.5494, + "duration_ms": 0.5680999999999999, "name": "semantic.ann_pass", "parent": 19, - "start_ms": 92.1485 + "start_ms": 90.2198 }, { "counters": { "returned_chunks": 8 }, - "duration_ms": 0.0347, + "duration_ms": 0.0356, "name": "semantic.ann_search", "parent": 24, - "start_ms": 92.161 + "start_ms": 90.2333 }, { "counters": { "filesystem_checks": 2, "unique_parents": 7 }, - "duration_ms": 0.4802, + "duration_ms": 0.4973, "name": "semantic.parent_materialization", "parent": 24, - "start_ms": 92.2066 + "start_ms": 90.2796 }, { "counters": { "valid": 1 }, - "duration_ms": 0.20550000000000002, + "duration_ms": 0.193, "name": "semantic.parent_filesystem_validation", "parent": 26, - "start_ms": 92.30539999999999 + "start_ms": 90.38690000000001 }, { "counters": { "valid": 1 }, - "duration_ms": 0.1146, + "duration_ms": 0.1215, "name": "semantic.parent_filesystem_validation", "parent": 26, - "start_ms": 92.5374 + "start_ms": 90.6088 + }, + { + "counters": { + "candidates_after": 1, + "candidates_before": 7, + "tail_candidates_pruned": 6 + }, + "duration_ms": 0.0216, + "name": "hybrid.semantic_shortlist", + "parent": 0, + "start_ms": 90.8471 }, { "counters": { "lexical_files": 0, - "semantic_files": 7 + "semantic_files": 1 }, - "duration_ms": 0.0376, + "duration_ms": 0.02, "name": "hybrid.rank_fusion", "parent": 0, - "start_ms": 92.7663 + "start_ms": 90.8783 } ] }, @@ -361,79 +379,130 @@ } ], "screenshots": [ + { + "file": "launcher-empty.png", + "sha256": "276ddb994d3b361c707d6afdadf28976d6386be9103baba19abbfe07fd4a3024", + "query": "", + "mode": "empty-ready", + "result_count": 0, + "selected_filename": null, + "theme": "light", + "capture_bounds": { + "x": 360, + "y": 120, + "width": 720, + "height": 110 + }, + "state_assertions": { + "semantic_ready": true, + "compact_empty_layout": true, + "native_pixels": false + } + }, { "file": "search-light.png", - "sha256": "bd45041de13093f7a7e26285618f4c1afc5b081fb41e51ff06e1313216af3562", + "sha256": "75fd2ebc9b40dcfb8501ee9ff0e14dc0b3a2cccc223b672f66c6feb4fe5f5858", "query": "cancel the supplier partnership", "mode": "basic", - "result_count": 7, + "result_count": 1, "selected_filename": "Supplier Agreement.pdf", "first_source_kind": "page", "first_source_number": 2, "theme": "light", - "summary": "7 RESULTS · KEYWORD + MEANING · SEARCH 2.1 MS · SHOWN 233 MS", + "summary": "1 RESULTS · KEYWORD + MEANING · SEARCH 73 MS · SHOWN 306 MS", + "capture_bounds": { + "x": 180, + "y": 0, + "width": 1080, + "height": 1000 + }, "state_assertions": { + "wide_split_preview": true, "semantic_ready": true, "hybrid_final": true, "image_visible": false, "actual_worker_inference": false, "literal_query_marks": true, "actual_page_explanation": true, - "why_disclosure_collapsed_by_default": true + "why_disclosure_collapsed_by_default": true, + "unrelated_photos_removed": true } }, { "file": "search-dark.png", - "sha256": "cd7e2aa3f7027cd9b40965171a232f6ee901e1e2b971f04628b4cfcc3b440263", + "sha256": "45c3b7f5072d22e4f9c52549727ad63ce122650c2cd105680d40d38b8e0730e1", "query": "cancel the supplier partnership", "mode": "basic", - "result_count": 7, + "result_count": 1, "selected_filename": "Supplier Agreement.pdf", "first_source_kind": "page", "first_source_number": 2, "theme": "dark", - "summary": "7 RESULTS · KEYWORD + MEANING · SEARCH 2.1 MS · SHOWN 233 MS", + "summary": "1 RESULTS · KEYWORD + MEANING · SEARCH 73 MS · SHOWN 306 MS", + "capture_bounds": { + "x": 180, + "y": 0, + "width": 1080, + "height": 1000 + }, "state_assertions": { + "wide_split_preview": true, "semantic_ready": true, "hybrid_final": true, "image_visible": false, "actual_worker_inference": false, "literal_query_marks": true, "actual_page_explanation": true, - "why_disclosure_collapsed_by_default": true + "why_disclosure_collapsed_by_default": true, + "unrelated_photos_removed": true } }, { "file": "media-search.png", - "sha256": "4c278446963cd5dbad073bea93a3ca33fb9c10cb43a9544258d5fb6039014d81", + "sha256": "718ffd4e7fa0a21ef717a0773749c0b72a1cbae4968bebb0c26e7f4d68133488", "query": "mountains reflected in calm water", "mode": "basic", - "result_count": 7, + "result_count": 2, "selected_filename": "Mountain Lake.jpg", "first_source_kind": "image", "first_source_number": 0, "theme": "light", - "summary": "7 RESULTS · KEYWORD + MEANING · SEARCH 1.6 MS · SHOWN 217 MS", + "summary": "2 RESULTS · KEYWORD + MEANING · SEARCH 77 MS · SHOWN 280 MS", + "capture_bounds": { + "x": 180, + "y": 0, + "width": 1080, + "height": 1000 + }, "state_assertions": { + "wide_split_preview": true, "semantic_ready": true, "hybrid_final": true, "image_visible": true, "actual_worker_inference": false, - "meaning_only_image_has_no_text_marks": true + "meaning_only_image_has_no_text_marks": true, + "unrelated_documents_removed": true } }, { "file": "performance-details.png", - "sha256": "4f336d4c42d0c4a50ce33bca27595f02da7d896c8734d447350d50dab5c339df", + "sha256": "945dec2b087961a4cfa073983f4ac055c6f7b75c736aaaa5094596ffdd959713", "query": "how can we cancel the supplier partnership", "mode": "debug", - "result_count": 7, + "result_count": 1, "selected_filename": "Supplier Agreement.pdf", "first_source_kind": "page", "first_source_number": 2, "theme": "light", - "summary": "7 RESULTS · KEYWORD + MEANING · SEARCH 93 MS · SHOWN 311 MS", + "summary": "1 RESULTS · KEYWORD + MEANING · SEARCH 91 MS · SHOWN 306 MS", + "capture_bounds": { + "x": 180, + "y": 0, + "width": 1080, + "height": 1000 + }, "state_assertions": { + "wide_split_preview": true, "semantic_ready": true, "hybrid_final": true, "image_visible": false, diff --git a/docs/screenshots/launcher-empty.png b/docs/screenshots/launcher-empty.png new file mode 100644 index 0000000..23cb802 Binary files /dev/null and b/docs/screenshots/launcher-empty.png differ diff --git a/docs/screenshots/media-search.png b/docs/screenshots/media-search.png index 6e879b3..b017ec7 100644 Binary files a/docs/screenshots/media-search.png and b/docs/screenshots/media-search.png differ diff --git a/docs/screenshots/performance-details.png b/docs/screenshots/performance-details.png index c959060..bc17959 100644 Binary files a/docs/screenshots/performance-details.png and b/docs/screenshots/performance-details.png differ diff --git a/docs/screenshots/search-dark.png b/docs/screenshots/search-dark.png index 462fc7a..e35707c 100644 Binary files a/docs/screenshots/search-dark.png and b/docs/screenshots/search-dark.png differ diff --git a/docs/screenshots/search-light.png b/docs/screenshots/search-light.png index 324df42..8f918d4 100644 Binary files a/docs/screenshots/search-light.png and b/docs/screenshots/search-light.png differ diff --git a/tools/desktop-capture/README.md b/tools/desktop-capture/README.md index 8c5ec62..2efbad4 100644 --- a/tools/desktop-capture/README.md +++ b/tools/desktop-capture/README.md @@ -22,6 +22,7 @@ then warms its retained CPU worker before listening on 127.0.0.1:1431. ```powershell $env:SEARCHI_FORCE_CPU = '1' +$env:SEARCHI_EXTRACT_WORKER = (Resolve-Path target/release/searchi-extract-worker.exe).Path cargo run --manifest-path tools/desktop-capture/backend/Cargo.toml -- --root .searchi/readme-capture/files --db .searchi/readme-capture/index.sqlite --models .searchi/models/embeddinggemma2 --worker .searchi/runtime/cpu/Release/searchi-gemma-worker.exe --dist apps/desktop/dist --ffmpeg .searchi/media-runtime/bin/ffmpeg.exe --ffprobe .searchi/media-runtime/bin/ffprobe.exe ``` @@ -46,12 +47,14 @@ loaded fonts, image pixels, and genuine worker inference counters in debug mode. ## Artifacts and limits -Outputs under docs/screenshots are search-light.png, search-dark.png, +Outputs under docs/screenshots are launcher-empty.png, search-light.png, search-dark.png, media-search.png and performance-details.png, plus capture-manifest.json. The manifest contains actual fixture queries, counts/selection, measured response metrics, device/model, viewport, PNG SHA256, runner/browser versions, Git base commit and dirty-state flag. The base commit does not identify uncommitted -source exactly. The ephemeral API token is never saved in the manifest. +source exactly. It also records the actual frontend package version and built +index SHA-256. The compact empty-bar capture has no manufactured results and +uses the genuine ready backend. The ephemeral API token is never saved in the manifest. Captures are **browser-rendered production UI with the actual CPU engine**. They omit native window chrome. They do not establish native WebView visual QA, diff --git a/tools/desktop-capture/backend/Cargo.lock b/tools/desktop-capture/backend/Cargo.lock index 6709a23..35f70c1 100644 --- a/tools/desktop-capture/backend/Cargo.lock +++ b/tools/desktop-capture/backend/Cargo.lock @@ -1129,11 +1129,16 @@ name = "searchi-core" version = "0.1.0" dependencies = [ "anyhow", + "libc", "lopdf", "quick-xml", "rusqlite", + "searchi-media", "serde", + "serde_json", + "sha2 0.10.9", "walkdir", + "windows-sys", "zip", ] @@ -1192,11 +1197,22 @@ dependencies = [ "searchi-core", "searchi-media", "searchi-semantic", + "searchi-speech", "serde", "sha2 0.10.9", "usearch", ] +[[package]] +name = "searchi-speech" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "sha2 0.10.9", +] + [[package]] name = "serde" version = "1.0.229" diff --git a/tools/desktop-capture/backend/src/main.rs b/tools/desktop-capture/backend/src/main.rs index 4c04ae3..5edba7f 100644 --- a/tools/desktop-capture/backend/src/main.rs +++ b/tools/desktop-capture/backend/src/main.rs @@ -44,6 +44,8 @@ struct Filters { max_duration_ms: Option, modified_after_unix_ms: Option, modified_before_unix_ms: Option, + media_artist: Option, + media_title: Option, } impl Filters { fn options(self, root: &Path) -> Result { @@ -63,6 +65,8 @@ impl Filters { max_duration_ms: self.max_duration_ms, modified_after_unix_ms: self.modified_after_unix_ms, modified_before_unix_ms: self.modified_before_unix_ms, + media_artist: self.media_artist, + media_title: self.media_title, }) } } @@ -87,6 +91,10 @@ fn main() -> Result<()> { let mut lexical = SearchIndex::open(&args.db)?; validate_database(&args.db, &root)?; lexical.index_folder(&root)?; + ensure!( + lexical.extracted_source_hash(&root.join("Supplier Agreement.pdf").to_string_lossy())?.is_some(), + "Capture PDF was not genuinely extracted; set SEARCHI_EXTRACT_WORKER to the verified document helper" + ); lexical.prewarm()?; let tools = FfmpegTools::new(args.ffmpeg, args.ffprobe)?; let mut hybrid = HybridSearch::open(&args.db, &args.models, &args.worker, 1)?; @@ -287,7 +295,7 @@ impl App { json!({"model":"Google EmbeddingGemma 2","ready":true,"model_directory":self.models,"worker_ready":true,"downloading":false,"embedding":false,"device_policy":"cpu","error":null,"downloaded_bytes":864676480u64,"total_bytes":864676480u64,"generation":status.generation,"chunks":status.chunks,"stale":status.stale,"pending":status.pending,"warming":false,"worker_info":self.hybrid.worker_info(),"media_tools_ready":true,"media_setup_error":null,"media_failures":status.failures}), ) } - "search_measured" | "hybrid_search_measured" => { + "search_measured" | "hybrid_search_measured" | "semantic_search_raw" => { let query = args["query"].as_str().context("Query required")?; ensure!(query.len() <= 4096, "Query too large"); let filters: Filters = if args["filters"].is_null() { @@ -297,7 +305,9 @@ impl App { }; let options = filters.options(&self.root)?; let debug = args["debugPerformance"].as_bool().unwrap_or(false); - if name == "search_measured" { + if name == "semantic_search_raw" { + Ok(serde_json::to_value(self.hybrid.search_semantic(query, 50, &options)?)?) + } else if name == "search_measured" { Ok(serde_json::to_value( self.lexical .search_with_options_measured(query, 50, &options, debug)?, diff --git a/tools/desktop-capture/capture.mjs b/tools/desktop-capture/capture.mjs index c217810..269b264 100644 --- a/tools/desktop-capture/capture.mjs +++ b/tools/desktop-capture/capture.mjs @@ -92,6 +92,28 @@ try { .evaluate((image) => image.complete && image.naturalWidth > 0), "Brand asset failed to load", ); + // Capture the actual ready/empty production layout before typing. This is + // browser-rendered UI, not native window chrome or synthesized app results. + await page.waitForFunction(() => + document.documentElement.dataset.launcherLayout === "compact" && + document.querySelector("#query")?.value === "", + ); + const compactPath = resolve(out, "launcher-empty.png"); + const compactBounds = await page.locator("#app").boundingBox(); + assert(compactBounds && compactBounds.width <= 720 && compactBounds.height <= 112, + "Ready empty launcher must remain a compact bar"); + await page.locator("#app").screenshot({ path: compactPath, animations: "disabled" }); + evidence.push({ + file: "launcher-empty.png", + sha256: createHash("sha256").update(await readFile(compactPath)).digest("hex"), + query: "", + mode: "empty-ready", + result_count: 0, + selected_filename: null, + theme: "light", + capture_bounds: compactBounds, + state_assertions: { semantic_ready: true, compact_empty_layout: true, native_pixels: false }, + }); async function query(text, debug = false) { const awaited = page.waitForResponse( (response) => { @@ -151,6 +173,10 @@ try { () => !document.querySelector("#preview")?.hidden, ); if (text === "cancel the supplier partnership") { + assert( + !body.results.some((result) => ["Mountain Lake.jpg", "Night Pier.jpg"].includes(result.filename)), + "Contract results must not contain the unrelated fixture photos", + ); assert.equal( latestMeasured.first_filename, "Supplier Agreement.pdf", @@ -225,9 +251,12 @@ try { literal_query_marks: true, actual_page_explanation: true, why_disclosure_collapsed_by_default: true, + unrelated_photos_removed: true, }; } if (text === "mountains reflected in calm water") { + assert(body.results.every((result) => result.match_kind === "semantic_image"), + "Visual search shortlist must not contain the unrelated fixture documents"); assert.equal( latestMeasured.first_filename, "Mountain Lake.jpg", @@ -238,7 +267,7 @@ try { 0, "Meaning-only photo result must not invent word-level alignment", ); - latestMeasured.ui_checks = { meaning_only_image_has_no_text_marks: true }; + latestMeasured.ui_checks = { meaning_only_image_has_no_text_marks: true, unrelated_documents_removed: true }; } await page.evaluate( () => @@ -249,8 +278,12 @@ try { } async function screenshot(name, queryText) { assert.deepEqual(failures, [], "Frontend page errors occurred"); + const pane = await page.locator(".results-pane").boundingBox(); + const preview = await page.locator("#preview").boundingBox(); + assert(pane && preview && preview.x >= pane.x + pane.width - 1, + "Wide result and preview panes must remain side by side"); const path = resolve(out, `${name}.png`); - await page.screenshot({ path, animations: "disabled", fullPage: false }); + await page.locator("#app").screenshot({ path, animations: "disabled" }); const selected = await page.locator("#preview-title").innerText(); assert.equal( selected, @@ -272,7 +305,9 @@ try { matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light", ), summary: await page.locator("#result-summary").innerText(), + capture_bounds: await page.locator("#app").boundingBox(), state_assertions: { + wide_split_preview: true, semantic_ready: true, hybrid_final: true, image_visible: name === "media-search", @@ -366,6 +401,10 @@ try { { kind: "browser-rendered-production-ui-real-cpu-engine", source, + frontend_build: { + version: JSON.parse(await readFile(resolve(repository, "apps/desktop/package.json"), "utf8")).version, + index_sha256: createHash("sha256").update(await readFile(resolve(repository, "apps/desktop/dist/index.html"))).digest("hex"), + }, viewport: { width: 1440, height: 1000 }, runner: { playwright: "1.64.0", chromium: browser.version() }, backend: { diff --git a/tools/package_windows.py b/tools/package_windows.py index 03ccb30..ed4d7d2 100644 --- a/tools/package_windows.py +++ b/tools/package_windows.py @@ -203,7 +203,8 @@ def package(root, executable, output, cli=None): "Windows needs WebView2. The Microsoft C++ runtime ships with these helpers; Visual Studio is not required.\n" "First launch downloads approximately 865 MB of verified Google model files.\n" "Local spoken-passage recognition adds approximately 149 MB of verified speech models.\n" - "Choose Add folder to index your own files; ordinary search stays local.\n" + "Use Ctrl+Shift+Space for the floating search bar. Local file discovery runs automatically.\n" + "Change automatic indexing or add a folder in Settings; ordinary search stays local.\n" "This is an unsigned development build, not a certified production release.\n") if cli is not None: archive.writestr("AGENT-SETUP.txt", diff --git a/tools/relevance-eval/policies/hybrid-tail-v1.json b/tools/relevance-eval/policies/hybrid-tail-v1.json new file mode 100644 index 0000000..d5b9307 --- /dev/null +++ b/tools/relevance-eval/policies/hybrid-tail-v1.json @@ -0,0 +1,12 @@ +{ + "policy_id": "hybrid-semantic-tail-gap070-v1", + "scope": "hybrid semantic candidates before reciprocal-rank fusion", + "minimum_adjacent_gap_exclusive": 0.07, + "derivation": "Rounded upward from maximum adjacent gap 0.06504970788955688 among eight calibration All-files absence requests", + "calibration_report_sha256": "56f42c523527dd748918bb8f17be6bd7818158227e692fccaf387d915f9d0a94", + "boundary": "largest adjacent raw cosine gap in existing filtered file-deduplicated candidate list", + "equal_largest_gaps": "retain all; numeric tie tolerance is twice f32 EPSILON", + "preserve": ["strongest semantic candidate", "all independent lexical candidates", "exact filename and identifier priority", "raw semantic diagnostic API"], + "claims": ["prunes clearly separated weak semantic tails"], + "not_claimed": ["no-answer detection", "calibrated confidence", "universal embedding threshold", "new held-out or human quality certification"] +}