Ship floating launcher, automatic discovery and bounded media search improvements

This commit is contained in:
karl committed 2026-10-14 11:44:15 +02:00
1 parent 01279a6b25
commit 521966a2ac
43 files changed
+3117 -219

No files matched your search

+53 -37
View File
@@ -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, words. See the relevant passage or media preview alongside your results,
then open the file. Your files stay on your device. 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) ![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 *“cancel the supplier partnership” finds the agreement and brings its
termination passage into view.* 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 Escape or closing the window hides it while search stays ready. Use the tray
icon beside the clock to open searchi or quit it. 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 | | What you remember | What to try |
| --- | --- | | --- | --- |
| An application to open | `VS Code`, `vscode` or `Calculator` |
| The file's name | `Supplier agreement.pdf` | | The file's name | `Supplier agreement.pdf` |
| Words inside a document | `sixty days written notice` | | Words inside a document | `sixty days written notice` |
| What a document is about | `cancel the supplier partnership` | | 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, Light and dark themes follow your system preference. Keyboard navigation,
short transitions and reduced-motion support keep the interface familiar short transitions and reduced-motion support keep the interface familiar
and comfortable. In **Settings → Appearance**, choose a solid, glass or 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, clear. Native glass currently requires **Windows 11 22H2 or later**. macOS,
Linux, unsupported Windows versions and high-contrast mode currently use a Linux, unsupported Windows versions and high-contrast mode currently use a
solid background; your appearance preference is retained. 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, *“mountains reflected in calm water” searches the pictures themselves,
so a useful filename isn't required.* 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 Images open in the preview. Audio and video have playback controls, and
matching media segments can jump to their approximate location. Nothing matching media segments can jump to their approximate location. Nothing
autoplays when you select a result. autoplays when you select a result.
## Get searchi ## 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. - [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.7/searchi_0.1.7_x64-setup.exe.sha256) · [Release notes](https://tea.karlcom.de/karl/searchi/releases/tag/v0.1.7). - [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 Already installed? Choose Quit from the tray before updating. Version 0.1.8
archived files dated before 1970. adds the floating launcher, installed-application search and automatic background
It includes isolated PDF/DOCX processing and bounded media previews. 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 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 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. 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) **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.4_amd64.deb`. and install with `sudo apt install ./searchi_0.1.8_amd64.deb`.
[Checksums](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.7/LINUX-DESKTOP-SHA256SUMS-0.1.4.txt). [Checksum](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.8/searchi_0.1.8_amd64.deb.sha256).
Startup was tested in WSL using CPU; native graphical interaction and other Packaged startup was tested on WSLg using CPU; native graphical interaction and other
distributions still need testing. [Compatibility and evidence](docs/LINUX-DESKTOP.md). distributions still need testing. [Compatibility and evidence](docs/LINUX-DESKTOP.md).
**Running a Linux server?** The optional **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 a working installation until it is verified. Ask for my help only when a
password, system approval or other manual action is required. password, system approval or other manual action is required.
Let me choose which folders to index. Keep search local and optional cloud On desktop, let automatic local file discovery run in the background. Show me
summaries disabled. On desktop, show me how to add a folder; on headless Linux, the floating search bar, an application search and the indexing setting. Keep
prepare the chosen folder through the CLI. Finish with a filename search search local and optional cloud summaries disabled. On headless Linux, ask which
followed by a search in my own words. 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. 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: Once searchi is running:
1. Choose **Add folder**, browse to the folder you want to search, then choose 1. Press **Ctrl + Shift + Space** on Windows. A compact search bar floats in
**Index folder**. the center of your screen; results and previews appear when you type.
2. Start with a filename or words you remember. These searches become 2. Try `VS Code` to find and launch an installed application. App search is
available as files are indexed. available independently of file indexing and the local model download.
3. Let the local search model prepare in the background. First setup downloads 3. searchi automatically discovers accessible local files in the background.
approximately **865 MB** and needs an internet connection. Document and code System folders, caches, build outputs, common credential stores and its own
search by meaning can begin once the smaller text model and text index are data are excluded. Common
ready, while the larger media model continues downloading. The app shows personal folders are prepared first; a large collection takes longer.
progress and keeps text search available if media setup needs a retry. 4. Search a filename or words you remember as files become available. The local
4. Describe what you're looking for, select a result and inspect the preview. Google model prepares in the background for searches by meaning. First setup
Press **Enter** to open the selected file in its usual application. 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. In **Settings**, you can turn automatic discovery off or add a specific folder,
The desktop keeps its search model warm for subsequent searches. including a network folder. Existing indexed results remain searchable when
searchi watches your chosen folders and updates search in the background as automatic discovery is off; an already running scan may finish. Files are
files change. You can also use **Reindex** to check a folder again, for example updated in the background, and **Reindex** checks a selected folder again.
after reconnecting an external drive. The desktop keeps the real Google query model warm for subsequent searches.
searchi automatically chooses supported graphics acceleration when available searchi automatically chooses supported graphics acceleration when available
and falls back to CPU when it cannot use it. You do not need a graphics 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 ## Private by design
You choose the folders to index. By default, file contents, previews and search Desktop file discovery runs automatically across accessible local storage.
processing stay local. Ordinary searches aren't sent to a cloud AI service. 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 If you enable optional AI answers, an explicit **Summarize** or **Ask** action
sends the previewed document excerpt to the endpoint you choose. Ordinary 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) For contributors: [building searchi and the headless Linux CLI](docs/BUILDING.md)
· [roadmap](docs/ROADMAP.md) · [search-quality evaluation](docs/MEDIA-QUALITY.md). · [roadmap](docs/ROADMAP.md) · [search-quality evaluation](docs/MEDIA-QUALITY.md).
The screenshots show the working interface searching real example files with The screenshots show the current interface searching real example files with
the local Google model. They are browser captures of the app interface; the local Google model. They are isolated software-test browser captures;
operating-system window borders are omitted. [Photo credits](fixtures/media/README.md). 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). Made by Karl. Open source under the [MIT license](LICENSE).
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "searchi-desktop", "name": "searchi-desktop",
"version": "0.1.7", "version": "0.1.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "searchi-desktop", "name": "searchi-desktop",
"version": "0.1.7", "version": "0.1.8",
"dependencies": { "dependencies": {
"@fluentui/svg-icons": "1.1.344", "@fluentui/svg-icons": "1.1.344",
"@fontsource-variable/inter": "5.3.0", "@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "searchi-desktop", "name": "searchi-desktop",
"version": "0.1.7", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -3799,7 +3799,7 @@ dependencies = [
[[package]] [[package]]
name = "searchi-desktop" name = "searchi-desktop"
version = "0.1.7" version = "0.1.8"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"image", "image",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "searchi-desktop" name = "searchi-desktop"
version = "0.1.7" version = "0.1.8"
edition = "2021" edition = "2021"
description = "Local Searchi desktop shell" description = "Local Searchi desktop shell"
license = "MIT" license = "MIT"
+539
View File
@@ -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<Application>,
#[cfg(windows)]
registered: std::sync::Arc<std::sync::Mutex<Vec<RegisteredApplication>>>,
}
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<PathBuf>) -> 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::<Vec<_>>()
.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<ApplicationResult> {
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<u8> {
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<PathBuf> {
#[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<Vec<RegisteredApplication>> {
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<String> {
#[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<String> {
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"));
}
}
@@ -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<PathBuf> {
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<PathBuf> = 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<PathBuf> = 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<PathBuf> {
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<u16> = 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<PathBuf> {
vec![PathBuf::from("/")]
}
pub fn exclusions() -> Vec<PathBuf> {
#[cfg(windows)]
let mut paths: Vec<PathBuf> = ["WINDIR", "ProgramFiles", "ProgramFiles(x86)", "ProgramData"]
.iter()
.filter_map(std::env::var_os)
.map(PathBuf::from)
.collect();
#[cfg(not(windows))]
let mut paths: Vec<PathBuf> = [
"/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<String> {
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<PathBuf> {
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());
}
}
+55
View File
@@ -90,6 +90,61 @@ pub fn hide_search(app: tauri::AppHandle) -> Result<(), String> {
.map_err(|error| error.to_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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+62 -2
View File
@@ -5,6 +5,8 @@ compile_error!("Release desktop builds require --features custom-protocol to emb
mod ai_commands; mod ai_commands;
mod appearance; mod appearance;
mod applications;
mod automatic_indexing;
mod data_directory; mod data_directory;
mod instance; mod instance;
mod media_preview; mod media_preview;
@@ -36,6 +38,7 @@ use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
struct AppState { struct AppState {
applications: applications::ApplicationCatalog,
appearance: Arc<Mutex<appearance::AppearanceStatus>>, appearance: Arc<Mutex<appearance::AppearanceStatus>>,
ai: Arc<AiSession>, ai: Arc<AiSession>,
database: PathBuf, database: PathBuf,
@@ -119,6 +122,38 @@ fn desktop_status(state: State<'_, AppState>) -> Result<DesktopStatus, String> {
}) })
} }
#[tauri::command]
fn search_applications(
query: String,
state: State<'_, AppState>,
) -> Vec<applications::ApplicationResult> {
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<Settings, String> {
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] #[tauri::command]
fn set_cpu_threads(threads: usize, state: State<'_, AppState>) -> Result<Settings, String> { fn set_cpu_threads(threads: usize, state: State<'_, AppState>) -> Result<Settings, String> {
if state.indexing.load(Ordering::Acquire) { if state.indexing.load(Ordering::Acquire) {
@@ -512,6 +547,21 @@ fn start_folder_watching(
let result = (|| -> Result<bool, String> { let result = (|| -> Result<bool, String> {
let mut index = SearchIndex::open(&database).map_err(|error| error.to_string())?; let mut index = SearchIndex::open(&database).map_err(|error| error.to_string())?;
index.set_document_worker(document_worker.clone()); 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 { let report = match request.paths {
Some(paths) => index.index_paths_cancellable( Some(paths) => index.index_paths_cancellable(
&request.root, &request.root,
@@ -613,6 +663,9 @@ fn main() {
"searchi-extract-worker" "searchi-extract-worker"
})); }));
let lexical = Arc::new(Mutex::new(SearchIndex::open(&database)?)); 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_database = database.clone();
let warm_target = lexical.clone(); let warm_target = lexical.clone();
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
@@ -676,20 +729,21 @@ fn main() {
} }
}); });
let mut ignored = vec![directory.clone(), model_directory.clone()]; 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) = 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()) { if let Some(parent) = worker.parent().filter(|path| path.is_absolute()) {
ignored.push(parent.to_path_buf()); ignored.push(parent.to_path_buf());
} }
let index_options = searchi_core::IndexOptions { let index_options = searchi_core::IndexOptions {
excluded_directory_names: automatic_indexing::excluded_names(),
excluded_paths: ignored.iter() excluded_paths: ignored.iter()
.map(|path| data_directory::canonical_exclusion(path)) .map(|path| data_directory::canonical_exclusion(path))
.collect::<std::io::Result<Vec<_>>>()?, .collect::<std::io::Result<Vec<_>>>()?,
..Default::default()
}; };
let indexing = Arc::new(AtomicBool::new(false)); let indexing = Arc::new(AtomicBool::new(false));
let watching = Arc::new(start_folder_watching( let watching = Arc::new(start_folder_watching(
app.handle().clone(), database.clone(), lexical.clone(), indexing.clone(), 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)?); ).map_err(std::io::Error::other)?);
let semantic = Arc::new( let semantic = Arc::new(
SemanticState::new(model_directory, worker, media_tools) 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 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); let appearance = appearance::apply(&window, settings.value.background_material, settings.value.background_opacity, false);
app.manage(AppState { app.manage(AppState {
applications: applications::ApplicationCatalog::discover(),
appearance: Arc::new(Mutex::new(appearance)), appearance: Arc::new(Mutex::new(appearance)),
ai: Arc::new(AiSession::default()), ai: Arc::new(AiSession::default()),
database, database,
@@ -749,6 +804,11 @@ speech_worker, directory.join("SpeechModels")),
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
instance::hide_search, instance::hide_search,
instance::set_search_layout,
instance::drag_search,
search_applications,
launch_application,
set_automatic_indexing,
prepare_ai_source, prepare_ai_source,
generate_ai, generate_ai,
search, search,
+11 -4
View File
@@ -23,10 +23,16 @@ fn default_background_opacity() -> u8 {
const SETTINGS_VERSION: u32 = 2; const SETTINGS_VERSION: u32 = 2;
const MAX_SETTINGS_BYTES: u64 = 128 * 1024; const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
fn default_automatic_indexing() -> bool {
true
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct Settings { pub struct Settings {
pub version: u32, pub version: u32,
#[serde(default = "default_automatic_indexing")]
pub automatic_indexing: bool,
#[serde(default)] #[serde(default)]
pub background_material: BackgroundMaterial, pub background_material: BackgroundMaterial,
#[serde(default = "default_background_opacity")] #[serde(default = "default_background_opacity")]
@@ -43,6 +49,7 @@ impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
version: SETTINGS_VERSION, version: SETTINGS_VERSION,
automatic_indexing: true,
background_material: BackgroundMaterial::default(), background_material: BackgroundMaterial::default(),
background_opacity: default_background_opacity(), background_opacity: default_background_opacity(),
roots: Vec::new(), roots: Vec::new(),
@@ -57,8 +64,8 @@ impl Settings {
if self.version != SETTINGS_VERSION { if self.version != SETTINGS_VERSION {
return Err(format!("Unsupported settings version {}", self.version)); return Err(format!("Unsupported settings version {}", self.version));
} }
if !(70..=100).contains(&self.background_opacity) { if !(40..=100).contains(&self.background_opacity) {
return Err("Background opacity must be between 70 and 100 percent.".into()); return Err("Background opacity must be between 40 and 100 percent.".into());
} }
if !(1..=8).contains(&self.cpu_threads) { if !(1..=8).contains(&self.cpu_threads) {
return Err("CPU threads must be between 1 and 8.".into()); 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); assert_eq!(store.value.background_opacity, 90);
let mut next = store.value.clone(); let mut next = store.value.clone();
next.background_material = BackgroundMaterial::Glass; next.background_material = BackgroundMaterial::Glass;
next.background_opacity = 70; next.background_opacity = 40;
store.save(next.clone()).unwrap(); store.save(next.clone()).unwrap();
assert_eq!(SettingsStore::load(path.clone()).unwrap().value, next); assert_eq!(SettingsStore::load(path.clone()).unwrap().value, next);
let bytes = fs::read(&path).unwrap(); 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(); let mut invalid = next.clone();
invalid.background_opacity = opacity; invalid.background_opacity = opacity;
assert!(store.save(invalid).is_err()); assert!(store.save(invalid).is_err());
+48 -4
View File
@@ -243,10 +243,9 @@ fn validate(roots: &[PathBuf]) -> Result<(), String> {
fn excluded(path: &Path, shared: &Shared) -> bool { fn excluded(path: &Path, shared: &Shared) -> bool {
shared.ignored.iter().any(|p| path.starts_with(p)) shared.ignored.iter().any(|p| path.starts_with(p))
|| path.components().any(|c| { || path.components().any(|c| {
matches!( super::automatic_indexing::EXCLUDED_DIRECTORY_NAMES
c.as_os_str().to_str(), .iter()
Some(".git" | "node_modules" | "target") .any(|name| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(name))
)
}) })
} }
fn hint(shared: &Shared, event: notify::Result<Event>) { fn hint(shared: &Shared, event: notify::Result<Event>) {
@@ -465,6 +464,51 @@ fn run(shared: Arc<Shared>, callback: impl Fn(ScanRequest) -> ScanOutcome) {
mod tests { mod tests {
use super::*; use super::*;
use std::sync::mpsc; 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 { fn fast() -> Timing {
Timing { Timing {
debounce: Duration::from_millis(40), debounce: Duration::from_millis(40),
+10 -5
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "searchi", "productName": "searchi",
"version": "0.1.7", "version": "0.1.8",
"identifier": "dev.searchi.desktop", "identifier": "dev.searchi.desktop",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -13,10 +13,15 @@
"windows": [ "windows": [
{ {
"title": "searchi", "title": "searchi",
"width": 1080, "width": 720,
"height": 760, "height": 110,
"minWidth": 680, "center": true,
"minHeight": 540 "decorations": false,
"resizable": false,
"transparent": true,
"shadow": true,
"skipTaskbar": true,
"alwaysOnTop": true
} }
], ],
"security": { "security": {
+1 -1
View File
@@ -19,7 +19,7 @@ export function appearanceView(
const material: BackgroundMaterial = const material: BackgroundMaterial =
!highContrast && valid ? status.effective_material : "solid"; !highContrast && valid ? status.effective_material : "solid";
const opacity = Number.isFinite(status?.opacity) const opacity = Number.isFinite(status?.opacity)
? Math.max(70, Math.min(100, status!.opacity)) ? Math.max(40, Math.min(100, status!.opacity))
: 90; : 90;
const message = highContrast const message = highContrast
? "High contrast uses a solid background. Your appearance preference is retained." ? "High contrast uses a solid background. Your appearance preference is retained."
+271 -13
View File
@@ -88,6 +88,7 @@ interface Settings {
version: number; version: number;
roots: string[]; roots: string[];
cpu_threads: number; cpu_threads: number;
automatic_indexing?: boolean;
background_material?: BackgroundMaterial; background_material?: BackgroundMaterial;
background_opacity?: number; background_opacity?: number;
} }
@@ -160,19 +161,86 @@ interface SearchRequest {
} }
document.querySelector<HTMLDivElement>("#app")!.innerHTML = ` document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Artist (exact)<input id="filter-artist" placeholder="Artist from music tags" maxlength="256"></label><label>Track title (exact)<input id="filter-title" placeholder="Title from music tags" maxlength="256"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Settings</h2><section class="appearance-settings" aria-labelledby="appearance-heading"><h3 id="appearance-heading">Appearance</h3><label for="background-material">Window background</label><select id="background-material"><option value="solid">Solid</option><option value="glass">Glass</option><option value="frosted">Frosted glass</option></select><div class="appearance-opacity"><label for="background-opacity">Background tint</label><output id="background-opacity-value" for="background-opacity">90%</output></div><input id="background-opacity" type="range" min="70" max="100" step="1" value="90" aria-describedby="appearance-status"><p id="appearance-status" class="hint" role="status">A subtle window background. Search results and passages stay clear.</p></section><h3>Folders & resources</h3><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i === 1 ? "threads (default)" : i ? "threads" : "thread (low power)"}</option>`).join("")}</select><p class="hint">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.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header> <header id="launcher-header"><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Artist (exact)<input id="filter-artist" placeholder="Artist from music tags" maxlength="256"></label><label>Track title (exact)<input id="filter-title" placeholder="Title from music tags" maxlength="256"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Settings</h2><section class="appearance-settings" aria-labelledby="appearance-heading"><h3 id="appearance-heading">Appearance</h3><label for="background-material">Window background</label><select id="background-material"><option value="solid">Solid</option><option value="glass">Glass</option><option value="frosted">Frosted glass</option></select><div class="appearance-opacity"><label for="background-opacity">Background opacity</label><output id="background-opacity-value" for="background-opacity">90%</output></div><input id="background-opacity" type="range" min="40" max="100" step="1" value="90" aria-describedby="appearance-status"><p id="appearance-status" class="hint" role="status">A subtle window background. Search results and passages stay clear.</p></section><h3>Folders & resources</h3><label class="performance-toggle"><input id="automatic-indexing" type="checkbox" checked>Search my device automatically</label><p class="hint">Your local drives are indexed in the background. Existing results remain searchable when this is off.</p><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i === 1 ? "threads (default)" : i ? "threads" : "thread (low power)"}</option>`).join("")}</select><p class="hint">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.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details><button id="setup-details" class="icon-button" type="button" aria-label="Local search setup" title="Local search setup">${icon("sparkles")}</button><button id="hide-launcher" class="icon-button" type="button" aria-label="Hide searchi" title="Hide searchi">${icon("dismiss")}</button></div></header>
<main> <main>
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form> <form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form>
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><p id="speech-status" class="hint" role="status" hidden></p><progress id="speech-progress" aria-label="Local speech model setup progress" hidden></progress><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details> <details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><p id="speech-status" class="hint" role="status" hidden></p><progress id="speech-progress" aria-label="Local speech model setup progress" hidden></progress><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
<form id="folder-form" hidden><label for="folder">Folder to index</label><div class="folder-row"><input id="folder" placeholder="Absolute folder path" required><button id="browse-folder" type="button">Browse…</button><button type="submit">Index folder</button><button id="cancel-folder" type="button">Cancel</button></div><p class="hint">Only choose folders you want searched. Large folders may take a while.</p></form> <form id="folder-form" hidden><label for="folder">Folder to index</label><div class="folder-row"><input id="folder" placeholder="Absolute folder path" required><button id="browse-folder" type="button">Browse…</button><button type="submit">Index folder</button><button id="cancel-folder" type="button">Cancel</button></div><p class="hint">Only choose folders you want searched. Large folders may take a while.</p></form>
<p id="notice" role="status" aria-live="polite"></p><details id="performance-panel" hidden><summary>Performance details</summary><div id="performance-detail"></div><button id="copy-performance" type="button">Copy local timing report</button><span id="copy-performance-status" role="status"></span></details><div class="workspace"><section class="results-pane" aria-label="Search results"><p id="result-summary" class="eyebrow">READY WHEN YOU ARE</p><ul id="results" aria-label="Matching files"></ul><div id="empty">${icon("search")}<h2>Find what matters. Locally.</h2><p>Index a folder, then search a filename, phrase, or what you remember.</p></div></section><aside id="preview" aria-label="Result preview" hidden><div class="preview-header"><span id="preview-icon" class="file-icon"></span><div class="preview-heading"><h2 id="preview-title"></h2><p id="preview-path" class="path"></p></div></div><div class="preview-toolbar"><span id="preview-kind"></span><div class="preview-actions"><button id="open-result" type="button">${icon("open")}<span>Open file</span></button><button id="reveal-result" type="button" title="Show in folder" aria-label="Show in folder">${icon("folder")}</button></div></div><details id="match-details" class="match-details"><summary>Why this result?</summary><p id="match-reason"></p><p id="match-legend"></p></details><div id="media-preview" class="media-preview" hidden></div><p id="media-preview-status" class="hint" role="status" hidden></p><details id="ai-panel" class="ai-panel" hidden><summary>Summarize or ask</summary><p id="ai-disclosure" class="hint"></p><p class="eyebrow">PASSAGE THAT WILL BE SENT</p><pre id="ai-source-text"></pre><div class="ai-actions"><button id="ai-summarize" type="button" disabled>Summarize this passage</button><label class="sr-only" for="ai-question">Question about this passage</label><input id="ai-question" placeholder="Ask about this passage…" maxlength="2048"><button id="ai-ask" type="button" disabled>Ask</button></div><p id="ai-status" class="hint" role="status"></p><pre id="ai-answer"></pre><p id="ai-answer-label" class="hint"></p><button id="ai-citation" type="button" hidden>Selected source</button></details><div class="passage"><p id="passage-label" class="eyebrow">EXTRACTED PASSAGE</p><pre id="preview-text"></pre></div></aside></div> <p id="notice" role="status" aria-live="polite"></p><details id="performance-panel" hidden><summary>Performance details</summary><div id="performance-detail"></div><button id="copy-performance" type="button">Copy local timing report</button><span id="copy-performance-status" role="status"></span></details><div class="workspace"><section class="results-pane" aria-label="Search results"><p id="result-summary" class="eyebrow">READY WHEN YOU ARE</p><ul id="application-results" aria-label="Applications" hidden></ul><ul id="results" aria-label="Matching files"></ul><div id="empty">${icon("search")}<h2>Find what matters. Locally.</h2><p>Your device is indexed in the background. Search an application, filename, phrase, or what you remember.</p></div></section><aside id="application-preview" aria-label="Application preview" hidden><div class="application-detail"><img src="/searchi-symbol.png" alt="" width="48" height="48"><h2 id="application-name"></h2><p class="hint">Installed application</p><button id="launch-application" type="button">${icon("open")} Launch application</button></div></aside><aside id="preview" aria-label="Result preview" hidden><div class="preview-header"><span id="preview-icon" class="file-icon"></span><div class="preview-heading"><h2 id="preview-title"></h2><p id="preview-path" class="path"></p></div></div><div class="preview-toolbar"><span id="preview-kind"></span><div class="preview-actions"><button id="open-result" type="button">${icon("open")}<span>Open file</span></button><button id="reveal-result" type="button" title="Show in folder" aria-label="Show in folder">${icon("folder")}</button></div></div><details id="match-details" class="match-details"><summary>Why this result?</summary><p id="match-reason"></p><p id="match-legend"></p></details><div id="media-preview" class="media-preview" hidden></div><p id="media-preview-status" class="hint" role="status" hidden></p><details id="ai-panel" class="ai-panel" hidden><summary>Summarize or ask</summary><p id="ai-disclosure" class="hint"></p><p class="eyebrow">PASSAGE THAT WILL BE SENT</p><pre id="ai-source-text"></pre><div class="ai-actions"><button id="ai-summarize" type="button" disabled>Summarize this passage</button><label class="sr-only" for="ai-question">Question about this passage</label><input id="ai-question" placeholder="Ask about this passage…" maxlength="2048"><button id="ai-ask" type="button" disabled>Ask</button></div><p id="ai-status" class="hint" role="status"></p><pre id="ai-answer"></pre><p id="ai-answer-label" class="hint"></p><button id="ai-citation" type="button" hidden>Selected source</button></details><div class="passage"><p id="passage-label" class="eyebrow">EXTRACTED PASSAGE</p><pre id="preview-text"></pre></div></aside></div>
</main><footer><span class="footer-privacy">${icon("lock")}<span id="privacy-summary">Private search. Everything stays on your device.</span><span id="privacy" class="privacy">Local</span></span><span class="keyboard-hints"><kbd>${icon("arrowUp")}</kbd><kbd>${icon("arrowDown")}</kbd><span>Navigate</span><kbd>Enter</kbd><span>Open</span><kbd>Esc</kbd><span>Clear</span></span></footer>`; </main><footer><span class="footer-privacy">${icon("lock")}<span id="privacy-summary">Private search. Everything stays on your device.</span><span id="privacy" class="privacy">Local</span></span><span class="keyboard-hints"><kbd>${icon("arrowUp")}</kbd><kbd>${icon("arrowDown")}</kbd><span>Navigate</span><kbd>Enter</kbd><span>Open</span><kbd>Esc</kbd><span>${isNativeDesktop() ? "Hide" : "Clear"}</span></span></footer>`;
const element = <T extends HTMLElement>(id: string) => const element = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T; document.getElementById(id) as T;
const query = element<HTMLInputElement>("query"); const query = element<HTMLInputElement>("query");
const list = element<HTMLUListElement>("results"); const list = element<HTMLUListElement>("results");
const notice = element<HTMLParagraphElement>("notice"); const notice = element<HTMLParagraphElement>("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<HTMLDetailsElement>("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<HTMLDetailsElement>("semantic-panel");
panel.open = !panel.open;
});
document
.querySelectorAll<HTMLDetailsElement>(".popover, #semantic-panel")
.forEach((panel) => {
panel.addEventListener("toggle", syncLauncher);
});
new MutationObserver(syncLauncher).observe(element("folder-form"), {
attributes: true,
attributeFilter: ["hidden"],
});
try { try {
element<HTMLInputElement>("performance-toggle").checked = element<HTMLInputElement>("performance-toggle").checked =
localStorage.getItem("searchi-performance-details") === "true"; localStorage.getItem("searchi-performance-details") === "true";
@@ -180,6 +248,130 @@ try {
/* Diagnostics remain opt-in when storage is unavailable. */ /* Diagnostics remain opt-in when storage is unavailable. */
} }
let results: SearchResult[] = []; 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<HTMLButtonElement>("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<ApplicationResult[]>("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<HTMLInputElement>("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 displayedLiteralQuery = literalQuery("");
let displayedCodeScope = false; let displayedCodeScope = false;
let explainedSelection = ""; let explainedSelection = "";
@@ -318,7 +510,13 @@ const resultRows = new Map<
const automaticIndex = new AutomaticIndexGate(); const automaticIndex = new AutomaticIndexGate();
function refreshEmptySearchState() { function refreshEmptySearchState() {
if (!latestSearch || latestSearch.id !== requestId || results.length) return; if (
!latestSearch ||
latestSearch.id !== requestId ||
results.length ||
applications.length
)
return;
if (!latestSearch.query.trim()) { if (!latestSearch.query.trim()) {
element("empty").querySelector("h2")!.textContent = element("empty").querySelector("h2")!.textContent =
"No matching music tags."; "No matching music tags.";
@@ -439,7 +637,8 @@ async function refreshSemanticStatus() {
else if (semanticAvailable(status) && status.chunks > 0) 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` : ""}`; 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) 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 else
message = message =
"Preparing local semantic search… Keyword search remains available."; "Preparing local semantic search… Keyword search remains available.";
@@ -592,8 +791,15 @@ element("background-material").addEventListener("change", () => {
void saveAppearance(); void saveAppearance();
}); });
element("background-opacity").addEventListener("input", () => { element("background-opacity").addEventListener("input", () => {
element("background-opacity-value").textContent = const opacity = element<HTMLInputElement>("background-opacity").value;
`${element<HTMLInputElement>("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", () => { element("background-opacity").addEventListener("change", () => {
void saveAppearance(); void saveAppearance();
@@ -616,6 +822,9 @@ async function refreshDesktopStatus(restoreAppearanceControls = false) {
restoreAppearanceControls, restoreAppearanceControls,
); );
indexingActive = status.indexing; indexingActive = status.indexing;
element<HTMLInputElement>("automatic-indexing").checked =
status.settings.automatic_indexing ?? true;
element<HTMLInputElement>("automatic-indexing").disabled = !isNativeDesktop();
element("shortcut-status").textContent = status.shortcut_status; element("shortcut-status").textContent = status.shortcut_status;
element("focus-shortcut").textContent = status.shortcut_status.includes( element("focus-shortcut").textContent = status.shortcut_status.includes(
"Super", "Super",
@@ -671,7 +880,7 @@ async function refreshDesktopStatus(restoreAppearanceControls = false) {
} }
if (status.indexing) if (status.indexing)
element("index-status").textContent = element("index-status").textContent =
"Indexing active · Search remains available"; "Searching your device · Ready to search";
return status; return status;
} }
@@ -885,6 +1094,14 @@ element("ai-citation").addEventListener("click", () => {
}); });
function select(index: number, focus = false, userChoice = true) { function select(index: number, focus = false, userChoice = true) {
if (!results.length) return; 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)); selected = Math.max(0, Math.min(index, results.length - 1));
const result = results[selected]; const result = results[selected];
if (userChoice) resultSelection.choose(result.path); if (userChoice) resultSelection.choose(result.path);
@@ -992,7 +1209,7 @@ function render() {
resultRows.delete(key); resultRows.delete(key);
} }
} }
element("empty").hidden = results.length > 0; element("empty").hidden = results.length > 0 || applications.length > 0;
if (!results.length) { if (!results.length) {
element("preview").hidden = true; element("preview").hidden = true;
previewPath = null; previewPath = null;
@@ -1120,7 +1337,8 @@ function applyResults(
const preserved = resultSelection.resolve( const preserved = resultSelection.resolve(
results.map((result) => result.path), 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(); if (!found.length) refreshEmptySearchState();
request.measurement.displayed( request.measurement.displayed(
lane, lane,
@@ -1330,6 +1548,8 @@ function invalidateSearch() {
} }
async function search(inputAt = performance.now()) { async function search(inputAt = performance.now()) {
syncLauncher();
void refreshApplications().catch(showError);
invalidateSearch(); invalidateSearch();
querySemanticFailed = false; querySemanticFailed = false;
shownLane = "lexical"; shownLane = "lexical";
@@ -1348,7 +1568,7 @@ async function search(inputAt = performance.now()) {
element("empty").querySelector("h2")!.textContent = element("empty").querySelector("h2")!.textContent =
"Find what matters. Locally."; "Find what matters. Locally.";
element("empty").querySelector("p")!.textContent = 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; return;
} }
resultSelection.begin(JSON.stringify([value, filters])); resultSelection.begin(JSON.stringify([value, filters]));
@@ -1384,6 +1604,13 @@ async function refreshStats() {
} }
query.addEventListener("input", () => { 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(); const inputAt = performance.now();
invalidateSearch(); invalidateSearch();
timer = setTimeout(() => void search(inputAt), 60); timer = setTimeout(() => void search(inputAt), 60);
@@ -1579,7 +1806,35 @@ document.addEventListener("keydown", (event) => {
void search(); void search();
query.focus(); 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 ( if (
event.key === "Enter" && event.key === "Enter" &&
results[selected] && results[selected] &&
@@ -1590,12 +1845,15 @@ document.addEventListener("keydown", (event) => {
} }
if (event.key === "ArrowDown" || event.key === "ArrowUp") { if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault(); event.preventDefault();
select(selected + (event.key === "ArrowDown" ? 1 : -1), true); select(selected + (event.key === "ArrowDown" ? 1 : -1));
query.focus();
} }
} }
}); });
async function initializeSurface() { async function initializeSurface() {
await initializeDesktopSurface(); await initializeDesktopSurface();
document.documentElement.dataset.nativeLauncher = String(isNativeDesktop());
syncLauncher();
renderAppearance(); renderAppearance();
if (isNativeDesktop()) { if (isNativeDesktop()) {
appearanceUpdates.submit(highContrast.matches); appearanceUpdates.submit(highContrast.matches);
+186 -13
View File
@@ -37,6 +37,7 @@
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
/* The resident launcher starts as a search bar and expands for useful content. */
body { body {
margin: 0; margin: 0;
} }
@@ -401,7 +402,7 @@ summary::-webkit-details-marker {
overflow: hidden; overflow: hidden;
background: var(--surface); background: var(--surface);
} }
.workspace:has(aside[hidden]) { .workspace:has(#preview[hidden]):has(#application-preview[hidden]) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.results-pane { .results-pane {
@@ -960,12 +961,7 @@ aside pre.code-passage {
background: rgba(248, 250, 252, var(--window-opacity, 0.9)); background: rgba(248, 250, 252, var(--window-opacity, 0.9));
} }
:root[data-background-material="frosted"] #app { :root[data-background-material="frosted"] #app {
background: rgba( background: rgba(248, 250, 252, var(--window-opacity, 0.9));
248,
250,
252,
calc(0.35 + var(--window-opacity, 0.9) * 0.65)
);
} }
.appearance-settings { .appearance-settings {
padding-bottom: 16px; padding-bottom: 16px;
@@ -1008,12 +1004,7 @@ aside pre.code-passage {
background: rgba(11, 15, 20, var(--window-opacity, 0.9)); background: rgba(11, 15, 20, var(--window-opacity, 0.9));
} }
:root[data-background-material="frosted"] #app { :root[data-background-material="frosted"] #app {
background: rgba( background: rgba(11, 15, 20, var(--window-opacity, 0.9));
11,
15,
20,
calc(0.35 + var(--window-opacity, 0.9) * 0.65)
);
} }
} }
@media (forced-colors: active) { @media (forced-colors: active) {
@@ -1032,3 +1023,185 @@ aside pre.code-passage {
> summary { > summary {
--muted: var(--ink); --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;
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ test('high contrast stays opaque without losing the preference', () => {
test('fallback is honest and shell tint is bounded', () => { test('fallback is honest and shell tint is bounded', () => {
const fallback = {...glass(), effective_material:'solid', native_effect:false, fallback_reason:'Native effects unavailable.'}; const fallback = {...glass(), effective_material:'solid', native_effect:false, fallback_reason:'Native effects unavailable.'};
assert.match(appearanceView(fallback, false).message, /Solid background/); 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:110}, false).opacity, 100);
assert.equal(appearanceView({...glass(), opacity:NaN}, false).opacity, 90); assert.equal(appearanceView({...glass(), opacity:NaN}, false).opacity, 90);
}); });
+142 -5
View File
@@ -645,6 +645,7 @@ impl SearchIndex {
} }
Ok(_) => {} Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, 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"), Err(error) => return Err(error).context("Inspect targeted path ancestor"),
} }
} }
@@ -684,6 +685,11 @@ impl SearchIndex {
check_index_cancelled(cancelled)?; check_index_cancelled(cancelled)?;
match fs::symlink_metadata(scope) { match fs::symlink_metadata(scope) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, 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"), Err(error) => return Err(error).context("Inspect reconciliation scope"),
Ok(metadata) if metadata.file_type().is_symlink() => { Ok(metadata) if metadata.file_type().is_symlink() => {
bail!("Reconciliation scope became a symlink") bail!("Reconciliation scope became a symlink")
@@ -731,8 +737,26 @@ impl SearchIndex {
}) })
{ {
check_index_cancelled(cancelled)?; check_index_cancelled(cancelled)?;
let entry = let entry = match entry {
entry.context("directory traversal failed; reconciliation rolled back")?; 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() { if !entry.file_type().is_file() {
continue; continue;
} }
@@ -751,7 +775,18 @@ impl SearchIndex {
let filename = entry.file_name().to_string_lossy().into_owned(); let filename = entry.file_name().to_string_lossy().into_owned();
transaction.execute("INSERT INTO seen_paths(path) VALUES (?1)", [&path])?; transaction.execute("INSERT INTO seen_paths(path) VALUES (?1)", [&path])?;
report.discovered += 1; 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 size = i64::try_from(metadata.len()).context("file exceeds supported size")?;
let mtime = searchi_media::unix_time_ns(metadata.modified()?)?; let mtime = searchi_media::unix_time_ns(metadata.modified()?)?;
let extension = entry let extension = entry
@@ -771,6 +806,10 @@ impl SearchIndex {
// metadata is unchanged. This detects same-stat overwrites while // metadata is unchanged. This detects same-stat overwrites while
// unsupported media/giant files retain the metadata-only fast path. // unsupported media/giant files retain the metadata-only fast path.
let input = extraction::read_source(entry.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 // A parser rejection may retain a filename-only record, but a
// filesystem read failure cannot certify this reconciliation. // filesystem read failure cannot certify this reconciliation.
if input if input
@@ -781,7 +820,14 @@ impl SearchIndex {
.with_context(|| format!("read {path}; reconciliation rolled back")); .with_context(|| format!("read {path}; reconciliation rolled back"));
} }
if matches!(&input, Ok(Some(_))) { 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()? { if after.len() != metadata.len() || after.modified()? != metadata.modified()? {
bail!("Source changed during extraction read; retry indexing: {path}; reconciliation rolled back"); bail!("Source changed during extraction read; retry indexing: {path}; reconciliation rolled back");
} }
@@ -840,7 +886,15 @@ impl SearchIndex {
check_index_cancelled(cancelled)?; check_index_cancelled(cancelled)?;
if matches!(extension.as_str(), "pdf" | "docx") { if matches!(extension.as_str(), "pdf" | "docx") {
if let Some(hash) = &source_hash { 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() { if extracted.is_none() {
@@ -1536,6 +1590,32 @@ fn substring_anchor(characters: &[char], frequencies: &[(i64, String)]) -> Strin
characters[start..end].iter().collect() characters[start..end].iter().collect()
} }
fn permission_denied(error: &anyhow::Error) -> bool {
error.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.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<()> { fn check_index_cancelled(cancelled: Option<&AtomicBool>) -> Result<()> {
if cancelled.is_some_and(|flag| flag.load(Ordering::Acquire)) { if cancelled.is_some_and(|flag| flag.load(Ordering::Acquire)) {
bail!("Index reconciliation cancelled; transaction rolled back"); bail!("Index reconciliation cancelled; transaction rolled back");
@@ -1560,6 +1640,63 @@ fn validate_normalized_absolute(path: &Path) -> Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { 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<String> = transaction
.prepare("SELECT path FROM seen_paths ORDER BY path")?
.query_map([], |row| row.get(0))?
.collect::<rusqlite::Result<_>>()?;
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] #[test]
fn legacy_metadata_migration_preserves_keyword_search_without_inventing_fields( fn legacy_metadata_migration_preserves_keyword_search_without_inventing_fields(
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
+83 -2
View File
@@ -11,6 +11,7 @@ pub use speech::{speech_policy_key, BudgetedSpeechProvider, SpeechExcerpt, Speec
mod prepared; mod prepared;
pub use prepared::PreparedGraphs; pub use prepared::PreparedGraphs;
mod pipeline; mod pipeline;
mod relevance;
mod routing; mod routing;
pub use routing::SemanticOrigin; pub use routing::SemanticOrigin;
mod store; mod store;
@@ -280,6 +281,12 @@ pub struct HybridSearch {
} }
impl HybridSearch { impl HybridSearch {
/// Prepare storage before background discovery starts. No model is loaded.
pub fn prepare_database(db: impl AsRef<Path>) -> 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. /// Attach shared admission without replacing the warmed model or query LRU.
pub fn with_budget( pub fn with_budget(
mut self, mut self,
@@ -485,7 +492,11 @@ impl HybridSearch {
/// Execute real query inference and load the active ANN generation before /// Execute real query inference and load the active ANN generation before
/// foreground use. Call on a background worker after assets are verified. /// foreground use. Call on a background worker after assets are verified.
pub fn warmup(&mut self) -> Result<()> { 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 vector = self.embedder.embed_query("warm up local file search")?;
let _ = self.store.search( let _ = self.store.search(
&vector, &vector,
@@ -580,8 +591,14 @@ impl HybridSearch {
promote_identifiers(query.trim(), &mut lexical) promote_identifiers(query.trim(), &mut lexical)
}); });
let vector = self.query_vector(query, options.code_only, trace, parent)?; 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)?; 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| { let results = trace.timed("hybrid.rank_fusion", parent, |trace, span| {
trace.counter(span, "lexical_files", lexical.len() as u64); trace.counter(span, "lexical_files", lexical.len() as u64);
trace.counter(span, "semantic_files", semantic.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::<rusqlite::Error>(), Some(rusqlite::Error::SqliteFailure(error, _))
if matches!(error.code, rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked))
}
fn bounded_title(title: &str) -> &str { fn bounded_title(title: &str) -> &str {
let mut end = title.len().min(512); let mut end = title.len().min(512);
while !title.is_char_boundary(end) { while !title.is_char_boundary(end) {
@@ -995,3 +1017,62 @@ mod tests {
#[cfg(test)] #[cfg(test)]
mod coverage_tests; mod coverage_tests;
#[cfg(test)]
mod startup_lock_tests {
use super::*;
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
struct QueryProbe(Arc<AtomicUsize>);
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<Vec<TextWindow>> {
anyhow::bail!("unused")
}
fn embed_documents(&mut self, _: &[&str], _: &str) -> Result<Vec<Vec<f32>>> {
anyhow::bail!("unused")
}
fn embed_query(&mut self, _: &str) -> Result<Vec<f32>> {
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(())
}
}
+101
View File
@@ -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<SearchResult>) -> 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<SearchResult> {
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");
}
}
+67 -16
View File
@@ -24,7 +24,7 @@ impl SemanticOrigin {
} }
/// A conservative intent hint, not a language understanding model. Explicit /// 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. /// silently restrict modality. Word boundaries avoid filename substrings.
fn preferred_origin(query: &str) -> Option<SemanticOrigin> { fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
if query.split_whitespace().count() == 1 && Path::new(query).extension().is_some() { if query.split_whitespace().count() == 1 && Path::new(query).extension().is_some() {
@@ -37,18 +37,10 @@ fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
.collect(); .collect();
let has = |values: &[&str]| words.iter().any(|word| values.contains(word)); let has = |values: &[&str]| words.iter().any(|word| values.contains(word));
if has(&[ if has(&[
"image",
"photo",
"picture",
"screenshot",
"diagram",
"document", "document",
"pdf", "pdf",
"text", "text",
"paper", "paper",
"bild",
"foto",
"screenshot",
"dokument", "dokument",
"textdatei", "textdatei",
"code", "code",
@@ -57,9 +49,33 @@ fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
"quellcode", "quellcode",
"funktion", "funktion",
"implementierung", "implementierung",
"algorithm",
"algorithms",
"algorithmus",
"algorithmen",
]) { ]) {
return None; 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(&[ if has(&[
"spoken", "spoken",
"speech", "speech",
@@ -86,7 +102,6 @@ fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
if has(&[ if has(&[
"audio", "audio",
"recording", "recording",
"sound",
"music", "music",
"geräusch", "geräusch",
"geraeusch", "geraeusch",
@@ -98,9 +113,13 @@ fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
"hoerbarer", "hoerbarer",
"hoerbare", "hoerbare",
"tonaufnahme", "tonaufnahme",
]) { ]) || (has(&["sound"]) && has(&["of", "effect", "effects"]))
{
return Some(SemanticOrigin::Audio); return Some(SemanticOrigin::Audio);
} }
if video {
return Some(SemanticOrigin::Video);
}
None None
} }
@@ -130,7 +149,7 @@ impl HybridSearch {
} else { } else {
preferred_origin(query) preferred_origin(query)
} { } {
trace.counter(parent, "audio_preference", 1); trace.counter(parent, "explicit_source_channel", 1);
let preferred = self.store.search_origin_traced( let preferred = self.store.search_origin_traced(
vector, vector,
&self.generation_fingerprint, &self.generation_fingerprint,
@@ -157,6 +176,12 @@ impl HybridSearch {
return Ok(raw); 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); trace.counter(parent, "general_lane", 1);
self.store.search_origin_traced( self.store.search_origin_traced(
@@ -226,7 +251,7 @@ mod tests {
} }
#[test] #[test]
fn explicit_spoken_intent_is_conservative_and_bilingual() { fn explicit_media_intent_is_conservative_and_bilingual() {
for query in [ for query in [
"where someone mentions a contract", "where someone mentions a contract",
"a speaker says the date", "a speaker says the date",
@@ -234,6 +259,7 @@ mod tests {
"jemand erwähnt das Budget", "jemand erwähnt das Budget",
"Wo spricht jemand über Kündigung?", "Wo spricht jemand über Kündigung?",
"Gespräch über die neue Wohnung", "Gespräch über die neue Wohnung",
"video where someone mentions a contract",
] { ] {
assert_eq!( assert_eq!(
preferred_origin(query), preferred_origin(query),
@@ -241,6 +267,29 @@ mod tests {
"{query}" "{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 [ for query in [
"audio of a waterfall", "audio of a waterfall",
"hörbarer Regen", "hörbarer Regen",
@@ -256,17 +305,19 @@ mod tests {
for query in [ for query in [
"contract deadline", "contract deadline",
"violin solo", "violin solo",
"photo of someone speaking",
"Bild von einem Sprecher",
"a document that someone mentioned", "a document that someone mentioned",
"speech.png", "speech.png",
"a screenshot of an audio waveform",
"code that chooses audio search when a query mentions speech", "code that chooses audio search when a query mentions speech",
"an implementation for identifying music", "an implementation for identifying music",
"a function that decodes audio samples", "a function that decodes audio samples",
"Quellcode für die Suche nach gesprochenen Themen", "Quellcode für die Suche nach gesprochenen Themen",
"eine Funktion für die Verarbeitung von Musik", "eine Funktion für die Verarbeitung von Musik",
"Implementierung für die Erkennung von Geräuschen", "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}"); assert_eq!(preferred_origin(query), None, "{query}");
} }
+176
View File
@@ -18,6 +18,99 @@ use usearch::{Index, IndexOptions, MetricKind, ScalarKind};
pub(crate) const MAX_SCAN_CHUNKS: usize = 100_000; pub(crate) const MAX_SCAN_CHUNKS: usize = 100_000;
pub(crate) const MIN_COSINE: f32 = 0.25; pub(crate) const MIN_COSINE: f32 = 0.25;
const SCHEMA_VERSION: i64 = 9;
fn current_schema_ready(connection: &Connection) -> Result<bool> {
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::<rusqlite::Result<HashSet<_>>>()?;
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. // 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<i64> { pub(crate) fn media_passage_id(file_id: i64, source_type: &str) -> Result<i64> {
@@ -122,6 +215,28 @@ impl Store {
let mut connection = Connection::open(db)?; let mut connection = Connection::open(db)?;
connection.busy_timeout(std::time::Duration::from_secs(5))?; connection.busy_timeout(std::time::Duration::from_secs(5))?;
connection.execute_batch("PRAGMA foreign_keys=ON;")?; connection.execute_batch("PRAGMA foreign_keys=ON;")?;
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 = let initialization =
connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
initialization.execute_batch( initialization.execute_batch(
@@ -222,6 +337,11 @@ impl Store {
} }
incremental::migrate(&initialization)?; incremental::migrate(&initialization)?;
initialization.commit()?; initialization.commit()?;
ensure!(
current_schema_ready(&connection)?,
"Semantic schema initialization incomplete"
);
}
let canonical = db.canonicalize()?; let canonical = db.canonicalize()?;
let lock_path = PathBuf::from(format!("{}.semantic.lock", canonical.to_string_lossy())); let lock_path = PathBuf::from(format!("{}.semantic.lock", canonical.to_string_lossy()));
Ok(Self { Ok(Self {
@@ -2068,3 +2188,59 @@ mod tests {
#[cfg(test)] #[cfg(test)]
#[path = "ann_benchmark.rs"] #[path = "ann_benchmark.rs"]
mod ann_benchmark; 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(())
}
}
@@ -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<PathBuf> {
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
}
@@ -3,6 +3,69 @@ use searchi_core::SearchIndex;
use searchi_service::{FfmpegTools, HybridSearch, MediaLimits, SearchOptions, SemanticOrigin}; use searchi_service::{FfmpegTools, HybridSearch, MediaLimits, SearchOptions, SemanticOrigin};
use std::{fs, path::PathBuf}; 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] #[test]
#[ignore = "Requires verified Google CPU worker/model and FFmpeg fixtures"] #[ignore = "Requires verified Google CPU worker/model and FFmpeg fixtures"]
fn explicit_code_intent_preserves_code_among_real_audio_candidates() -> Result<()> { fn explicit_code_intent_preserves_code_among_real_audio_candidates() -> Result<()> {
+26
View File
@@ -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.
+5 -3
View File
@@ -48,13 +48,15 @@ copy into unsupported performance or production-readiness claims.
## Window materials ## Window materials
Appearance settings offer Solid, Glass and Frosted glass, with a bounded Appearance settings offer Solid, Glass and Frosted glass, with a bounded
70–100% tint-strength control (90% by default). Frosted glass layers an extra 40–100% background-opacity control (90% by default). Both materials use the selected opacity directly, without a second milk-tint
35% milk tint beneath that control; Glass has the lighter direct tint. Both layer. The slider previews changes while dragging. Both
currently share Windows native acrylic blur. Native compositor effects expose and soften currently share Windows native acrylic blur. Native compositor effects expose and soften
the actual desktop; CSS transparency alone cannot blur other windows. If the the actual desktop; CSS transparency alone cannot blur other windows. If the
platform cannot apply its native effect, use an honest opaque fallback. Forced platform cannot apply its native effect, use an honest opaque fallback. Forced
colors always make the shell opaque while retaining the saved preference. 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. never lower text opacity or introduce decorative wallpaper simulations.
Native acrylic currently requires Windows 11 22H2 or later with a compatible Native acrylic currently requires Windows 11 22H2 or later with a compatible
compositor and system transparency settings. macOS, Linux and older Windows compositor and system transparency settings. macOS, Linux and older Windows
+53
View File
@@ -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).
+8 -4
View File
@@ -1,9 +1,13 @@
# Linux desktop release build evidence # 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). 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 [current package checks](LINUX-0.1.4.md) and See [final package checks](NATIVE-FINAL-VALIDATION.md) and
[checksums](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.4/LINUX-DESKTOP-SHA256SUMS-0.1.4.txt). [checksum](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.8/searchi_0.1.8_amd64.deb.sha256).
The evidence below records the preceding 0.1.3 preparation. 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 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). 6.18.54.1-microsoft-standard-WSL2, two virtual CPU cores (AMD Ryzen 7 7800X3D).
+136
View File
@@ -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.
+320
View File
@@ -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`.
+48 -1
View File
@@ -1,6 +1,53 @@
# Production readiness roadmap # 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 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 install it on DEV-WIN11 now. Extensive platform/GPU, clean-machine, signing and
+83
View File
@@ -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.
+165 -96
View File
@@ -1,9 +1,13 @@
{ {
"kind": "browser-rendered-production-ui-real-cpu-engine", "kind": "browser-rendered-production-ui-real-cpu-engine",
"source": { "source": {
"base_commit": "9870df7cb5b537ba516997a0d049b996bc30b08d", "base_commit": "01279a6b25b545d0920889bf277abe00631a666d",
"working_tree_dirty": true "working_tree_dirty": true
}, },
"frontend_build": {
"version": "0.1.8",
"index_sha256": "8415df31094999f0d8bae5a6eb06db160132f1dc4657d1a329a24a6125defe0b"
},
"viewport": { "viewport": {
"width": 1440, "width": 1440,
"height": 1000 "height": 1000
@@ -33,11 +37,11 @@
{ {
"query": "cancel the supplier partnership", "query": "cancel the supplier partnership",
"mode": "basic", "mode": "basic",
"result_count": 7, "result_count": 1,
"metrics": { "metrics": {
"dropped_spans": 0, "dropped_spans": 0,
"result_count": 7, "result_count": 1,
"total_ms": 2.0688999999999997, "total_ms": 73.3586,
"trace": null "trace": null
}, },
"first_filename": "Supplier Agreement.pdf", "first_filename": "Supplier Agreement.pdf",
@@ -48,17 +52,18 @@
"ui_checks": { "ui_checks": {
"literal_query_marks": true, "literal_query_marks": true,
"actual_page_explanation": 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", "query": "mountains reflected in calm water",
"mode": "basic", "mode": "basic",
"result_count": 7, "result_count": 2,
"metrics": { "metrics": {
"dropped_spans": 0, "dropped_spans": 0,
"result_count": 7, "result_count": 2,
"total_ms": 1.5953, "total_ms": 76.9483,
"trace": null "trace": null
}, },
"first_filename": "Mountain Lake.jpg", "first_filename": "Mountain Lake.jpg",
@@ -67,40 +72,41 @@
"number": 0 "number": 0
}, },
"ui_checks": { "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", "query": "how can we cancel the supplier partnership",
"mode": "debug", "mode": "debug",
"result_count": 7, "result_count": 1,
"metrics": { "metrics": {
"dropped_spans": 0, "dropped_spans": 0,
"result_count": 7, "result_count": 1,
"total_ms": 92.8279, "total_ms": 90.92160000000001,
"trace": [ "trace": [
{ {
"counters": {}, "counters": {},
"duration_ms": 92.80539999999999, "duration_ms": 90.8989,
"name": "hybrid.search", "name": "hybrid.search",
"parent": null, "parent": null,
"start_ms": 0.0106 "start_ms": 0.0108
}, },
{ {
"counters": {}, "counters": {},
"duration_ms": 2.6003, "duration_ms": 1.8246,
"name": "lexical.search", "name": "lexical.search",
"parent": 0, "parent": 0,
"start_ms": 0.027 "start_ms": 0.0306
}, },
{ {
"counters": { "counters": {
"query_bytes": 42 "query_bytes": 42
}, },
"duration_ms": 0.0389, "duration_ms": 0.0422,
"name": "lexical.normalization", "name": "lexical.normalization",
"parent": 1, "parent": 1,
"start_ms": 0.0374 "start_ms": 0.041100000000000005
}, },
{ {
"counters": { "counters": {
@@ -108,148 +114,149 @@
"frequency_cache_misses": 8, "frequency_cache_misses": 8,
"trigram_index": 1 "trigram_index": 1
}, },
"duration_ms": 1.0081, "duration_ms": 0.2968,
"name": "lexical.candidate_plan", "name": "lexical.candidate_plan",
"parent": 1, "parent": 1,
"start_ms": 0.08650000000000001 "start_ms": 0.094
}, },
{ {
"counters": {}, "counters": {},
"duration_ms": 0.2286, "duration_ms": 0.3236,
"name": "lexical.exact_filename", "name": "lexical.exact_filename",
"parent": 1, "parent": 1,
"start_ms": 1.1168 "start_ms": 0.4067
}, },
{ {
"counters": { "counters": {
"filters_active": 0, "filters_active": 0,
"rows": 0 "rows": 0
}, },
"duration_ms": 0.1837, "duration_ms": 0.2841,
"name": "lexical.filtered_sql", "name": "lexical.filtered_sql",
"parent": 4, "parent": 4,
"start_ms": 1.1471 "start_ms": 0.4364
}, },
{ {
"counters": {}, "counters": {},
"duration_ms": 0.2688, "duration_ms": 0.2009,
"name": "lexical.filename_substring", "name": "lexical.filename_substring",
"parent": 1, "parent": 1,
"start_ms": 1.3663 "start_ms": 0.7506999999999999
}, },
{ {
"counters": { "counters": {
"filters_active": 0, "filters_active": 0,
"rows": 0 "rows": 0
}, },
"duration_ms": 0.2249, "duration_ms": 0.17229999999999998,
"name": "lexical.filtered_sql", "name": "lexical.filtered_sql",
"parent": 6, "parent": 6,
"start_ms": 1.3906999999999998 "start_ms": 0.7678999999999999
}, },
{ {
"counters": {}, "counters": {},
"duration_ms": 0.2146, "duration_ms": 0.1716,
"name": "lexical.path_substring", "name": "lexical.path_substring",
"parent": 1, "parent": 1,
"start_ms": 1.6556 "start_ms": 0.9650000000000001
}, },
{ {
"counters": { "counters": {
"filters_active": 0, "filters_active": 0,
"rows": 0 "rows": 0
}, },
"duration_ms": 0.175, "duration_ms": 0.145,
"name": "lexical.filtered_sql", "name": "lexical.filtered_sql",
"parent": 8, "parent": 8,
"start_ms": 1.6802 "start_ms": 0.9821000000000001
}, },
{ {
"counters": {}, "counters": {},
"duration_ms": 0.2391, "duration_ms": 0.229,
"name": "lexical.content", "name": "lexical.content",
"parent": 1, "parent": 1,
"start_ms": 1.8861999999999999 "start_ms": 1.1488
}, },
{ {
"counters": { "counters": {
"filters_active": 0, "filters_active": 0,
"rows": 0 "rows": 0
}, },
"duration_ms": 0.2053, "duration_ms": 0.2014,
"name": "lexical.filtered_sql", "name": "lexical.filtered_sql",
"parent": 10, "parent": 10,
"start_ms": 1.9049 "start_ms": 1.1647
}, },
{ {
"counters": {}, "counters": {},
"duration_ms": 0.3054, "duration_ms": 0.27080000000000004,
"name": "lexical.filename_terms", "name": "lexical.filename_terms",
"parent": 1, "parent": 1,
"start_ms": 2.1413 "start_ms": 1.3904
}, },
{ {
"counters": { "counters": {
"filters_active": 0, "filters_active": 0,
"rows": 0 "rows": 0
}, },
"duration_ms": 0.2773, "duration_ms": 0.2424,
"name": "lexical.filtered_sql", "name": "lexical.filtered_sql",
"parent": 12, "parent": 12,
"start_ms": 2.1576999999999997 "start_ms": 1.4071
}, },
{ {
"counters": {}, "counters": {},
"duration_ms": 0.146, "duration_ms": 0.162,
"name": "lexical.media_metadata", "name": "lexical.media_metadata",
"parent": 1, "parent": 1,
"start_ms": 2.4598 "start_ms": 1.675
}, },
{ {
"counters": { "counters": {
"filters_active": 0, "filters_active": 0,
"rows": 0 "rows": 0
}, },
"duration_ms": 0.112, "duration_ms": 0.1344,
"name": "lexical.filtered_sql", "name": "lexical.filtered_sql",
"parent": 14, "parent": 14,
"start_ms": 2.4749 "start_ms": 1.6907999999999999
}, },
{ {
"counters": {}, "counters": {},
"duration_ms": 0.0155, "duration_ms": 0.015000000000000001,
"name": "hybrid.identifier_priority", "name": "hybrid.identifier_priority",
"parent": 0, "parent": 0,
"start_ms": 2.6382 "start_ms": 1.8684999999999998
}, },
{ {
"counters": { "counters": {
"code_task": 0,
"failed": 0, "failed": 0,
"query_cache_hit": 0, "query_cache_hit": 0,
"query_cache_miss": 1, "query_cache_miss": 1,
"request_encode_us": 18, "request_encode_us": 25,
"request_write_us": 58, "request_write_us": 197,
"response_validate_us": 69, "response_validate_us": 97,
"worker_batch_prepare_us": 19, "worker_batch_prepare_us": 21,
"worker_inference_us": 87311, "worker_inference_us": 86213,
"worker_pool_normalize_us": 14, "worker_pool_normalize_us": 19,
"worker_token_count": 16, "worker_token_count": 16,
"worker_tokenization_us": 173, "worker_tokenization_us": 179,
"worker_wait_and_response_decode_us": 88454 "worker_wait_and_response_decode_us": 87455
}, },
"duration_ms": 89.1821, "duration_ms": 87.9572,
"name": "semantic.query_embedding", "name": "semantic.query_embedding",
"parent": 0, "parent": 0,
"start_ms": 2.6649 "start_ms": 1.8927
}, },
{ {
"counters": { "counters": {
"general_lane": 1 "general_lane": 1
}, },
"duration_ms": 0.8757, "duration_ms": 0.9468,
"name": "semantic.routing", "name": "semantic.routing",
"parent": 0, "parent": 0,
"start_ms": 91.8792 "start_ms": 89.89030000000001
}, },
{ {
"counters": { "counters": {
@@ -257,99 +264,110 @@
"filesystem_checks": 2, "filesystem_checks": 2,
"result_files": 7 "result_files": 7
}, },
"duration_ms": 0.8334, "duration_ms": 0.8935,
"name": "semantic.retrieval", "name": "semantic.retrieval",
"parent": 18, "parent": 18,
"start_ms": 91.90950000000001 "start_ms": 89.93039999999999
}, },
{ {
"counters": {}, "counters": {},
"duration_ms": 0.1384, "duration_ms": 0.14650000000000002,
"name": "semantic.generation_snapshot", "name": "semantic.generation_snapshot",
"parent": 19, "parent": 19,
"start_ms": 91.9247 "start_ms": 89.953
}, },
{ {
"counters": { "counters": {
"cache_hit": 1 "cache_hit": 1
}, },
"duration_ms": 0.0115, "duration_ms": 0.0167,
"name": "semantic.ann_load", "name": "semantic.ann_load",
"parent": 19, "parent": 19,
"start_ms": 92.07310000000001 "start_ms": 90.1134
}, },
{ {
"counters": {}, "counters": {},
"duration_ms": 0.011, "duration_ms": 0.0167,
"name": "semantic.filter_normalization", "name": "semantic.filter_normalization",
"parent": 19, "parent": 19,
"start_ms": 92.0933 "start_ms": 90.14410000000001
}, },
{ {
"counters": { "counters": {
"cache_hit": 1, "cache_hit": 1,
"eligible_chunks": 8 "eligible_chunks": 8
}, },
"duration_ms": 0.0252, "duration_ms": 0.034999999999999996,
"name": "semantic.eligibility", "name": "semantic.eligibility",
"parent": 19, "parent": 19,
"start_ms": 92.1129 "start_ms": 90.1714
}, },
{ {
"counters": { "counters": {
"requested_chunks": 8 "requested_chunks": 8
}, },
"duration_ms": 0.5494, "duration_ms": 0.5680999999999999,
"name": "semantic.ann_pass", "name": "semantic.ann_pass",
"parent": 19, "parent": 19,
"start_ms": 92.1485 "start_ms": 90.2198
}, },
{ {
"counters": { "counters": {
"returned_chunks": 8 "returned_chunks": 8
}, },
"duration_ms": 0.0347, "duration_ms": 0.0356,
"name": "semantic.ann_search", "name": "semantic.ann_search",
"parent": 24, "parent": 24,
"start_ms": 92.161 "start_ms": 90.2333
}, },
{ {
"counters": { "counters": {
"filesystem_checks": 2, "filesystem_checks": 2,
"unique_parents": 7 "unique_parents": 7
}, },
"duration_ms": 0.4802, "duration_ms": 0.4973,
"name": "semantic.parent_materialization", "name": "semantic.parent_materialization",
"parent": 24, "parent": 24,
"start_ms": 92.2066 "start_ms": 90.2796
}, },
{ {
"counters": { "counters": {
"valid": 1 "valid": 1
}, },
"duration_ms": 0.20550000000000002, "duration_ms": 0.193,
"name": "semantic.parent_filesystem_validation", "name": "semantic.parent_filesystem_validation",
"parent": 26, "parent": 26,
"start_ms": 92.30539999999999 "start_ms": 90.38690000000001
}, },
{ {
"counters": { "counters": {
"valid": 1 "valid": 1
}, },
"duration_ms": 0.1146, "duration_ms": 0.1215,
"name": "semantic.parent_filesystem_validation", "name": "semantic.parent_filesystem_validation",
"parent": 26, "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": { "counters": {
"lexical_files": 0, "lexical_files": 0,
"semantic_files": 7 "semantic_files": 1
}, },
"duration_ms": 0.0376, "duration_ms": 0.02,
"name": "hybrid.rank_fusion", "name": "hybrid.rank_fusion",
"parent": 0, "parent": 0,
"start_ms": 92.7663 "start_ms": 90.8783
} }
] ]
}, },
@@ -361,79 +379,130 @@
} }
], ],
"screenshots": [ "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", "file": "search-light.png",
"sha256": "bd45041de13093f7a7e26285618f4c1afc5b081fb41e51ff06e1313216af3562", "sha256": "75fd2ebc9b40dcfb8501ee9ff0e14dc0b3a2cccc223b672f66c6feb4fe5f5858",
"query": "cancel the supplier partnership", "query": "cancel the supplier partnership",
"mode": "basic", "mode": "basic",
"result_count": 7, "result_count": 1,
"selected_filename": "Supplier Agreement.pdf", "selected_filename": "Supplier Agreement.pdf",
"first_source_kind": "page", "first_source_kind": "page",
"first_source_number": 2, "first_source_number": 2,
"theme": "light", "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": { "state_assertions": {
"wide_split_preview": true,
"semantic_ready": true, "semantic_ready": true,
"hybrid_final": true, "hybrid_final": true,
"image_visible": false, "image_visible": false,
"actual_worker_inference": false, "actual_worker_inference": false,
"literal_query_marks": true, "literal_query_marks": true,
"actual_page_explanation": 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", "file": "search-dark.png",
"sha256": "cd7e2aa3f7027cd9b40965171a232f6ee901e1e2b971f04628b4cfcc3b440263", "sha256": "45c3b7f5072d22e4f9c52549727ad63ce122650c2cd105680d40d38b8e0730e1",
"query": "cancel the supplier partnership", "query": "cancel the supplier partnership",
"mode": "basic", "mode": "basic",
"result_count": 7, "result_count": 1,
"selected_filename": "Supplier Agreement.pdf", "selected_filename": "Supplier Agreement.pdf",
"first_source_kind": "page", "first_source_kind": "page",
"first_source_number": 2, "first_source_number": 2,
"theme": "dark", "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": { "state_assertions": {
"wide_split_preview": true,
"semantic_ready": true, "semantic_ready": true,
"hybrid_final": true, "hybrid_final": true,
"image_visible": false, "image_visible": false,
"actual_worker_inference": false, "actual_worker_inference": false,
"literal_query_marks": true, "literal_query_marks": true,
"actual_page_explanation": 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", "file": "media-search.png",
"sha256": "4c278446963cd5dbad073bea93a3ca33fb9c10cb43a9544258d5fb6039014d81", "sha256": "718ffd4e7fa0a21ef717a0773749c0b72a1cbae4968bebb0c26e7f4d68133488",
"query": "mountains reflected in calm water", "query": "mountains reflected in calm water",
"mode": "basic", "mode": "basic",
"result_count": 7, "result_count": 2,
"selected_filename": "Mountain Lake.jpg", "selected_filename": "Mountain Lake.jpg",
"first_source_kind": "image", "first_source_kind": "image",
"first_source_number": 0, "first_source_number": 0,
"theme": "light", "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": { "state_assertions": {
"wide_split_preview": true,
"semantic_ready": true, "semantic_ready": true,
"hybrid_final": true, "hybrid_final": true,
"image_visible": true, "image_visible": true,
"actual_worker_inference": false, "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", "file": "performance-details.png",
"sha256": "4f336d4c42d0c4a50ce33bca27595f02da7d896c8734d447350d50dab5c339df", "sha256": "945dec2b087961a4cfa073983f4ac055c6f7b75c736aaaa5094596ffdd959713",
"query": "how can we cancel the supplier partnership", "query": "how can we cancel the supplier partnership",
"mode": "debug", "mode": "debug",
"result_count": 7, "result_count": 1,
"selected_filename": "Supplier Agreement.pdf", "selected_filename": "Supplier Agreement.pdf",
"first_source_kind": "page", "first_source_kind": "page",
"first_source_number": 2, "first_source_number": 2,
"theme": "light", "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": { "state_assertions": {
"wide_split_preview": true,
"semantic_ready": true, "semantic_ready": true,
"hybrid_final": true, "hybrid_final": true,
"image_visible": false, "image_visible": false,
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 80 KiB

+5 -2
View File
@@ -22,6 +22,7 @@ then warms its retained CPU worker before listening on 127.0.0.1:1431.
```powershell ```powershell
$env:SEARCHI_FORCE_CPU = '1' $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 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 ## 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 media-search.png and performance-details.png, plus capture-manifest.json. The
manifest contains actual fixture queries, counts/selection, measured response manifest contains actual fixture queries, counts/selection, measured response
metrics, device/model, viewport, PNG SHA256, runner/browser versions, Git base metrics, device/model, viewport, PNG SHA256, runner/browser versions, Git base
commit and dirty-state flag. The base commit does not identify uncommitted 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**. Captures are **browser-rendered production UI with the actual CPU engine**.
They omit native window chrome. They do not establish native WebView visual QA, They omit native window chrome. They do not establish native WebView visual QA,
+16
View File
@@ -1129,11 +1129,16 @@ name = "searchi-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc",
"lopdf", "lopdf",
"quick-xml", "quick-xml",
"rusqlite", "rusqlite",
"searchi-media",
"serde", "serde",
"serde_json",
"sha2 0.10.9",
"walkdir", "walkdir",
"windows-sys",
"zip", "zip",
] ]
@@ -1192,11 +1197,22 @@ dependencies = [
"searchi-core", "searchi-core",
"searchi-media", "searchi-media",
"searchi-semantic", "searchi-semantic",
"searchi-speech",
"serde", "serde",
"sha2 0.10.9", "sha2 0.10.9",
"usearch", "usearch",
] ]
[[package]]
name = "searchi-speech"
version = "0.1.0"
dependencies = [
"anyhow",
"serde",
"serde_json",
"sha2 0.10.9",
]
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.229" version = "1.0.229"
+12 -2
View File
@@ -44,6 +44,8 @@ struct Filters {
max_duration_ms: Option<u32>, max_duration_ms: Option<u32>,
modified_after_unix_ms: Option<i64>, modified_after_unix_ms: Option<i64>,
modified_before_unix_ms: Option<i64>, modified_before_unix_ms: Option<i64>,
media_artist: Option<String>,
media_title: Option<String>,
} }
impl Filters { impl Filters {
fn options(self, root: &Path) -> Result<SearchOptions> { fn options(self, root: &Path) -> Result<SearchOptions> {
@@ -63,6 +65,8 @@ impl Filters {
max_duration_ms: self.max_duration_ms, max_duration_ms: self.max_duration_ms,
modified_after_unix_ms: self.modified_after_unix_ms, modified_after_unix_ms: self.modified_after_unix_ms,
modified_before_unix_ms: self.modified_before_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)?; let mut lexical = SearchIndex::open(&args.db)?;
validate_database(&args.db, &root)?; validate_database(&args.db, &root)?;
lexical.index_folder(&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()?; lexical.prewarm()?;
let tools = FfmpegTools::new(args.ffmpeg, args.ffprobe)?; let tools = FfmpegTools::new(args.ffmpeg, args.ffprobe)?;
let mut hybrid = HybridSearch::open(&args.db, &args.models, &args.worker, 1)?; 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}), 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")?; let query = args["query"].as_str().context("Query required")?;
ensure!(query.len() <= 4096, "Query too large"); ensure!(query.len() <= 4096, "Query too large");
let filters: Filters = if args["filters"].is_null() { let filters: Filters = if args["filters"].is_null() {
@@ -297,7 +305,9 @@ impl App {
}; };
let options = filters.options(&self.root)?; let options = filters.options(&self.root)?;
let debug = args["debugPerformance"].as_bool().unwrap_or(false); 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( Ok(serde_json::to_value(
self.lexical self.lexical
.search_with_options_measured(query, 50, &options, debug)?, .search_with_options_measured(query, 50, &options, debug)?,
+41 -2
View File
@@ -92,6 +92,28 @@ try {
.evaluate((image) => image.complete && image.naturalWidth > 0), .evaluate((image) => image.complete && image.naturalWidth > 0),
"Brand asset failed to load", "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) { async function query(text, debug = false) {
const awaited = page.waitForResponse( const awaited = page.waitForResponse(
(response) => { (response) => {
@@ -151,6 +173,10 @@ try {
() => !document.querySelector("#preview")?.hidden, () => !document.querySelector("#preview")?.hidden,
); );
if (text === "cancel the supplier partnership") { 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( assert.equal(
latestMeasured.first_filename, latestMeasured.first_filename,
"Supplier Agreement.pdf", "Supplier Agreement.pdf",
@@ -225,9 +251,12 @@ try {
literal_query_marks: true, literal_query_marks: true,
actual_page_explanation: true, actual_page_explanation: true,
why_disclosure_collapsed_by_default: true, why_disclosure_collapsed_by_default: true,
unrelated_photos_removed: true,
}; };
} }
if (text === "mountains reflected in calm water") { 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( assert.equal(
latestMeasured.first_filename, latestMeasured.first_filename,
"Mountain Lake.jpg", "Mountain Lake.jpg",
@@ -238,7 +267,7 @@ try {
0, 0,
"Meaning-only photo result must not invent word-level alignment", "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( await page.evaluate(
() => () =>
@@ -249,8 +278,12 @@ try {
} }
async function screenshot(name, queryText) { async function screenshot(name, queryText) {
assert.deepEqual(failures, [], "Frontend page errors occurred"); 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`); 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(); const selected = await page.locator("#preview-title").innerText();
assert.equal( assert.equal(
selected, selected,
@@ -272,7 +305,9 @@ try {
matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light", matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
), ),
summary: await page.locator("#result-summary").innerText(), summary: await page.locator("#result-summary").innerText(),
capture_bounds: await page.locator("#app").boundingBox(),
state_assertions: { state_assertions: {
wide_split_preview: true,
semantic_ready: true, semantic_ready: true,
hybrid_final: true, hybrid_final: true,
image_visible: name === "media-search", image_visible: name === "media-search",
@@ -366,6 +401,10 @@ try {
{ {
kind: "browser-rendered-production-ui-real-cpu-engine", kind: "browser-rendered-production-ui-real-cpu-engine",
source, 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 }, viewport: { width: 1440, height: 1000 },
runner: { playwright: "1.64.0", chromium: browser.version() }, runner: { playwright: "1.64.0", chromium: browser.version() },
backend: { backend: {
+2 -1
View File
@@ -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" "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" "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" "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") "This is an unsigned development build, not a certified production release.\n")
if cli is not None: if cli is not None:
archive.writestr("AGENT-SETUP.txt", archive.writestr("AGENT-SETUP.txt",
@@ -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"]
}