Ship floating launcher, automatic discovery and bounded media search improvements
No files matched your search
@@ -13,12 +13,17 @@ Search a filename, a phrase, or describe what you're looking for in your own
|
||||
words. See the relevant passage or media preview alongside your results,
|
||||
then open the file. Your files stay on your device.
|
||||
|
||||

|
||||
|
||||
*One shortcut. One floating search bar. Results appear when you type.*
|
||||
|
||||

|
||||
|
||||
*“cancel the supplier partnership” finds the agreement and brings its
|
||||
termination passage into view.*
|
||||
|
||||
Press **Ctrl + Shift + Space** on Windows to bring searchi forward and type.
|
||||
Press **Ctrl + Shift + Space** on Windows to bring up a floating search bar.
|
||||
Type to reveal results and a preview; clear the query to return to the compact bar.
|
||||
Escape or closing the window hides it while search stays ready. Use the tray
|
||||
icon beside the clock to open searchi or quit it.
|
||||
|
||||
@@ -26,6 +31,7 @@ icon beside the clock to open searchi or quit it.
|
||||
|
||||
| What you remember | What to try |
|
||||
| --- | --- |
|
||||
| An application to open | `VS Code`, `vscode` or `Calculator` |
|
||||
| The file's name | `Supplier agreement.pdf` |
|
||||
| Words inside a document | `sixty days written notice` |
|
||||
| What a document is about | `cancel the supplier partnership` |
|
||||
@@ -83,7 +89,7 @@ like an explanation, expand the small **Why this result?** detail.
|
||||
Light and dark themes follow your system preference. Keyboard navigation,
|
||||
short transitions and reduced-motion support keep the interface familiar
|
||||
and comfortable. In **Settings → Appearance**, choose a solid, glass or
|
||||
frosted-glass window background and adjust its tint. Reading surfaces stay
|
||||
frosted-glass window background and adjust its opacity. Reading surfaces stay
|
||||
clear. Native glass currently requires **Windows 11 22H2 or later**. macOS,
|
||||
Linux, unsupported Windows versions and high-contrast mode currently use a
|
||||
solid background; your appearance preference is retained.
|
||||
@@ -93,29 +99,34 @@ solid background; your appearance preference is retained.
|
||||
*“mountains reflected in calm water” searches the pictures themselves,
|
||||
so a useful filename isn't required.*
|
||||
|
||||
The screenshots show the current compact and expanded interface, rendered
|
||||
against the real local search engine; native glass depends on the operating system.
|
||||
|
||||
Images open in the preview. Audio and video have playback controls, and
|
||||
matching media segments can jump to their approximate location. Nothing
|
||||
autoplays when you select a result.
|
||||
|
||||
## Get searchi
|
||||
|
||||
**Windows x64 version 0.1.7 is available.**
|
||||
**Windows x64 version 0.1.8 is available.**
|
||||
|
||||
- [Download the Windows installer](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.7/searchi_0.1.7_x64-setup.exe) — about 100 MB.
|
||||
- [Checksums](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.7/searchi_0.1.7_x64-setup.exe.sha256) · [Release notes](https://tea.karlcom.de/karl/searchi/releases/tag/v0.1.7).
|
||||
- [Download the Windows installer](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.8/searchi_0.1.8_x64-setup.exe) — about 100 MB.
|
||||
- [Checksums](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.8/searchi_0.1.8_x64-setup.exe.sha256) · [Release notes](https://tea.karlcom.de/karl/searchi/releases/tag/v0.1.8).
|
||||
|
||||
Already installed? Choose Quit from the tray before updating. Version 0.1.7 adds a resident tray launcher alongside experimental local speech search, exact music artist/title filters and retains the fixes for
|
||||
archived files dated before 1970.
|
||||
It includes isolated PDF/DOCX processing and bounded media previews.
|
||||
Already installed? Choose Quit from the tray before updating. Version 0.1.8
|
||||
adds the floating launcher, installed-application search and automatic background
|
||||
file discovery. Explicit media requests no longer fall back to unrelated media
|
||||
channels. It retains local speech search, exact artist/title filters, isolated
|
||||
PDF/DOCX processing and bounded media previews.
|
||||
|
||||
The installer creates a launcher and sets up WebView2 if needed. This initial
|
||||
release is unsigned. A Linux desktop preview is also available; macOS source build instructions
|
||||
are available now. You can also let your local AI agent handle setup.
|
||||
|
||||
**Ubuntu 24.04 x64 desktop preview:** [Download the Debian package](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.7/searchi_0.1.4_amd64.deb)
|
||||
and install with `sudo apt install ./searchi_0.1.4_amd64.deb`.
|
||||
[Checksums](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.7/LINUX-DESKTOP-SHA256SUMS-0.1.4.txt).
|
||||
Startup was tested in WSL using CPU; native graphical interaction and other
|
||||
**Ubuntu 24.04 x64 desktop preview 0.1.8:** [Download the Debian package](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.8/searchi_0.1.8_amd64.deb)
|
||||
and install with `sudo apt install ./searchi_0.1.8_amd64.deb`.
|
||||
[Checksum](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.8/searchi_0.1.8_amd64.deb.sha256).
|
||||
Packaged startup was tested on WSLg using CPU; native graphical interaction and other
|
||||
distributions still need testing. [Compatibility and evidence](docs/LINUX-DESKTOP.md).
|
||||
|
||||
**Running a Linux server?** The optional
|
||||
@@ -166,10 +177,11 @@ or permission prevents setup, explain precisely what is missing; don't report
|
||||
a working installation until it is verified. Ask for my help only when a
|
||||
password, system approval or other manual action is required.
|
||||
|
||||
Let me choose which folders to index. Keep search local and optional cloud
|
||||
summaries disabled. On desktop, show me how to add a folder; on headless Linux,
|
||||
prepare the chosen folder through the CLI. Finish with a filename search
|
||||
followed by a search in my own words.
|
||||
On desktop, let automatic local file discovery run in the background. Show me
|
||||
the floating search bar, an application search and the indexing setting. Keep
|
||||
search local and optional cloud summaries disabled. On headless Linux, ask which
|
||||
folder to prepare through the CLI. Finish with a filename search followed by
|
||||
a search in my own words. Do not claim every file is ready while indexing runs.
|
||||
```
|
||||
|
||||
Your agent needs internet access and permission to install software locally.
|
||||
@@ -189,23 +201,26 @@ Prefer to set it up yourself? See the
|
||||
|
||||
Once searchi is running:
|
||||
|
||||
1. Choose **Add folder**, browse to the folder you want to search, then choose
|
||||
**Index folder**.
|
||||
2. Start with a filename or words you remember. These searches become
|
||||
available as files are indexed.
|
||||
3. Let the local search model prepare in the background. First setup downloads
|
||||
approximately **865 MB** and needs an internet connection. Document and code
|
||||
search by meaning can begin once the smaller text model and text index are
|
||||
ready, while the larger media model continues downloading. The app shows
|
||||
progress and keeps text search available if media setup needs a retry.
|
||||
4. Describe what you're looking for, select a result and inspect the preview.
|
||||
Press **Enter** to open the selected file in its usual application.
|
||||
1. Press **Ctrl + Shift + Space** on Windows. A compact search bar floats in
|
||||
the center of your screen; results and previews appear when you type.
|
||||
2. Try `VS Code` to find and launch an installed application. App search is
|
||||
available independently of file indexing and the local model download.
|
||||
3. searchi automatically discovers accessible local files in the background.
|
||||
System folders, caches, build outputs, common credential stores and its own
|
||||
data are excluded. Common
|
||||
personal folders are prepared first; a large collection takes longer.
|
||||
4. Search a filename or words you remember as files become available. The local
|
||||
Google model prepares in the background for searches by meaning. First setup
|
||||
downloads approximately **865 MB**; the smaller text model becomes usable
|
||||
before the larger media assets finish downloading.
|
||||
5. Select a result to inspect its passage or media preview, then press **Enter**
|
||||
to open it. Escape hides the launcher; the tray keeps it available.
|
||||
|
||||
How quickly a folder becomes searchable depends on its size and contents.
|
||||
The desktop keeps its search model warm for subsequent searches.
|
||||
searchi watches your chosen folders and updates search in the background as
|
||||
files change. You can also use **Reindex** to check a folder again, for example
|
||||
after reconnecting an external drive.
|
||||
In **Settings**, you can turn automatic discovery off or add a specific folder,
|
||||
including a network folder. Existing indexed results remain searchable when
|
||||
automatic discovery is off; an already running scan may finish. Files are
|
||||
updated in the background, and **Reindex** checks a selected folder again.
|
||||
The desktop keeps the real Google query model warm for subsequent searches.
|
||||
|
||||
searchi automatically chooses supported graphics acceleration when available
|
||||
and falls back to CPU when it cannot use it. You do not need a graphics
|
||||
@@ -213,8 +228,9 @@ development toolkit. Hardware and release validation are still in progress.
|
||||
|
||||
## Private by design
|
||||
|
||||
You choose the folders to index. By default, file contents, previews and search
|
||||
processing stay local. Ordinary searches aren't sent to a cloud AI service.
|
||||
Desktop file discovery runs automatically across accessible local storage.
|
||||
You can change it in Settings. File contents, previews and ordinary search
|
||||
processing stay local; searches are not sent to a cloud AI service.
|
||||
|
||||
If you enable optional AI answers, an explicit **Summarize** or **Ask** action
|
||||
sends the previewed document excerpt to the endpoint you choose. Ordinary
|
||||
@@ -290,8 +306,8 @@ files contain private information.
|
||||
For contributors: [building searchi and the headless Linux CLI](docs/BUILDING.md)
|
||||
· [roadmap](docs/ROADMAP.md) · [search-quality evaluation](docs/MEDIA-QUALITY.md).
|
||||
|
||||
The screenshots show the working interface searching real example files with
|
||||
the local Google model. They are browser captures of the app interface;
|
||||
operating-system window borders are omitted. [Photo credits](fixtures/media/README.md).
|
||||
The screenshots show the current interface searching real example files with
|
||||
the local Google model. They are isolated software-test browser captures;
|
||||
operating-system integration and native glass are not shown. [Photo credits](fixtures/media/README.md).
|
||||
|
||||
Made by Karl. Open source under the [MIT license](LICENSE).
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "searchi-desktop",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "searchi-desktop",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"dependencies": {
|
||||
"@fluentui/svg-icons": "1.1.344",
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "searchi-desktop",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -3799,7 +3799,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "searchi-desktop"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"image",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "searchi-desktop"
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
edition = "2021"
|
||||
description = "Local Searchi desktop shell"
|
||||
license = "MIT"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,61 @@ pub fn hide_search(app: tauri::AppHandle) -> Result<(), String> {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// A launcher grows only when it has content, staying inside its current display.
|
||||
#[tauri::command]
|
||||
pub fn set_search_layout(app: tauri::AppHandle, expanded: bool) -> Result<(), String> {
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
.ok_or_else(|| "Search window is unavailable.".to_owned())?;
|
||||
let monitor = window
|
||||
.current_monitor()
|
||||
.map_err(|error| error.to_string())?
|
||||
.or(window
|
||||
.primary_monitor()
|
||||
.map_err(|error| error.to_string())?);
|
||||
let (width, height) = if expanded {
|
||||
(1080.0, 700.0)
|
||||
} else {
|
||||
(720.0, 110.0)
|
||||
};
|
||||
if let Some(monitor) = monitor {
|
||||
let scale = monitor.scale_factor();
|
||||
let area = monitor.work_area();
|
||||
let size = &area.size;
|
||||
let width = (width * scale)
|
||||
.min(f64::from(size.width).max(80.0) - 48.0)
|
||||
.max(32.0) as u32;
|
||||
let height = (height * scale)
|
||||
.min(f64::from(size.height).max(80.0) - 80.0)
|
||||
.max(32.0) as u32;
|
||||
window
|
||||
.set_size(tauri::PhysicalSize::new(width, height))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let origin = &area.position;
|
||||
window
|
||||
.set_position(tauri::PhysicalPosition::new(
|
||||
origin.x + ((size.width - width) / 2) as i32,
|
||||
origin.y + ((size.height - height) / 2) as i32,
|
||||
))
|
||||
.map_err(|error| error.to_string())?;
|
||||
} else {
|
||||
window
|
||||
.set_size(tauri::LogicalSize::new(width, height))
|
||||
.map_err(|error| error.to_string())?;
|
||||
window.center().map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dragging is limited to our launcher; no general window access is exposed.
|
||||
#[tauri::command]
|
||||
pub fn drag_search(app: tauri::AppHandle) -> Result<(), String> {
|
||||
app.get_webview_window("main")
|
||||
.ok_or_else(|| "Search window is unavailable.".to_owned())?
|
||||
.start_dragging()
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -5,6 +5,8 @@ compile_error!("Release desktop builds require --features custom-protocol to emb
|
||||
|
||||
mod ai_commands;
|
||||
mod appearance;
|
||||
mod applications;
|
||||
mod automatic_indexing;
|
||||
mod data_directory;
|
||||
mod instance;
|
||||
mod media_preview;
|
||||
@@ -36,6 +38,7 @@ use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
struct AppState {
|
||||
applications: applications::ApplicationCatalog,
|
||||
appearance: Arc<Mutex<appearance::AppearanceStatus>>,
|
||||
ai: Arc<AiSession>,
|
||||
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]
|
||||
fn set_cpu_threads(threads: usize, state: State<'_, AppState>) -> Result<Settings, String> {
|
||||
if state.indexing.load(Ordering::Acquire) {
|
||||
@@ -512,6 +547,21 @@ fn start_folder_watching(
|
||||
let result = (|| -> Result<bool, String> {
|
||||
let mut index = SearchIndex::open(&database).map_err(|error| error.to_string())?;
|
||||
index.set_document_worker(document_worker.clone());
|
||||
if request.paths.is_none()
|
||||
&& index.stats().map_err(|error| error.to_string())?.files == 0
|
||||
{
|
||||
for scope in automatic_indexing::first_use_scopes(&request.root) {
|
||||
index
|
||||
.index_paths_cancellable(
|
||||
&request.root,
|
||||
&[scope],
|
||||
&options,
|
||||
&request.cancelled,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let _ = app.emit("search-index-changed", ());
|
||||
}
|
||||
}
|
||||
let report = match request.paths {
|
||||
Some(paths) => index.index_paths_cancellable(
|
||||
&request.root,
|
||||
@@ -613,6 +663,9 @@ fn main() {
|
||||
"searchi-extract-worker"
|
||||
}));
|
||||
let lexical = Arc::new(Mutex::new(SearchIndex::open(&database)?));
|
||||
// Install semantic schema before automatic scans acquire the writer.
|
||||
// Query/model preparation can then open current stores as readers.
|
||||
searchi_service::HybridSearch::prepare_database(&database)?;
|
||||
let warm_database = database.clone();
|
||||
let warm_target = lexical.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
@@ -676,20 +729,21 @@ fn main() {
|
||||
}
|
||||
});
|
||||
let mut ignored = vec![directory.clone(), model_directory.clone()];
|
||||
ignored.extend(automatic_indexing::exclusions());
|
||||
if let Some(parent) = speech_worker.parent().filter(|path| path.is_absolute()) { ignored.push(parent.to_path_buf()); }
|
||||
if let Some(parent) = worker.parent().filter(|path| path.is_absolute()) {
|
||||
ignored.push(parent.to_path_buf());
|
||||
}
|
||||
let index_options = searchi_core::IndexOptions {
|
||||
excluded_directory_names: automatic_indexing::excluded_names(),
|
||||
excluded_paths: ignored.iter()
|
||||
.map(|path| data_directory::canonical_exclusion(path))
|
||||
.collect::<std::io::Result<Vec<_>>>()?,
|
||||
..Default::default()
|
||||
};
|
||||
let indexing = Arc::new(AtomicBool::new(false));
|
||||
let watching = Arc::new(start_folder_watching(
|
||||
app.handle().clone(), database.clone(), lexical.clone(), indexing.clone(),
|
||||
settings.value.roots.clone(), index_options.clone(), document_worker.clone(),
|
||||
automatic_indexing::roots(&settings.value.roots, settings.value.automatic_indexing), index_options.clone(), document_worker.clone(),
|
||||
).map_err(std::io::Error::other)?);
|
||||
let semantic = Arc::new(
|
||||
SemanticState::new(model_directory, worker, media_tools)
|
||||
@@ -715,6 +769,7 @@ speech_worker, directory.join("SpeechModels")),
|
||||
let window = app.get_webview_window("main").ok_or_else(|| std::io::Error::other("Main window is unavailable."))?;
|
||||
let appearance = appearance::apply(&window, settings.value.background_material, settings.value.background_opacity, false);
|
||||
app.manage(AppState {
|
||||
applications: applications::ApplicationCatalog::discover(),
|
||||
appearance: Arc::new(Mutex::new(appearance)),
|
||||
ai: Arc::new(AiSession::default()),
|
||||
database,
|
||||
@@ -749,6 +804,11 @@ speech_worker, directory.join("SpeechModels")),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
instance::hide_search,
|
||||
instance::set_search_layout,
|
||||
instance::drag_search,
|
||||
search_applications,
|
||||
launch_application,
|
||||
set_automatic_indexing,
|
||||
prepare_ai_source,
|
||||
generate_ai,
|
||||
search,
|
||||
|
||||
@@ -23,10 +23,16 @@ fn default_background_opacity() -> u8 {
|
||||
const SETTINGS_VERSION: u32 = 2;
|
||||
const MAX_SETTINGS_BYTES: u64 = 128 * 1024;
|
||||
|
||||
fn default_automatic_indexing() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Settings {
|
||||
pub version: u32,
|
||||
#[serde(default = "default_automatic_indexing")]
|
||||
pub automatic_indexing: bool,
|
||||
#[serde(default)]
|
||||
pub background_material: BackgroundMaterial,
|
||||
#[serde(default = "default_background_opacity")]
|
||||
@@ -43,6 +49,7 @@ impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: SETTINGS_VERSION,
|
||||
automatic_indexing: true,
|
||||
background_material: BackgroundMaterial::default(),
|
||||
background_opacity: default_background_opacity(),
|
||||
roots: Vec::new(),
|
||||
@@ -57,8 +64,8 @@ impl Settings {
|
||||
if self.version != SETTINGS_VERSION {
|
||||
return Err(format!("Unsupported settings version {}", self.version));
|
||||
}
|
||||
if !(70..=100).contains(&self.background_opacity) {
|
||||
return Err("Background opacity must be between 70 and 100 percent.".into());
|
||||
if !(40..=100).contains(&self.background_opacity) {
|
||||
return Err("Background opacity must be between 40 and 100 percent.".into());
|
||||
}
|
||||
if !(1..=8).contains(&self.cpu_threads) {
|
||||
return Err("CPU threads must be between 1 and 8.".into());
|
||||
@@ -265,11 +272,11 @@ mod appearance_tests {
|
||||
assert_eq!(store.value.background_opacity, 90);
|
||||
let mut next = store.value.clone();
|
||||
next.background_material = BackgroundMaterial::Glass;
|
||||
next.background_opacity = 70;
|
||||
next.background_opacity = 40;
|
||||
store.save(next.clone()).unwrap();
|
||||
assert_eq!(SettingsStore::load(path.clone()).unwrap().value, next);
|
||||
let bytes = fs::read(&path).unwrap();
|
||||
for opacity in [0, 69, 101, 255] {
|
||||
for opacity in [0, 39, 101, 255] {
|
||||
let mut invalid = next.clone();
|
||||
invalid.background_opacity = opacity;
|
||||
assert!(store.save(invalid).is_err());
|
||||
|
||||
@@ -243,10 +243,9 @@ fn validate(roots: &[PathBuf]) -> Result<(), String> {
|
||||
fn excluded(path: &Path, shared: &Shared) -> bool {
|
||||
shared.ignored.iter().any(|p| path.starts_with(p))
|
||||
|| path.components().any(|c| {
|
||||
matches!(
|
||||
c.as_os_str().to_str(),
|
||||
Some(".git" | "node_modules" | "target")
|
||||
)
|
||||
super::automatic_indexing::EXCLUDED_DIRECTORY_NAMES
|
||||
.iter()
|
||||
.any(|name| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(name))
|
||||
})
|
||||
}
|
||||
fn hint(shared: &Shared, event: notify::Result<Event>) {
|
||||
@@ -465,6 +464,51 @@ fn run(shared: Arc<Shared>, callback: impl Fn(ScanRequest) -> ScanOutcome) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::mpsc;
|
||||
#[test]
|
||||
fn excluded_cache_events_do_not_dirty_root_but_documents_do() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().to_owned();
|
||||
let mut root = Root::new(Instant::now());
|
||||
root.full = false;
|
||||
root.first = None;
|
||||
let shared = Shared {
|
||||
state: Mutex::new(State {
|
||||
roots: BTreeMap::from([(path.clone(), root)]),
|
||||
stop: false,
|
||||
}),
|
||||
changed: Condvar::new(),
|
||||
ignored: Vec::new(),
|
||||
timing: Timing::default(),
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
for relative in [
|
||||
".cargo/registry/change.bin",
|
||||
"Cache/change.bin",
|
||||
"cAcHe/change.bin",
|
||||
] {
|
||||
hint(
|
||||
&shared,
|
||||
Ok(Event::new(EventKind::Any).add_path(path.join(relative))),
|
||||
);
|
||||
let state = shared.state.lock().unwrap();
|
||||
let root = &state.roots[&path];
|
||||
assert!(root.first.is_none());
|
||||
assert!(root.paths.is_empty());
|
||||
assert!(!root.media_hint);
|
||||
assert!(!root.full);
|
||||
}
|
||||
let document = path.join("Documents/change.txt");
|
||||
hint(
|
||||
&shared,
|
||||
Ok(Event::new(EventKind::Any).add_path(document.clone())),
|
||||
);
|
||||
let state = shared.state.lock().unwrap();
|
||||
let root = &state.roots[&path];
|
||||
assert!(root.first.is_some());
|
||||
assert_eq!(root.paths, BTreeSet::from([document]));
|
||||
assert!(root.media_hint);
|
||||
assert!(!root.full);
|
||||
}
|
||||
fn fast() -> Timing {
|
||||
Timing {
|
||||
debounce: Duration::from_millis(40),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "searchi",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"identifier": "dev.searchi.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -13,10 +13,15 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "searchi",
|
||||
"width": 1080,
|
||||
"height": 760,
|
||||
"minWidth": 680,
|
||||
"minHeight": 540
|
||||
"width": 720,
|
||||
"height": 110,
|
||||
"center": true,
|
||||
"decorations": false,
|
||||
"resizable": false,
|
||||
"transparent": true,
|
||||
"shadow": true,
|
||||
"skipTaskbar": true,
|
||||
"alwaysOnTop": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -19,7 +19,7 @@ export function appearanceView(
|
||||
const material: BackgroundMaterial =
|
||||
!highContrast && valid ? status.effective_material : "solid";
|
||||
const opacity = Number.isFinite(status?.opacity)
|
||||
? Math.max(70, Math.min(100, status!.opacity))
|
||||
? Math.max(40, Math.min(100, status!.opacity))
|
||||
: 90;
|
||||
const message = highContrast
|
||||
? "High contrast uses a solid background. Your appearance preference is retained."
|
||||
|
||||
@@ -88,6 +88,7 @@ interface Settings {
|
||||
version: number;
|
||||
roots: string[];
|
||||
cpu_threads: number;
|
||||
automatic_indexing?: boolean;
|
||||
background_material?: BackgroundMaterial;
|
||||
background_opacity?: number;
|
||||
}
|
||||
@@ -160,19 +161,86 @@ interface SearchRequest {
|
||||
}
|
||||
|
||||
document.querySelector<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>
|
||||
<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>
|
||||
<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>
|
||||
</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>`;
|
||||
<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>${isNativeDesktop() ? "Hide" : "Clear"}</span></span></footer>`;
|
||||
|
||||
const element = <T extends HTMLElement>(id: string) =>
|
||||
document.getElementById(id) as T;
|
||||
const query = element<HTMLInputElement>("query");
|
||||
const list = element<HTMLUListElement>("results");
|
||||
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 {
|
||||
element<HTMLInputElement>("performance-toggle").checked =
|
||||
localStorage.getItem("searchi-performance-details") === "true";
|
||||
@@ -180,6 +248,130 @@ try {
|
||||
/* Diagnostics remain opt-in when storage is unavailable. */
|
||||
}
|
||||
let results: SearchResult[] = [];
|
||||
interface ApplicationResult {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
let applications: ApplicationResult[] = [];
|
||||
let selectedApplication = -1;
|
||||
let applicationRevision = 0;
|
||||
let settledApplicationRevision = -1;
|
||||
let applicationQuery = "";
|
||||
function selectApplication(index: number, focus = false) {
|
||||
if (!applications.length) return;
|
||||
selectedApplication = Math.max(0, Math.min(index, applications.length - 1));
|
||||
const app = applications[selectedApplication];
|
||||
clearMedia();
|
||||
element("preview").hidden = true;
|
||||
element("application-preview").hidden = false;
|
||||
element("application-name").textContent = app.name;
|
||||
list.querySelectorAll("button").forEach((button) => {
|
||||
button.classList.remove("selected");
|
||||
button.setAttribute("aria-current", "false");
|
||||
});
|
||||
element("application-results")
|
||||
.querySelectorAll<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 displayedCodeScope = false;
|
||||
let explainedSelection = "";
|
||||
@@ -318,7 +510,13 @@ const resultRows = new Map<
|
||||
const automaticIndex = new AutomaticIndexGate();
|
||||
|
||||
function refreshEmptySearchState() {
|
||||
if (!latestSearch || latestSearch.id !== requestId || results.length) return;
|
||||
if (
|
||||
!latestSearch ||
|
||||
latestSearch.id !== requestId ||
|
||||
results.length ||
|
||||
applications.length
|
||||
)
|
||||
return;
|
||||
if (!latestSearch.query.trim()) {
|
||||
element("empty").querySelector("h2")!.textContent =
|
||||
"No matching music tags.";
|
||||
@@ -439,7 +637,8 @@ async function refreshSemanticStatus() {
|
||||
else if (semanticAvailable(status) && status.chunks > 0)
|
||||
message = `Semantic search ready · ${status.chunks.toLocaleString()} content segments${status.stale ? " · Updates pending" : ""}${status.media_failures?.length ? ` · ${status.media_failures.length} files could not be indexed` : ""}`;
|
||||
else if (status.ready)
|
||||
message = "Google model ready. Index a folder to search by meaning.";
|
||||
message =
|
||||
"Google model ready. Your files become searchable by meaning as background indexing completes.";
|
||||
else
|
||||
message =
|
||||
"Preparing local semantic search… Keyword search remains available.";
|
||||
@@ -592,8 +791,15 @@ element("background-material").addEventListener("change", () => {
|
||||
void saveAppearance();
|
||||
});
|
||||
element("background-opacity").addEventListener("input", () => {
|
||||
element("background-opacity-value").textContent =
|
||||
`${element<HTMLInputElement>("background-opacity").value}%`;
|
||||
const opacity = 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", () => {
|
||||
void saveAppearance();
|
||||
@@ -616,6 +822,9 @@ async function refreshDesktopStatus(restoreAppearanceControls = false) {
|
||||
restoreAppearanceControls,
|
||||
);
|
||||
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("focus-shortcut").textContent = status.shortcut_status.includes(
|
||||
"Super",
|
||||
@@ -671,7 +880,7 @@ async function refreshDesktopStatus(restoreAppearanceControls = false) {
|
||||
}
|
||||
if (status.indexing)
|
||||
element("index-status").textContent =
|
||||
"Indexing active · Search remains available";
|
||||
"Searching your device · Ready to search";
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -885,6 +1094,14 @@ element("ai-citation").addEventListener("click", () => {
|
||||
});
|
||||
function select(index: number, focus = false, userChoice = true) {
|
||||
if (!results.length) return;
|
||||
selectedApplication = -1;
|
||||
element("application-preview").hidden = true;
|
||||
element("application-results")
|
||||
.querySelectorAll("button")
|
||||
.forEach((button) => {
|
||||
button.classList.remove("selected");
|
||||
button.setAttribute("aria-current", "false");
|
||||
});
|
||||
selected = Math.max(0, Math.min(index, results.length - 1));
|
||||
const result = results[selected];
|
||||
if (userChoice) resultSelection.choose(result.path);
|
||||
@@ -992,7 +1209,7 @@ function render() {
|
||||
resultRows.delete(key);
|
||||
}
|
||||
}
|
||||
element("empty").hidden = results.length > 0;
|
||||
element("empty").hidden = results.length > 0 || applications.length > 0;
|
||||
if (!results.length) {
|
||||
element("preview").hidden = true;
|
||||
previewPath = null;
|
||||
@@ -1120,7 +1337,8 @@ function applyResults(
|
||||
const preserved = resultSelection.resolve(
|
||||
results.map((result) => result.path),
|
||||
);
|
||||
if (preserved >= 0) select(preserved, resultsHadFocus, false);
|
||||
if (preserved >= 0 && selectedApplication < 0)
|
||||
select(preserved, resultsHadFocus, false);
|
||||
if (!found.length) refreshEmptySearchState();
|
||||
request.measurement.displayed(
|
||||
lane,
|
||||
@@ -1330,6 +1548,8 @@ function invalidateSearch() {
|
||||
}
|
||||
|
||||
async function search(inputAt = performance.now()) {
|
||||
syncLauncher();
|
||||
void refreshApplications().catch(showError);
|
||||
invalidateSearch();
|
||||
querySemanticFailed = false;
|
||||
shownLane = "lexical";
|
||||
@@ -1348,7 +1568,7 @@ async function search(inputAt = performance.now()) {
|
||||
element("empty").querySelector("h2")!.textContent =
|
||||
"Find what matters. Locally.";
|
||||
element("empty").querySelector("p")!.textContent =
|
||||
"Index a folder, then search a filename, phrase, or what you remember.";
|
||||
"Your device is indexed in the background. Search an application, filename, phrase, or what you remember.";
|
||||
return;
|
||||
}
|
||||
resultSelection.begin(JSON.stringify([value, filters]));
|
||||
@@ -1384,6 +1604,13 @@ async function refreshStats() {
|
||||
}
|
||||
|
||||
query.addEventListener("input", () => {
|
||||
++applicationRevision;
|
||||
selectedApplication = -1;
|
||||
applications = [];
|
||||
element("application-results").replaceChildren();
|
||||
element("application-results").hidden = true;
|
||||
element("application-preview").hidden = true;
|
||||
syncLauncher();
|
||||
const inputAt = performance.now();
|
||||
invalidateSearch();
|
||||
timer = setTimeout(() => void search(inputAt), 60);
|
||||
@@ -1579,7 +1806,35 @@ document.addEventListener("keydown", (event) => {
|
||||
void search();
|
||||
query.focus();
|
||||
}
|
||||
if (event.target === query || list.contains(event.target as Node)) {
|
||||
if (
|
||||
event.target === query ||
|
||||
list.contains(event.target as Node) ||
|
||||
element("application-results").contains(event.target as Node)
|
||||
) {
|
||||
if (event.key === "Enter" && selectedApplication >= 0) {
|
||||
event.preventDefault();
|
||||
void launchApplication();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(event.key === "ArrowDown" || event.key === "ArrowUp") &&
|
||||
applications.length
|
||||
) {
|
||||
event.preventDefault();
|
||||
const step = event.key === "ArrowDown" ? 1 : -1;
|
||||
const position =
|
||||
selectedApplication >= 0
|
||||
? selectedApplication
|
||||
: applications.length + selected;
|
||||
const next = Math.max(
|
||||
0,
|
||||
Math.min(position + step, applications.length + results.length - 1),
|
||||
);
|
||||
if (next < applications.length) selectApplication(next);
|
||||
else select(next - applications.length);
|
||||
query.focus();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
results[selected] &&
|
||||
@@ -1590,12 +1845,15 @@ document.addEventListener("keydown", (event) => {
|
||||
}
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
select(selected + (event.key === "ArrowDown" ? 1 : -1), true);
|
||||
select(selected + (event.key === "ArrowDown" ? 1 : -1));
|
||||
query.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
async function initializeSurface() {
|
||||
await initializeDesktopSurface();
|
||||
document.documentElement.dataset.nativeLauncher = String(isNativeDesktop());
|
||||
syncLauncher();
|
||||
renderAppearance();
|
||||
if (isNativeDesktop()) {
|
||||
appearanceUpdates.submit(highContrast.matches);
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* The resident launcher starts as a search bar and expands for useful content. */
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -401,7 +402,7 @@ summary::-webkit-details-marker {
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
.workspace:has(aside[hidden]) {
|
||||
.workspace:has(#preview[hidden]):has(#application-preview[hidden]) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.results-pane {
|
||||
@@ -960,12 +961,7 @@ aside pre.code-passage {
|
||||
background: rgba(248, 250, 252, var(--window-opacity, 0.9));
|
||||
}
|
||||
:root[data-background-material="frosted"] #app {
|
||||
background: rgba(
|
||||
248,
|
||||
250,
|
||||
252,
|
||||
calc(0.35 + var(--window-opacity, 0.9) * 0.65)
|
||||
);
|
||||
background: rgba(248, 250, 252, var(--window-opacity, 0.9));
|
||||
}
|
||||
.appearance-settings {
|
||||
padding-bottom: 16px;
|
||||
@@ -1008,12 +1004,7 @@ aside pre.code-passage {
|
||||
background: rgba(11, 15, 20, var(--window-opacity, 0.9));
|
||||
}
|
||||
:root[data-background-material="frosted"] #app {
|
||||
background: rgba(
|
||||
11,
|
||||
15,
|
||||
20,
|
||||
calc(0.35 + var(--window-opacity, 0.9) * 0.65)
|
||||
);
|
||||
background: rgba(11, 15, 20, var(--window-opacity, 0.9));
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
@@ -1032,3 +1023,185 @@ aside pre.code-passage {
|
||||
> summary {
|
||||
--muted: var(--ink);
|
||||
}
|
||||
:root {
|
||||
background: transparent;
|
||||
}
|
||||
#app {
|
||||
min-height: 110px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: var(--canvas);
|
||||
overflow: hidden;
|
||||
}
|
||||
header {
|
||||
padding: 6px 15px 4px;
|
||||
min-height: 38px;
|
||||
gap: 12px;
|
||||
}
|
||||
.brand-symbol {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.wordmark {
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
.index-status {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
.header-actions {
|
||||
gap: 3px;
|
||||
}
|
||||
.header-actions summary,
|
||||
.header-actions .icon-button {
|
||||
padding: 5px;
|
||||
}
|
||||
.header-actions .icon svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
main {
|
||||
padding: 0 10px;
|
||||
}
|
||||
#search-form {
|
||||
min-height: 60px;
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
box-shadow: none;
|
||||
padding: 10px 13px;
|
||||
}
|
||||
#search-form:focus-within {
|
||||
box-shadow: none;
|
||||
}
|
||||
#query {
|
||||
font-size: 21px;
|
||||
}
|
||||
#focus-shortcut {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.workspace {
|
||||
border-radius: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.workspace:has(#preview[hidden]):has(#application-preview[hidden]) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.workspace:has(#application-preview:not([hidden])) {
|
||||
grid-template-columns: minmax(0, 0.96fr) minmax(0, 1.04fr);
|
||||
}
|
||||
footer {
|
||||
min-height: 38px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.popover-panel {
|
||||
max-height: min(560px, 75dvh);
|
||||
overflow: auto;
|
||||
}
|
||||
#application-results {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
max-height: 45%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.application-result {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
.application-result.selected {
|
||||
background: var(--selected);
|
||||
}
|
||||
.application-detail {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.application-detail h2 {
|
||||
font-size: 25px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
:root[data-launcher-layout="compact"] .workspace,
|
||||
:root[data-launcher-layout="compact"] footer,
|
||||
:root[data-launcher-layout="compact"] #semantic-panel,
|
||||
:root[data-launcher-layout="compact"] #notice,
|
||||
:root[data-launcher-layout="compact"] #performance-panel {
|
||||
display: none;
|
||||
}
|
||||
:root[data-launcher-layout="expanded"] .workspace {
|
||||
animation: launcher-reveal 140ms ease-out;
|
||||
}
|
||||
@keyframes launcher-reveal {
|
||||
from {
|
||||
opacity: 0.6;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
:root[data-native-launcher="false"] #app {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
:root[data-native-launcher="false"][data-launcher-layout="compact"] #app {
|
||||
max-width: 720px;
|
||||
height: 110px;
|
||||
margin-top: min(20dvh, 120px);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root[data-launcher-layout] .workspace {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
#app {
|
||||
border: 1px solid CanvasText;
|
||||
background: Canvas;
|
||||
}
|
||||
}
|
||||
|
||||
/* At the lowest shell opacity, local reading tints still protect text against
|
||||
an opposite-color desktop. The outer shell retains its selected opacity. */
|
||||
:root[data-background-material="glass"] #search-form,
|
||||
:root[data-background-material="frosted"] #search-form,
|
||||
:root[data-background-material="glass"] .header-actions,
|
||||
:root[data-background-material="frosted"] .header-actions,
|
||||
:root[data-background-material="glass"] .brand,
|
||||
:root[data-background-material="frosted"] .brand,
|
||||
:root[data-background-material="glass"] .index-status,
|
||||
:root[data-background-material="frosted"] .index-status,
|
||||
:root[data-background-material="glass"] footer,
|
||||
:root[data-background-material="frosted"] footer {
|
||||
background: rgba(248, 250, 252, 0.6);
|
||||
border-radius: 8px;
|
||||
--muted: var(--ink);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root[data-background-material="glass"] #search-form,
|
||||
:root[data-background-material="frosted"] #search-form,
|
||||
:root[data-background-material="glass"] .header-actions,
|
||||
:root[data-background-material="frosted"] .header-actions,
|
||||
:root[data-background-material="glass"] .brand,
|
||||
:root[data-background-material="frosted"] .brand,
|
||||
:root[data-background-material="glass"] .index-status,
|
||||
:root[data-background-material="frosted"] .index-status,
|
||||
:root[data-background-material="glass"] footer,
|
||||
:root[data-background-material="frosted"] footer {
|
||||
background: rgba(11, 15, 20, 0.6);
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
:root[data-background-material] #search-form {
|
||||
background: Canvas;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ test('high contrast stays opaque without losing the preference', () => {
|
||||
test('fallback is honest and shell tint is bounded', () => {
|
||||
const fallback = {...glass(), effective_material:'solid', native_effect:false, fallback_reason:'Native effects unavailable.'};
|
||||
assert.match(appearanceView(fallback, false).message, /Solid background/);
|
||||
assert.equal(appearanceView({...glass(), opacity:5}, false).opacity, 70);
|
||||
assert.equal(appearanceView({...glass(), opacity:5}, false).opacity, 40);
|
||||
assert.equal(appearanceView({...glass(), opacity:110}, false).opacity, 100);
|
||||
assert.equal(appearanceView({...glass(), opacity:NaN}, false).opacity, 90);
|
||||
});
|
||||
|
||||
@@ -645,6 +645,7 @@ impl SearchIndex {
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => break,
|
||||
Err(error) => return Err(error).context("Inspect targeted path ancestor"),
|
||||
}
|
||||
}
|
||||
@@ -684,6 +685,11 @@ impl SearchIndex {
|
||||
check_index_cancelled(cancelled)?;
|
||||
match fs::symlink_metadata(scope) {
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
preserve_unreadable_scope(&transaction, &root_key, scope)?;
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error).context("Inspect reconciliation scope"),
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
bail!("Reconciliation scope became a symlink")
|
||||
@@ -731,8 +737,26 @@ impl SearchIndex {
|
||||
})
|
||||
{
|
||||
check_index_cancelled(cancelled)?;
|
||||
let entry =
|
||||
entry.context("directory traversal failed; reconciliation rolled back")?;
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(error)
|
||||
if error.io_error().is_some_and(|error| {
|
||||
error.kind() == std::io::ErrorKind::PermissionDenied
|
||||
}) && error.path().is_some() =>
|
||||
{
|
||||
preserve_unreadable_scope(
|
||||
&transaction,
|
||||
&root_key,
|
||||
error.path().expect("Checked traversal error path"),
|
||||
)?;
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(error)
|
||||
.context("directory traversal failed; reconciliation rolled back")
|
||||
}
|
||||
};
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
@@ -751,7 +775,18 @@ impl SearchIndex {
|
||||
let filename = entry.file_name().to_string_lossy().into_owned();
|
||||
transaction.execute("INSERT INTO seen_paths(path) VALUES (?1)", [&path])?;
|
||||
report.discovered += 1;
|
||||
let metadata = entry.metadata()?;
|
||||
let metadata = match entry.metadata() {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error)
|
||||
if error.io_error().is_some_and(|error| {
|
||||
error.kind() == std::io::ErrorKind::PermissionDenied
|
||||
}) =>
|
||||
{
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let size = i64::try_from(metadata.len()).context("file exceeds supported size")?;
|
||||
let mtime = searchi_media::unix_time_ns(metadata.modified()?)?;
|
||||
let extension = entry
|
||||
@@ -771,6 +806,10 @@ impl SearchIndex {
|
||||
// metadata is unchanged. This detects same-stat overwrites while
|
||||
// unsupported media/giant files retain the metadata-only fast path.
|
||||
let input = extraction::read_source(entry.path());
|
||||
if input.as_ref().is_err_and(permission_denied) {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// A parser rejection may retain a filename-only record, but a
|
||||
// filesystem read failure cannot certify this reconciliation.
|
||||
if input
|
||||
@@ -781,7 +820,14 @@ impl SearchIndex {
|
||||
.with_context(|| format!("read {path}; reconciliation rolled back"));
|
||||
}
|
||||
if matches!(&input, Ok(Some(_))) {
|
||||
let after = fs::metadata(entry.path())?;
|
||||
let after = match fs::metadata(entry.path()) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if after.len() != metadata.len() || after.modified()? != metadata.modified()? {
|
||||
bail!("Source changed during extraction read; retry indexing: {path}; reconciliation rolled back");
|
||||
}
|
||||
@@ -840,7 +886,15 @@ impl SearchIndex {
|
||||
check_index_cancelled(cancelled)?;
|
||||
if matches!(extension.as_str(), "pdf" | "docx") {
|
||||
if let Some(hash) = &source_hash {
|
||||
extraction::verify_document_snapshot(entry.path(), hash, &metadata)?;
|
||||
if let Err(error) =
|
||||
extraction::verify_document_snapshot(entry.path(), hash, &metadata)
|
||||
{
|
||||
if permission_denied(&error) {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if extracted.is_none() {
|
||||
@@ -1536,6 +1590,32 @@ fn substring_anchor(characters: &[char], frequencies: &[(i64, String)]) -> Strin
|
||||
characters[start..end].iter().collect()
|
||||
}
|
||||
|
||||
fn permission_denied(error: &anyhow::Error) -> bool {
|
||||
error.chain().any(|cause| {
|
||||
cause
|
||||
.downcast_ref::<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<()> {
|
||||
if cancelled.is_some_and(|flag| flag.load(Ordering::Acquire)) {
|
||||
bail!("Index reconciliation cancelled; transaction rolled back");
|
||||
@@ -1560,6 +1640,63 @@ fn validate_normalized_absolute(path: &Path) -> Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn denied_scope_preserves_only_existing_records_with_component_boundary() -> anyhow::Result<()>
|
||||
{
|
||||
use super::*;
|
||||
let directory = tempfile::tempdir()?;
|
||||
let root = directory.path().canonicalize()?;
|
||||
let denied = root.join("private");
|
||||
let sibling = root.join("private-other");
|
||||
fs::create_dir(&denied)?;
|
||||
fs::create_dir(&sibling)?;
|
||||
fs::write(denied.join("kept.txt"), "original exact source")?;
|
||||
fs::write(sibling.join("removed.txt"), "sibling")?;
|
||||
let mut index = SearchIndex::open(":memory:")?;
|
||||
index.index_folder(&root)?;
|
||||
let root_key = root.to_string_lossy().into_owned();
|
||||
let transaction = index.connection.transaction()?;
|
||||
transaction.execute_batch("CREATE TEMP TABLE IF NOT EXISTS seen_paths(path TEXT PRIMARY KEY); DELETE FROM seen_paths;")?;
|
||||
preserve_unreadable_scope(&transaction, &root_key, &denied)?;
|
||||
let preserved: Vec<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]
|
||||
fn legacy_metadata_migration_preserves_keyword_search_without_inventing_fields(
|
||||
) -> anyhow::Result<()> {
|
||||
|
||||
@@ -11,6 +11,7 @@ pub use speech::{speech_policy_key, BudgetedSpeechProvider, SpeechExcerpt, Speec
|
||||
mod prepared;
|
||||
pub use prepared::PreparedGraphs;
|
||||
mod pipeline;
|
||||
mod relevance;
|
||||
mod routing;
|
||||
pub use routing::SemanticOrigin;
|
||||
mod store;
|
||||
@@ -280,6 +281,12 @@ pub struct HybridSearch {
|
||||
}
|
||||
|
||||
impl HybridSearch {
|
||||
/// Prepare storage before background discovery starts. No model is loaded.
|
||||
pub fn prepare_database(db: impl AsRef<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.
|
||||
pub fn with_budget(
|
||||
mut self,
|
||||
@@ -485,7 +492,11 @@ impl HybridSearch {
|
||||
/// Execute real query inference and load the active ANN generation before
|
||||
/// foreground use. Call on a background worker after assets are verified.
|
||||
pub fn warmup(&mut self) -> Result<()> {
|
||||
self.lexical.prewarm()?;
|
||||
if let Err(error) = self.lexical.prewarm() {
|
||||
if !is_busy_sqlite_error(&error) {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let vector = self.embedder.embed_query("warm up local file search")?;
|
||||
let _ = self.store.search(
|
||||
&vector,
|
||||
@@ -580,8 +591,14 @@ impl HybridSearch {
|
||||
promote_identifiers(query.trim(), &mut lexical)
|
||||
});
|
||||
let vector = self.query_vector(query, options.code_only, trace, parent)?;
|
||||
let semantic =
|
||||
let mut semantic =
|
||||
self.routed_semantic_traced(query, &vector, candidates, options, trace, parent)?;
|
||||
trace.timed("hybrid.semantic_shortlist", parent, |trace, span| {
|
||||
trace.counter(span, "candidates_before", semantic.len() as u64);
|
||||
let removed = relevance::prune_semantic_tail(&mut semantic);
|
||||
trace.counter(span, "tail_candidates_pruned", removed as u64);
|
||||
trace.counter(span, "candidates_after", semantic.len() as u64);
|
||||
});
|
||||
let results = trace.timed("hybrid.rank_fusion", parent, |trace, span| {
|
||||
trace.counter(span, "lexical_files", lexical.len() as u64);
|
||||
trace.counter(span, "semantic_files", semantic.len() as u64);
|
||||
@@ -684,6 +701,11 @@ impl HybridSearch {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_busy_sqlite_error(error: &anyhow::Error) -> bool {
|
||||
matches!(error.downcast_ref::<rusqlite::Error>(), Some(rusqlite::Error::SqliteFailure(error, _))
|
||||
if matches!(error.code, rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked))
|
||||
}
|
||||
|
||||
fn bounded_title(title: &str) -> &str {
|
||||
let mut end = title.len().min(512);
|
||||
while !title.is_char_boundary(end) {
|
||||
@@ -995,3 +1017,62 @@ mod tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod coverage_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_lock_tests {
|
||||
use super::*;
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
};
|
||||
struct QueryProbe(Arc<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(())
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ impl SemanticOrigin {
|
||||
}
|
||||
|
||||
/// A conservative intent hint, not a language understanding model. Explicit
|
||||
/// document/visual/code cues veto it; ordinary topic and instrument queries do not
|
||||
/// document/code cues veto it; ordinary topic and instrument queries do not
|
||||
/// silently restrict modality. Word boundaries avoid filename substrings.
|
||||
fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
|
||||
if query.split_whitespace().count() == 1 && Path::new(query).extension().is_some() {
|
||||
@@ -37,18 +37,10 @@ fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
|
||||
.collect();
|
||||
let has = |values: &[&str]| words.iter().any(|word| values.contains(word));
|
||||
if has(&[
|
||||
"image",
|
||||
"photo",
|
||||
"picture",
|
||||
"screenshot",
|
||||
"diagram",
|
||||
"document",
|
||||
"pdf",
|
||||
"text",
|
||||
"paper",
|
||||
"bild",
|
||||
"foto",
|
||||
"screenshot",
|
||||
"dokument",
|
||||
"textdatei",
|
||||
"code",
|
||||
@@ -57,9 +49,33 @@ fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
|
||||
"quellcode",
|
||||
"funktion",
|
||||
"implementierung",
|
||||
"algorithm",
|
||||
"algorithms",
|
||||
"algorithmus",
|
||||
"algorithmen",
|
||||
]) {
|
||||
return None;
|
||||
}
|
||||
// Explicit visual requests should not be answered by unrelated audio or
|
||||
// document neighbors. Check these before speech/audio words: a photo of a
|
||||
// speaker and a screenshot of a waveform still request visual evidence.
|
||||
let visual = has(&[
|
||||
"image",
|
||||
"photo",
|
||||
"picture",
|
||||
"screenshot",
|
||||
"diagram",
|
||||
"bild",
|
||||
"foto",
|
||||
"fotografie",
|
||||
]);
|
||||
let video = has(&["video", "movie", "clip", "film"]);
|
||||
if visual && video {
|
||||
return None;
|
||||
}
|
||||
if visual {
|
||||
return Some(SemanticOrigin::Image);
|
||||
}
|
||||
if has(&[
|
||||
"spoken",
|
||||
"speech",
|
||||
@@ -86,7 +102,6 @@ fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
|
||||
if has(&[
|
||||
"audio",
|
||||
"recording",
|
||||
"sound",
|
||||
"music",
|
||||
"geräusch",
|
||||
"geraeusch",
|
||||
@@ -98,9 +113,13 @@ fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
|
||||
"hoerbarer",
|
||||
"hoerbare",
|
||||
"tonaufnahme",
|
||||
]) {
|
||||
]) || (has(&["sound"]) && has(&["of", "effect", "effects"]))
|
||||
{
|
||||
return Some(SemanticOrigin::Audio);
|
||||
}
|
||||
if video {
|
||||
return Some(SemanticOrigin::Video);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -130,7 +149,7 @@ impl HybridSearch {
|
||||
} else {
|
||||
preferred_origin(query)
|
||||
} {
|
||||
trace.counter(parent, "audio_preference", 1);
|
||||
trace.counter(parent, "explicit_source_channel", 1);
|
||||
let preferred = self.store.search_origin_traced(
|
||||
vector,
|
||||
&self.generation_fingerprint,
|
||||
@@ -157,6 +176,12 @@ impl HybridSearch {
|
||||
return Ok(raw);
|
||||
}
|
||||
}
|
||||
// An empty requested channel is useful evidence. Falling back
|
||||
// to every source type turns an absent recording/photo into an
|
||||
// unrelated result; lexical filename/content hits remain in
|
||||
// the independently fused lane.
|
||||
trace.counter(parent, "requested_channel_empty", 1);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
trace.counter(parent, "general_lane", 1);
|
||||
self.store.search_origin_traced(
|
||||
@@ -226,7 +251,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_spoken_intent_is_conservative_and_bilingual() {
|
||||
fn explicit_media_intent_is_conservative_and_bilingual() {
|
||||
for query in [
|
||||
"where someone mentions a contract",
|
||||
"a speaker says the date",
|
||||
@@ -234,6 +259,7 @@ mod tests {
|
||||
"jemand erwähnt das Budget",
|
||||
"Wo spricht jemand über Kündigung?",
|
||||
"Gespräch über die neue Wohnung",
|
||||
"video where someone mentions a contract",
|
||||
] {
|
||||
assert_eq!(
|
||||
preferred_origin(query),
|
||||
@@ -241,6 +267,29 @@ mod tests {
|
||||
"{query}"
|
||||
);
|
||||
}
|
||||
for query in [
|
||||
"photo of someone speaking",
|
||||
"Bild von einem Sprecher",
|
||||
"a screenshot of an audio waveform",
|
||||
"Foto mit Bergen",
|
||||
] {
|
||||
assert_eq!(
|
||||
preferred_origin(query),
|
||||
Some(SemanticOrigin::Image),
|
||||
"{query}"
|
||||
);
|
||||
}
|
||||
for query in [
|
||||
"video of a boat",
|
||||
"Film mit einem Wasserfall",
|
||||
"a movie with a blue car",
|
||||
] {
|
||||
assert_eq!(
|
||||
preferred_origin(query),
|
||||
Some(SemanticOrigin::Video),
|
||||
"{query}"
|
||||
);
|
||||
}
|
||||
for query in [
|
||||
"audio of a waterfall",
|
||||
"hörbarer Regen",
|
||||
@@ -256,17 +305,19 @@ mod tests {
|
||||
for query in [
|
||||
"contract deadline",
|
||||
"violin solo",
|
||||
"photo of someone speaking",
|
||||
"Bild von einem Sprecher",
|
||||
"a document that someone mentioned",
|
||||
"speech.png",
|
||||
"a screenshot of an audio waveform",
|
||||
"code that chooses audio search when a query mentions speech",
|
||||
"an implementation for identifying music",
|
||||
"a function that decodes audio samples",
|
||||
"Quellcode für die Suche nach gesprochenen Themen",
|
||||
"eine Funktion für die Verarbeitung von Musik",
|
||||
"Implementierung für die Erkennung von Geräuschen",
|
||||
"photo or video of a boat",
|
||||
"Bild oder Film vom Wasserfall",
|
||||
"image compression algorithm",
|
||||
"Algorithmus zur Bildkomprimierung",
|
||||
"sound financial advice",
|
||||
] {
|
||||
assert_eq!(preferred_origin(query), None, "{query}");
|
||||
}
|
||||
|
||||
@@ -18,6 +18,99 @@ use usearch::{Index, IndexOptions, MetricKind, ScalarKind};
|
||||
|
||||
pub(crate) const MAX_SCAN_CHUNKS: usize = 100_000;
|
||||
pub(crate) const MIN_COSINE: f32 = 0.25;
|
||||
const SCHEMA_VERSION: i64 = 9;
|
||||
|
||||
fn current_schema_ready(connection: &Connection) -> Result<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.
|
||||
pub(crate) fn media_passage_id(file_id: i64, source_type: &str) -> Result<i64> {
|
||||
@@ -122,24 +215,46 @@ impl Store {
|
||||
let mut connection = Connection::open(db)?;
|
||||
connection.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||
connection.execute_batch("PRAGMA foreign_keys=ON;")?;
|
||||
let initialization =
|
||||
connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||
initialization.execute_batch(
|
||||
let has_meta: bool = connection.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='semantic_meta')",
|
||||
[], |row| row.get(0),
|
||||
)?;
|
||||
let current_version = if has_meta {
|
||||
let version: i64 = connection.query_row(
|
||||
"SELECT schema_version FROM semantic_meta WHERE singleton=1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
ensure!(
|
||||
(1..=SCHEMA_VERSION).contains(&version),
|
||||
"Unsupported semantic schema version {version}"
|
||||
);
|
||||
Some(version)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// WAL readers must not acquire a writer lock just to reopen an already
|
||||
// prepared index. Some earlier schema-9 builds added columns without a
|
||||
// version bump, so incomplete legacy shapes still run normal migration.
|
||||
if current_version != Some(SCHEMA_VERSION) || !current_schema_ready(&connection)? {
|
||||
let initialization =
|
||||
connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||
initialization.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS semantic_meta (
|
||||
singleton INTEGER PRIMARY KEY CHECK(singleton=1), schema_version INTEGER NOT NULL,
|
||||
active_generation INTEGER, corpus_revision INTEGER NOT NULL DEFAULT 0, dirty_floor INTEGER NOT NULL DEFAULT 0);
|
||||
INSERT OR IGNORE INTO semantic_meta(singleton,schema_version) VALUES(1,5);",
|
||||
)?;
|
||||
let version: i64 = initialization.query_row(
|
||||
"SELECT schema_version FROM semantic_meta WHERE singleton=1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
ensure!(
|
||||
(1..=9).contains(&version),
|
||||
"Unsupported semantic schema version {version}"
|
||||
);
|
||||
initialization.execute_batch("
|
||||
let version: i64 = initialization.query_row(
|
||||
"SELECT schema_version FROM semantic_meta WHERE singleton=1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
ensure!(
|
||||
(1..=9).contains(&version),
|
||||
"Unsupported semantic schema version {version}"
|
||||
);
|
||||
initialization.execute_batch("
|
||||
CREATE TABLE IF NOT EXISTS semantic_generations(id INTEGER PRIMARY KEY, fingerprint TEXT NOT NULL, dimensions INTEGER NOT NULL, corpus_revision INTEGER NOT NULL DEFAULT 0, ann_blob BLOB, ann_sha256 TEXT);
|
||||
CREATE TABLE IF NOT EXISTS semantic_chunks(
|
||||
generation INTEGER NOT NULL REFERENCES semantic_generations(id) ON DELETE CASCADE,
|
||||
@@ -148,8 +263,8 @@ impl Store {
|
||||
content_hash TEXT NOT NULL, start_byte INTEGER NOT NULL, end_byte INTEGER NOT NULL,
|
||||
snippet TEXT NOT NULL, vector BLOB NOT NULL,
|
||||
PRIMARY KEY(generation,passage_id,ordinal));")?;
|
||||
if version < 5 {
|
||||
for (table,column,alter) in [
|
||||
if version < 5 {
|
||||
for (table,column,alter) in [
|
||||
("semantic_meta","corpus_revision","ALTER TABLE semantic_meta ADD COLUMN corpus_revision INTEGER NOT NULL DEFAULT 0"),
|
||||
("semantic_meta","dirty_floor","ALTER TABLE semantic_meta ADD COLUMN dirty_floor INTEGER NOT NULL DEFAULT 0"),
|
||||
("semantic_generations","dimensions","ALTER TABLE semantic_generations ADD COLUMN dimensions INTEGER NOT NULL DEFAULT 384"),
|
||||
@@ -160,10 +275,10 @@ impl Store {
|
||||
let exists:bool=initialization.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info(?1) WHERE name=?2)",params![table,column],|row| row.get(0))?;
|
||||
if !exists { initialization.execute_batch(alter)?; }
|
||||
}
|
||||
initialization.execute_batch("DROP TRIGGER IF EXISTS semantic_files_insert;
|
||||
initialization.execute_batch("DROP TRIGGER IF EXISTS semantic_files_insert;
|
||||
DROP TRIGGER IF EXISTS semantic_files_update; DROP TRIGGER IF EXISTS semantic_files_delete;")?;
|
||||
}
|
||||
initialization.execute_batch(
|
||||
}
|
||||
initialization.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS semantic_dirty_paths(path TEXT PRIMARY KEY, revision INTEGER NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS semantic_dirty_revision ON semantic_dirty_paths(revision);
|
||||
CREATE INDEX IF NOT EXISTS semantic_chunk_path ON semantic_chunks(generation,path);
|
||||
@@ -183,45 +298,50 @@ impl Store {
|
||||
ON CONFLICT(path) DO UPDATE SET revision=excluded.revision; END;
|
||||
UPDATE semantic_meta SET schema_version=6 WHERE singleton=1;",
|
||||
)?;
|
||||
for (column, declaration) in [
|
||||
("source_type", "TEXT NOT NULL DEFAULT 'text'"),
|
||||
("file_size", "INTEGER"),
|
||||
("file_mtime", "INTEGER"),
|
||||
] {
|
||||
let exists: bool = initialization.query_row(
|
||||
for (column, declaration) in [
|
||||
("source_type", "TEXT NOT NULL DEFAULT 'text'"),
|
||||
("file_size", "INTEGER"),
|
||||
("file_mtime", "INTEGER"),
|
||||
] {
|
||||
let exists: bool = initialization.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('semantic_chunks') WHERE name=?1)",
|
||||
[column],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
initialization.execute_batch(&format!(
|
||||
"ALTER TABLE semantic_chunks ADD COLUMN {column} {declaration}"
|
||||
))?;
|
||||
if !exists {
|
||||
initialization.execute_batch(&format!(
|
||||
"ALTER TABLE semantic_chunks ADD COLUMN {column} {declaration}"
|
||||
))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
initialization.execute_batch("CREATE TABLE IF NOT EXISTS semantic_media_errors(generation INTEGER NOT NULL REFERENCES semantic_generations(id) ON DELETE CASCADE,path TEXT NOT NULL,message TEXT NOT NULL,PRIMARY KEY(generation,path))")?;
|
||||
let has_speech_end:bool=initialization.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info('semantic_chunks') WHERE name='speech_end_ms')",[],|row|row.get(0))?;
|
||||
if !has_speech_end {
|
||||
initialization.execute(
|
||||
"ALTER TABLE semantic_chunks ADD COLUMN speech_end_ms INTEGER",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
// Probe publication changes filter eligibility, not source discovery identity.
|
||||
// Keep its epoch separate so indexing does not invalidate its own corpus snapshot.
|
||||
initialization.execute_batch("CREATE TABLE IF NOT EXISTS semantic_media_metadata_revision(singleton INTEGER PRIMARY KEY CHECK(singleton=1), revision INTEGER NOT NULL);
|
||||
initialization.execute_batch("CREATE TABLE IF NOT EXISTS semantic_media_errors(generation INTEGER NOT NULL REFERENCES semantic_generations(id) ON DELETE CASCADE,path TEXT NOT NULL,message TEXT NOT NULL,PRIMARY KEY(generation,path))")?;
|
||||
let has_speech_end:bool=initialization.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info('semantic_chunks') WHERE name='speech_end_ms')",[],|row|row.get(0))?;
|
||||
if !has_speech_end {
|
||||
initialization.execute(
|
||||
"ALTER TABLE semantic_chunks ADD COLUMN speech_end_ms INTEGER",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
// Probe publication changes filter eligibility, not source discovery identity.
|
||||
// Keep its epoch separate so indexing does not invalidate its own corpus snapshot.
|
||||
initialization.execute_batch("CREATE TABLE IF NOT EXISTS semantic_media_metadata_revision(singleton INTEGER PRIMARY KEY CHECK(singleton=1), revision INTEGER NOT NULL);
|
||||
INSERT OR IGNORE INTO semantic_media_metadata_revision(singleton,revision) VALUES(1,0);")?;
|
||||
for (name, event) in [
|
||||
("insert", "INSERT"),
|
||||
("update", "UPDATE"),
|
||||
("delete", "DELETE"),
|
||||
] {
|
||||
initialization.execute_batch(&format!("DROP TRIGGER IF EXISTS semantic_media_metadata_{name};
|
||||
for (name, event) in [
|
||||
("insert", "INSERT"),
|
||||
("update", "UPDATE"),
|
||||
("delete", "DELETE"),
|
||||
] {
|
||||
initialization.execute_batch(&format!("DROP TRIGGER IF EXISTS semantic_media_metadata_{name};
|
||||
CREATE TRIGGER semantic_media_metadata_{name} AFTER {event} ON media_metadata BEGIN
|
||||
UPDATE semantic_media_metadata_revision SET revision=revision+1 WHERE singleton=1; END;"))?;
|
||||
}
|
||||
incremental::migrate(&initialization)?;
|
||||
initialization.commit()?;
|
||||
ensure!(
|
||||
current_schema_ready(&connection)?,
|
||||
"Semantic schema initialization incomplete"
|
||||
);
|
||||
}
|
||||
incremental::migrate(&initialization)?;
|
||||
initialization.commit()?;
|
||||
let canonical = db.canonicalize()?;
|
||||
let lock_path = PathBuf::from(format!("{}.semantic.lock", canonical.to_string_lossy()));
|
||||
Ok(Self {
|
||||
@@ -2068,3 +2188,59 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
#[path = "ann_benchmark.rs"]
|
||||
mod ann_benchmark;
|
||||
|
||||
#[cfg(test)]
|
||||
mod reopen_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn initialized_wal_store_reopens_during_an_owned_write_transaction() -> Result<()> {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let db = temporary.path().join("index.db");
|
||||
crate::HybridSearch::prepare_database(&db)?;
|
||||
let mut writer = Connection::open(&db)?;
|
||||
let transaction =
|
||||
writer.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||
transaction.execute(
|
||||
"UPDATE semantic_meta SET corpus_revision=corpus_revision+1 WHERE singleton=1",
|
||||
[],
|
||||
)?;
|
||||
let started = std::time::Instant::now();
|
||||
let reader = Store::open(&db, 768)?;
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
assert_eq!(
|
||||
reader.connection.query_row(
|
||||
"SELECT corpus_revision FROM semantic_meta WHERE singleton=1",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)?,
|
||||
0
|
||||
);
|
||||
transaction.rollback()?;
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
fn legacy_schema_nine_additions_are_repaired_and_future_versions_rejected() -> Result<()> {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let db = temporary.path().join("index.db");
|
||||
crate::HybridSearch::prepare_database(&db)?;
|
||||
let connection = Connection::open(&db)?;
|
||||
connection.execute_batch("ALTER TABLE semantic_chunks DROP COLUMN speech_end_ms; DROP TRIGGER semantic_media_metadata_update;")?;
|
||||
assert!(!current_schema_ready(&connection)?);
|
||||
drop(Store::open(&db, 768)?);
|
||||
assert!(current_schema_ready(&connection)?);
|
||||
connection.execute("UPDATE semantic_meta SET schema_version=10", [])?;
|
||||
let error = match Store::open(&db, 768) {
|
||||
Ok(_) => panic!("future schema accepted"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("Unsupported semantic schema version 10"));
|
||||
assert_eq!(
|
||||
connection.query_row("SELECT schema_version FROM semantic_meta", [], |row| row
|
||||
.get::<_, i64>(0))?,
|
||||
10
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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 std::{fs, path::PathBuf};
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires verified Google CPU worker/model and FFmpeg fixtures"]
|
||||
fn explicit_media_requests_do_not_fall_back_to_unrelated_channels() -> Result<()> {
|
||||
assert_eq!(std::env::var("SEARCHI_FORCE_CPU")?, "1");
|
||||
let model = std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Set model directory");
|
||||
let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Set worker path");
|
||||
let tools = FfmpegTools {
|
||||
ffmpeg: std::env::var_os("SEARCHI_TEST_FFMPEG")
|
||||
.expect("Set FFmpeg")
|
||||
.into(),
|
||||
ffprobe: std::env::var_os("SEARCHI_TEST_FFPROBE")
|
||||
.expect("Set FFprobe")
|
||||
.into(),
|
||||
};
|
||||
let fixture =
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/media/assets/image01.jpg");
|
||||
let temp = tempfile::tempdir()?;
|
||||
let corpus = temp.path().join("corpus");
|
||||
fs::create_dir(&corpus)?;
|
||||
fs::copy(fixture, corpus.join("cat.jpg"))?;
|
||||
let database = temp.path().join("index.db");
|
||||
let mut lexical = SearchIndex::open(&database)?;
|
||||
lexical.index_folder(&corpus)?;
|
||||
let mut indexer = HybridSearch::open(&database, &model, &worker, 1)?;
|
||||
let report = indexer.index_embeddings_with_media(&tools, &MediaLimits::default())?;
|
||||
assert!(report.failures.is_empty(), "{report:?}");
|
||||
assert_eq!(report.media_files, 1);
|
||||
drop(indexer);
|
||||
let mut search = HybridSearch::open_query(&database, &model, &worker, 1)?;
|
||||
let options = SearchOptions::default();
|
||||
// The unfiltered real model does return the nearest photo for these requests.
|
||||
// The correction rejects its wrong channel, not its similarity score.
|
||||
for query in [
|
||||
"music with violin",
|
||||
"audio of thunder",
|
||||
"someone mentions a contract",
|
||||
"video of a boat",
|
||||
"Musik mit Klavier",
|
||||
"Film mit einem Wasserfall",
|
||||
] {
|
||||
assert!(
|
||||
!search
|
||||
.search_semantic_with_origin(query, 10, &options, SemanticOrigin::All)?
|
||||
.is_empty(),
|
||||
"{query}"
|
||||
);
|
||||
assert!(
|
||||
search.search_semantic(query, 10, &options)?.is_empty(),
|
||||
"{query}"
|
||||
);
|
||||
}
|
||||
for query in ["photo of a cat indoors", "Bild einer Katze", "cat indoors"] {
|
||||
let hits = search.search_semantic(query, 10, &options)?;
|
||||
assert_eq!(hits[0].filename, "cat.jpg", "{query}");
|
||||
assert_eq!(hits[0].match_kind, "semantic_image", "{query}");
|
||||
}
|
||||
assert_eq!(
|
||||
search.search("cat.jpg", 1, &options)?[0].match_kind,
|
||||
"exact_filename"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires verified Google CPU worker/model and FFmpeg fixtures"]
|
||||
fn explicit_code_intent_preserves_code_among_real_audio_candidates() -> Result<()> {
|
||||
|
||||
@@ -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.
|
||||
@@ -48,13 +48,15 @@ copy into unsupported performance or production-readiness claims.
|
||||
## Window materials
|
||||
|
||||
Appearance settings offer Solid, Glass and Frosted glass, with a bounded
|
||||
70–100% tint-strength control (90% by default). Frosted glass layers an extra
|
||||
35% milk tint beneath that control; Glass has the lighter direct tint. Both
|
||||
40–100% background-opacity control (90% by default). Both materials use the selected opacity directly, without a second milk-tint
|
||||
layer. The slider previews changes while dragging. Both
|
||||
currently share Windows native acrylic blur. Native compositor effects expose and soften
|
||||
the actual desktop; CSS transparency alone cannot blur other windows. If the
|
||||
platform cannot apply its native effect, use an honest opaque fallback. Forced
|
||||
colors always make the shell opaque while retaining the saved preference.
|
||||
Search input, results, previews and settings keep their opaque reading surfaces;
|
||||
The search input and shell labels use a local 60% reading tint, so the lowest
|
||||
shell setting still yields at least 76% theme tint under text. Results, previews
|
||||
and settings keep opaque reading surfaces;
|
||||
never lower text opacity or introduce decorative wallpaper simulations.
|
||||
Native acrylic currently requires Windows 11 22H2 or later with a compatible
|
||||
compositor and system transparency settings. macOS, Linux and older Windows
|
||||
|
||||
@@ -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).
|
||||
@@ -1,9 +1,13 @@
|
||||
# Linux desktop release build evidence
|
||||
|
||||
Current download: [Ubuntu 24.04 x64 desktop 0.1.4](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.4/searchi_0.1.4_amd64.deb).
|
||||
See [current package checks](LINUX-0.1.4.md) and
|
||||
[checksums](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.4/LINUX-DESKTOP-SHA256SUMS-0.1.4.txt).
|
||||
The evidence below records the preceding 0.1.3 preparation.
|
||||
Current download: [Ubuntu 24.04 x64 desktop 0.1.8](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.8/searchi_0.1.8_amd64.deb).
|
||||
See [final package checks](NATIVE-FINAL-VALIDATION.md) and
|
||||
[checksum](https://tea.karlcom.de/karl/searchi/releases/download/v0.1.8/searchi_0.1.8_amd64.deb.sha256).
|
||||
The current package includes the floating launcher, automatic local discovery,
|
||||
application search and hybrid relevance cleanup. Native compilation, 56 desktop
|
||||
tests, runtime provenance, loader checks and packaged CPU startup passed on
|
||||
Ubuntu 24.04 WSL. Native graphical interaction and other distributions remain
|
||||
unverified. The historical evidence below records preceding releases.
|
||||
|
||||
Source: `a6bf091`, desktop 0.1.3, Ubuntu 24.04 WSL x86-64, kernel
|
||||
6.18.54.1-microsoft-standard-WSL2, two virtual CPU cores (AMD Ryzen 7 7800X3D).
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -1,6 +1,53 @@
|
||||
# Production readiness roadmap
|
||||
|
||||
Status: Windows v0.1.7 published and installed with resident tray/global shortcut and login startup; Linux headless v0.1.6 and desktop v0.1.4 previews published. Broader production gates remain open. Windows VM tests use CPU only.
|
||||
## Current bounded goal — 2026-10-14
|
||||
|
||||
Karl replaces the broad production goal with two deliverables. Work stays local;
|
||||
there are no intermediate pushes or releases. Publish only the final checked version.
|
||||
|
||||
- [ ] Verify native Windows/Linux interaction: launcher shortcut, tray, hide/show,
|
||||
keyboard search, selection, preview and media seeking. Record unavailable UI
|
||||
automation honestly rather than substituting headless tests.
|
||||
- [x] Test Windows uninstall and reinstall, preserving existing user data and
|
||||
using a fresh isolated application profile. Reinstalling on the development
|
||||
VM does not prove missing prerequisites on a truly clean Windows image.
|
||||
- [x] Establish bounded media baseline and improve practical image, music,
|
||||
audio/video relevance, absent-content results and speech timestamps. Use real
|
||||
Google EmbeddingGemma 2, retain source citations and report regressions.
|
||||
- [ ] Run meaningful changed-code checks, build/install the final package,
|
||||
update user documentation and publish that final version once.
|
||||
|
||||
Perfect media recognition, macOS, real GPU qualification and the broader gates
|
||||
below are outside this renewed scope. Stop experimentation when evidence shows
|
||||
the practical limits; explain those limits and ship the verified improvement.
|
||||
|
||||
Additional user steering in this same final local version:
|
||||
- [x] Replace the normal desktop window with a centered floating search bar;
|
||||
reveal results/preview on input and keep settings behind a small control.
|
||||
- [x] Automatically index accessible local files in the background, with
|
||||
system/cache/index exclusions and a settings switch; no first-use folder gate.
|
||||
- [x] Search installed applications and launch catalogued results from the bar.
|
||||
|
||||
Bounded evidence: uninstall/reinstall and isolated-profile Windows checks passed;
|
||||
actual CPU Google media tests and the documented music/timing limits are recorded
|
||||
in MEDIA-FINAL-QUALITY.md and SPEECH-FINAL-QUALITY.md. Launcher/app discovery and
|
||||
automatic-indexing code passed Windows/Linux desktop tests. Current software
|
||||
screenshots show actual retrieval and source highlights. Native final-build
|
||||
hotkey/tray/glass interaction remains unchecked after capture/input failures;
|
||||
do not infer it from unit tests or software screenshots. A final automatic-start
|
||||
check exposed a schema writer conflict. Its correction passed held-writer
|
||||
storage/warmup regressions and actual Google CPU retrieval. An empty Windows
|
||||
profile with automatic indexing enabled then exposed 27 Desktop files and the
|
||||
owned marker, with a resident CPU query-role worker and no schema-lock error.
|
||||
|
||||
Final bounded delivery: Windows desktop 0.1.8 is installed and its corrected
|
||||
installer verified; Ubuntu 24.04 desktop 0.1.8 passed packaged CPU startup.
|
||||
These are the final release artifacts. README includes current working-search
|
||||
images and a user-facing agent installation prompt. Headless Linux/Windows CLI
|
||||
0.1.6 remain available through older releases. The native interaction checkbox
|
||||
above stays open because final capture/input was denied; clean-machine, macOS
|
||||
and real GPU gates below remain deferred. No further feature work belongs to
|
||||
this bounded delivery. Gitea Actions remain disabled.
|
||||
|
||||
Karl's release priority, 2026-10-13: publish a usable first Windows version and
|
||||
install it on DEV-WIN11 now. Extensive platform/GPU, clean-machine, signing and
|
||||
|
||||
@@ -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.
|
||||
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"kind": "browser-rendered-production-ui-real-cpu-engine",
|
||||
"source": {
|
||||
"base_commit": "9870df7cb5b537ba516997a0d049b996bc30b08d",
|
||||
"base_commit": "01279a6b25b545d0920889bf277abe00631a666d",
|
||||
"working_tree_dirty": true
|
||||
},
|
||||
"frontend_build": {
|
||||
"version": "0.1.8",
|
||||
"index_sha256": "8415df31094999f0d8bae5a6eb06db160132f1dc4657d1a329a24a6125defe0b"
|
||||
},
|
||||
"viewport": {
|
||||
"width": 1440,
|
||||
"height": 1000
|
||||
@@ -33,11 +37,11 @@
|
||||
{
|
||||
"query": "cancel the supplier partnership",
|
||||
"mode": "basic",
|
||||
"result_count": 7,
|
||||
"result_count": 1,
|
||||
"metrics": {
|
||||
"dropped_spans": 0,
|
||||
"result_count": 7,
|
||||
"total_ms": 2.0688999999999997,
|
||||
"result_count": 1,
|
||||
"total_ms": 73.3586,
|
||||
"trace": null
|
||||
},
|
||||
"first_filename": "Supplier Agreement.pdf",
|
||||
@@ -48,17 +52,18 @@
|
||||
"ui_checks": {
|
||||
"literal_query_marks": true,
|
||||
"actual_page_explanation": true,
|
||||
"why_disclosure_collapsed_by_default": true
|
||||
"why_disclosure_collapsed_by_default": true,
|
||||
"unrelated_photos_removed": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"query": "mountains reflected in calm water",
|
||||
"mode": "basic",
|
||||
"result_count": 7,
|
||||
"result_count": 2,
|
||||
"metrics": {
|
||||
"dropped_spans": 0,
|
||||
"result_count": 7,
|
||||
"total_ms": 1.5953,
|
||||
"result_count": 2,
|
||||
"total_ms": 76.9483,
|
||||
"trace": null
|
||||
},
|
||||
"first_filename": "Mountain Lake.jpg",
|
||||
@@ -67,40 +72,41 @@
|
||||
"number": 0
|
||||
},
|
||||
"ui_checks": {
|
||||
"meaning_only_image_has_no_text_marks": true
|
||||
"meaning_only_image_has_no_text_marks": true,
|
||||
"unrelated_documents_removed": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"query": "how can we cancel the supplier partnership",
|
||||
"mode": "debug",
|
||||
"result_count": 7,
|
||||
"result_count": 1,
|
||||
"metrics": {
|
||||
"dropped_spans": 0,
|
||||
"result_count": 7,
|
||||
"total_ms": 92.8279,
|
||||
"result_count": 1,
|
||||
"total_ms": 90.92160000000001,
|
||||
"trace": [
|
||||
{
|
||||
"counters": {},
|
||||
"duration_ms": 92.80539999999999,
|
||||
"duration_ms": 90.8989,
|
||||
"name": "hybrid.search",
|
||||
"parent": null,
|
||||
"start_ms": 0.0106
|
||||
"start_ms": 0.0108
|
||||
},
|
||||
{
|
||||
"counters": {},
|
||||
"duration_ms": 2.6003,
|
||||
"duration_ms": 1.8246,
|
||||
"name": "lexical.search",
|
||||
"parent": 0,
|
||||
"start_ms": 0.027
|
||||
"start_ms": 0.0306
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"query_bytes": 42
|
||||
},
|
||||
"duration_ms": 0.0389,
|
||||
"duration_ms": 0.0422,
|
||||
"name": "lexical.normalization",
|
||||
"parent": 1,
|
||||
"start_ms": 0.0374
|
||||
"start_ms": 0.041100000000000005
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
@@ -108,148 +114,149 @@
|
||||
"frequency_cache_misses": 8,
|
||||
"trigram_index": 1
|
||||
},
|
||||
"duration_ms": 1.0081,
|
||||
"duration_ms": 0.2968,
|
||||
"name": "lexical.candidate_plan",
|
||||
"parent": 1,
|
||||
"start_ms": 0.08650000000000001
|
||||
"start_ms": 0.094
|
||||
},
|
||||
{
|
||||
"counters": {},
|
||||
"duration_ms": 0.2286,
|
||||
"duration_ms": 0.3236,
|
||||
"name": "lexical.exact_filename",
|
||||
"parent": 1,
|
||||
"start_ms": 1.1168
|
||||
"start_ms": 0.4067
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"filters_active": 0,
|
||||
"rows": 0
|
||||
},
|
||||
"duration_ms": 0.1837,
|
||||
"duration_ms": 0.2841,
|
||||
"name": "lexical.filtered_sql",
|
||||
"parent": 4,
|
||||
"start_ms": 1.1471
|
||||
"start_ms": 0.4364
|
||||
},
|
||||
{
|
||||
"counters": {},
|
||||
"duration_ms": 0.2688,
|
||||
"duration_ms": 0.2009,
|
||||
"name": "lexical.filename_substring",
|
||||
"parent": 1,
|
||||
"start_ms": 1.3663
|
||||
"start_ms": 0.7506999999999999
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"filters_active": 0,
|
||||
"rows": 0
|
||||
},
|
||||
"duration_ms": 0.2249,
|
||||
"duration_ms": 0.17229999999999998,
|
||||
"name": "lexical.filtered_sql",
|
||||
"parent": 6,
|
||||
"start_ms": 1.3906999999999998
|
||||
"start_ms": 0.7678999999999999
|
||||
},
|
||||
{
|
||||
"counters": {},
|
||||
"duration_ms": 0.2146,
|
||||
"duration_ms": 0.1716,
|
||||
"name": "lexical.path_substring",
|
||||
"parent": 1,
|
||||
"start_ms": 1.6556
|
||||
"start_ms": 0.9650000000000001
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"filters_active": 0,
|
||||
"rows": 0
|
||||
},
|
||||
"duration_ms": 0.175,
|
||||
"duration_ms": 0.145,
|
||||
"name": "lexical.filtered_sql",
|
||||
"parent": 8,
|
||||
"start_ms": 1.6802
|
||||
"start_ms": 0.9821000000000001
|
||||
},
|
||||
{
|
||||
"counters": {},
|
||||
"duration_ms": 0.2391,
|
||||
"duration_ms": 0.229,
|
||||
"name": "lexical.content",
|
||||
"parent": 1,
|
||||
"start_ms": 1.8861999999999999
|
||||
"start_ms": 1.1488
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"filters_active": 0,
|
||||
"rows": 0
|
||||
},
|
||||
"duration_ms": 0.2053,
|
||||
"duration_ms": 0.2014,
|
||||
"name": "lexical.filtered_sql",
|
||||
"parent": 10,
|
||||
"start_ms": 1.9049
|
||||
"start_ms": 1.1647
|
||||
},
|
||||
{
|
||||
"counters": {},
|
||||
"duration_ms": 0.3054,
|
||||
"duration_ms": 0.27080000000000004,
|
||||
"name": "lexical.filename_terms",
|
||||
"parent": 1,
|
||||
"start_ms": 2.1413
|
||||
"start_ms": 1.3904
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"filters_active": 0,
|
||||
"rows": 0
|
||||
},
|
||||
"duration_ms": 0.2773,
|
||||
"duration_ms": 0.2424,
|
||||
"name": "lexical.filtered_sql",
|
||||
"parent": 12,
|
||||
"start_ms": 2.1576999999999997
|
||||
"start_ms": 1.4071
|
||||
},
|
||||
{
|
||||
"counters": {},
|
||||
"duration_ms": 0.146,
|
||||
"duration_ms": 0.162,
|
||||
"name": "lexical.media_metadata",
|
||||
"parent": 1,
|
||||
"start_ms": 2.4598
|
||||
"start_ms": 1.675
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"filters_active": 0,
|
||||
"rows": 0
|
||||
},
|
||||
"duration_ms": 0.112,
|
||||
"duration_ms": 0.1344,
|
||||
"name": "lexical.filtered_sql",
|
||||
"parent": 14,
|
||||
"start_ms": 2.4749
|
||||
"start_ms": 1.6907999999999999
|
||||
},
|
||||
{
|
||||
"counters": {},
|
||||
"duration_ms": 0.0155,
|
||||
"duration_ms": 0.015000000000000001,
|
||||
"name": "hybrid.identifier_priority",
|
||||
"parent": 0,
|
||||
"start_ms": 2.6382
|
||||
"start_ms": 1.8684999999999998
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"code_task": 0,
|
||||
"failed": 0,
|
||||
"query_cache_hit": 0,
|
||||
"query_cache_miss": 1,
|
||||
"request_encode_us": 18,
|
||||
"request_write_us": 58,
|
||||
"response_validate_us": 69,
|
||||
"worker_batch_prepare_us": 19,
|
||||
"worker_inference_us": 87311,
|
||||
"worker_pool_normalize_us": 14,
|
||||
"request_encode_us": 25,
|
||||
"request_write_us": 197,
|
||||
"response_validate_us": 97,
|
||||
"worker_batch_prepare_us": 21,
|
||||
"worker_inference_us": 86213,
|
||||
"worker_pool_normalize_us": 19,
|
||||
"worker_token_count": 16,
|
||||
"worker_tokenization_us": 173,
|
||||
"worker_wait_and_response_decode_us": 88454
|
||||
"worker_tokenization_us": 179,
|
||||
"worker_wait_and_response_decode_us": 87455
|
||||
},
|
||||
"duration_ms": 89.1821,
|
||||
"duration_ms": 87.9572,
|
||||
"name": "semantic.query_embedding",
|
||||
"parent": 0,
|
||||
"start_ms": 2.6649
|
||||
"start_ms": 1.8927
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"general_lane": 1
|
||||
},
|
||||
"duration_ms": 0.8757,
|
||||
"duration_ms": 0.9468,
|
||||
"name": "semantic.routing",
|
||||
"parent": 0,
|
||||
"start_ms": 91.8792
|
||||
"start_ms": 89.89030000000001
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
@@ -257,99 +264,110 @@
|
||||
"filesystem_checks": 2,
|
||||
"result_files": 7
|
||||
},
|
||||
"duration_ms": 0.8334,
|
||||
"duration_ms": 0.8935,
|
||||
"name": "semantic.retrieval",
|
||||
"parent": 18,
|
||||
"start_ms": 91.90950000000001
|
||||
"start_ms": 89.93039999999999
|
||||
},
|
||||
{
|
||||
"counters": {},
|
||||
"duration_ms": 0.1384,
|
||||
"duration_ms": 0.14650000000000002,
|
||||
"name": "semantic.generation_snapshot",
|
||||
"parent": 19,
|
||||
"start_ms": 91.9247
|
||||
"start_ms": 89.953
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"cache_hit": 1
|
||||
},
|
||||
"duration_ms": 0.0115,
|
||||
"duration_ms": 0.0167,
|
||||
"name": "semantic.ann_load",
|
||||
"parent": 19,
|
||||
"start_ms": 92.07310000000001
|
||||
"start_ms": 90.1134
|
||||
},
|
||||
{
|
||||
"counters": {},
|
||||
"duration_ms": 0.011,
|
||||
"duration_ms": 0.0167,
|
||||
"name": "semantic.filter_normalization",
|
||||
"parent": 19,
|
||||
"start_ms": 92.0933
|
||||
"start_ms": 90.14410000000001
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"cache_hit": 1,
|
||||
"eligible_chunks": 8
|
||||
},
|
||||
"duration_ms": 0.0252,
|
||||
"duration_ms": 0.034999999999999996,
|
||||
"name": "semantic.eligibility",
|
||||
"parent": 19,
|
||||
"start_ms": 92.1129
|
||||
"start_ms": 90.1714
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"requested_chunks": 8
|
||||
},
|
||||
"duration_ms": 0.5494,
|
||||
"duration_ms": 0.5680999999999999,
|
||||
"name": "semantic.ann_pass",
|
||||
"parent": 19,
|
||||
"start_ms": 92.1485
|
||||
"start_ms": 90.2198
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"returned_chunks": 8
|
||||
},
|
||||
"duration_ms": 0.0347,
|
||||
"duration_ms": 0.0356,
|
||||
"name": "semantic.ann_search",
|
||||
"parent": 24,
|
||||
"start_ms": 92.161
|
||||
"start_ms": 90.2333
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"filesystem_checks": 2,
|
||||
"unique_parents": 7
|
||||
},
|
||||
"duration_ms": 0.4802,
|
||||
"duration_ms": 0.4973,
|
||||
"name": "semantic.parent_materialization",
|
||||
"parent": 24,
|
||||
"start_ms": 92.2066
|
||||
"start_ms": 90.2796
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"valid": 1
|
||||
},
|
||||
"duration_ms": 0.20550000000000002,
|
||||
"duration_ms": 0.193,
|
||||
"name": "semantic.parent_filesystem_validation",
|
||||
"parent": 26,
|
||||
"start_ms": 92.30539999999999
|
||||
"start_ms": 90.38690000000001
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"valid": 1
|
||||
},
|
||||
"duration_ms": 0.1146,
|
||||
"duration_ms": 0.1215,
|
||||
"name": "semantic.parent_filesystem_validation",
|
||||
"parent": 26,
|
||||
"start_ms": 92.5374
|
||||
"start_ms": 90.6088
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"candidates_after": 1,
|
||||
"candidates_before": 7,
|
||||
"tail_candidates_pruned": 6
|
||||
},
|
||||
"duration_ms": 0.0216,
|
||||
"name": "hybrid.semantic_shortlist",
|
||||
"parent": 0,
|
||||
"start_ms": 90.8471
|
||||
},
|
||||
{
|
||||
"counters": {
|
||||
"lexical_files": 0,
|
||||
"semantic_files": 7
|
||||
"semantic_files": 1
|
||||
},
|
||||
"duration_ms": 0.0376,
|
||||
"duration_ms": 0.02,
|
||||
"name": "hybrid.rank_fusion",
|
||||
"parent": 0,
|
||||
"start_ms": 92.7663
|
||||
"start_ms": 90.8783
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -361,79 +379,130 @@
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"file": "launcher-empty.png",
|
||||
"sha256": "276ddb994d3b361c707d6afdadf28976d6386be9103baba19abbfe07fd4a3024",
|
||||
"query": "",
|
||||
"mode": "empty-ready",
|
||||
"result_count": 0,
|
||||
"selected_filename": null,
|
||||
"theme": "light",
|
||||
"capture_bounds": {
|
||||
"x": 360,
|
||||
"y": 120,
|
||||
"width": 720,
|
||||
"height": 110
|
||||
},
|
||||
"state_assertions": {
|
||||
"semantic_ready": true,
|
||||
"compact_empty_layout": true,
|
||||
"native_pixels": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "search-light.png",
|
||||
"sha256": "bd45041de13093f7a7e26285618f4c1afc5b081fb41e51ff06e1313216af3562",
|
||||
"sha256": "75fd2ebc9b40dcfb8501ee9ff0e14dc0b3a2cccc223b672f66c6feb4fe5f5858",
|
||||
"query": "cancel the supplier partnership",
|
||||
"mode": "basic",
|
||||
"result_count": 7,
|
||||
"result_count": 1,
|
||||
"selected_filename": "Supplier Agreement.pdf",
|
||||
"first_source_kind": "page",
|
||||
"first_source_number": 2,
|
||||
"theme": "light",
|
||||
"summary": "7 RESULTS · KEYWORD + MEANING · SEARCH 2.1 MS · SHOWN 233 MS",
|
||||
"summary": "1 RESULTS · KEYWORD + MEANING · SEARCH 73 MS · SHOWN 306 MS",
|
||||
"capture_bounds": {
|
||||
"x": 180,
|
||||
"y": 0,
|
||||
"width": 1080,
|
||||
"height": 1000
|
||||
},
|
||||
"state_assertions": {
|
||||
"wide_split_preview": true,
|
||||
"semantic_ready": true,
|
||||
"hybrid_final": true,
|
||||
"image_visible": false,
|
||||
"actual_worker_inference": false,
|
||||
"literal_query_marks": true,
|
||||
"actual_page_explanation": true,
|
||||
"why_disclosure_collapsed_by_default": true
|
||||
"why_disclosure_collapsed_by_default": true,
|
||||
"unrelated_photos_removed": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "search-dark.png",
|
||||
"sha256": "cd7e2aa3f7027cd9b40965171a232f6ee901e1e2b971f04628b4cfcc3b440263",
|
||||
"sha256": "45c3b7f5072d22e4f9c52549727ad63ce122650c2cd105680d40d38b8e0730e1",
|
||||
"query": "cancel the supplier partnership",
|
||||
"mode": "basic",
|
||||
"result_count": 7,
|
||||
"result_count": 1,
|
||||
"selected_filename": "Supplier Agreement.pdf",
|
||||
"first_source_kind": "page",
|
||||
"first_source_number": 2,
|
||||
"theme": "dark",
|
||||
"summary": "7 RESULTS · KEYWORD + MEANING · SEARCH 2.1 MS · SHOWN 233 MS",
|
||||
"summary": "1 RESULTS · KEYWORD + MEANING · SEARCH 73 MS · SHOWN 306 MS",
|
||||
"capture_bounds": {
|
||||
"x": 180,
|
||||
"y": 0,
|
||||
"width": 1080,
|
||||
"height": 1000
|
||||
},
|
||||
"state_assertions": {
|
||||
"wide_split_preview": true,
|
||||
"semantic_ready": true,
|
||||
"hybrid_final": true,
|
||||
"image_visible": false,
|
||||
"actual_worker_inference": false,
|
||||
"literal_query_marks": true,
|
||||
"actual_page_explanation": true,
|
||||
"why_disclosure_collapsed_by_default": true
|
||||
"why_disclosure_collapsed_by_default": true,
|
||||
"unrelated_photos_removed": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "media-search.png",
|
||||
"sha256": "4c278446963cd5dbad073bea93a3ca33fb9c10cb43a9544258d5fb6039014d81",
|
||||
"sha256": "718ffd4e7fa0a21ef717a0773749c0b72a1cbae4968bebb0c26e7f4d68133488",
|
||||
"query": "mountains reflected in calm water",
|
||||
"mode": "basic",
|
||||
"result_count": 7,
|
||||
"result_count": 2,
|
||||
"selected_filename": "Mountain Lake.jpg",
|
||||
"first_source_kind": "image",
|
||||
"first_source_number": 0,
|
||||
"theme": "light",
|
||||
"summary": "7 RESULTS · KEYWORD + MEANING · SEARCH 1.6 MS · SHOWN 217 MS",
|
||||
"summary": "2 RESULTS · KEYWORD + MEANING · SEARCH 77 MS · SHOWN 280 MS",
|
||||
"capture_bounds": {
|
||||
"x": 180,
|
||||
"y": 0,
|
||||
"width": 1080,
|
||||
"height": 1000
|
||||
},
|
||||
"state_assertions": {
|
||||
"wide_split_preview": true,
|
||||
"semantic_ready": true,
|
||||
"hybrid_final": true,
|
||||
"image_visible": true,
|
||||
"actual_worker_inference": false,
|
||||
"meaning_only_image_has_no_text_marks": true
|
||||
"meaning_only_image_has_no_text_marks": true,
|
||||
"unrelated_documents_removed": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"file": "performance-details.png",
|
||||
"sha256": "4f336d4c42d0c4a50ce33bca27595f02da7d896c8734d447350d50dab5c339df",
|
||||
"sha256": "945dec2b087961a4cfa073983f4ac055c6f7b75c736aaaa5094596ffdd959713",
|
||||
"query": "how can we cancel the supplier partnership",
|
||||
"mode": "debug",
|
||||
"result_count": 7,
|
||||
"result_count": 1,
|
||||
"selected_filename": "Supplier Agreement.pdf",
|
||||
"first_source_kind": "page",
|
||||
"first_source_number": 2,
|
||||
"theme": "light",
|
||||
"summary": "7 RESULTS · KEYWORD + MEANING · SEARCH 93 MS · SHOWN 311 MS",
|
||||
"summary": "1 RESULTS · KEYWORD + MEANING · SEARCH 91 MS · SHOWN 306 MS",
|
||||
"capture_bounds": {
|
||||
"x": 180,
|
||||
"y": 0,
|
||||
"width": 1080,
|
||||
"height": 1000
|
||||
},
|
||||
"state_assertions": {
|
||||
"wide_split_preview": true,
|
||||
"semantic_ready": true,
|
||||
"hybrid_final": true,
|
||||
"image_visible": false,
|
||||
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 439 KiB After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 80 KiB |
@@ -22,6 +22,7 @@ then warms its retained CPU worker before listening on 127.0.0.1:1431.
|
||||
|
||||
```powershell
|
||||
$env:SEARCHI_FORCE_CPU = '1'
|
||||
$env:SEARCHI_EXTRACT_WORKER = (Resolve-Path target/release/searchi-extract-worker.exe).Path
|
||||
cargo run --manifest-path tools/desktop-capture/backend/Cargo.toml -- --root .searchi/readme-capture/files --db .searchi/readme-capture/index.sqlite --models .searchi/models/embeddinggemma2 --worker .searchi/runtime/cpu/Release/searchi-gemma-worker.exe --dist apps/desktop/dist --ffmpeg .searchi/media-runtime/bin/ffmpeg.exe --ffprobe .searchi/media-runtime/bin/ffprobe.exe
|
||||
```
|
||||
|
||||
@@ -46,12 +47,14 @@ loaded fonts, image pixels, and genuine worker inference counters in debug mode.
|
||||
|
||||
## Artifacts and limits
|
||||
|
||||
Outputs under docs/screenshots are search-light.png, search-dark.png,
|
||||
Outputs under docs/screenshots are launcher-empty.png, search-light.png, search-dark.png,
|
||||
media-search.png and performance-details.png, plus capture-manifest.json. The
|
||||
manifest contains actual fixture queries, counts/selection, measured response
|
||||
metrics, device/model, viewport, PNG SHA256, runner/browser versions, Git base
|
||||
commit and dirty-state flag. The base commit does not identify uncommitted
|
||||
source exactly. The ephemeral API token is never saved in the manifest.
|
||||
source exactly. It also records the actual frontend package version and built
|
||||
index SHA-256. The compact empty-bar capture has no manufactured results and
|
||||
uses the genuine ready backend. The ephemeral API token is never saved in the manifest.
|
||||
|
||||
Captures are **browser-rendered production UI with the actual CPU engine**.
|
||||
They omit native window chrome. They do not establish native WebView visual QA,
|
||||
|
||||
@@ -1129,11 +1129,16 @@ name = "searchi-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"libc",
|
||||
"lopdf",
|
||||
"quick-xml",
|
||||
"rusqlite",
|
||||
"searchi-media",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"walkdir",
|
||||
"windows-sys",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -1192,11 +1197,22 @@ dependencies = [
|
||||
"searchi-core",
|
||||
"searchi-media",
|
||||
"searchi-semantic",
|
||||
"searchi-speech",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"usearch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "searchi-speech"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
|
||||
@@ -44,6 +44,8 @@ struct Filters {
|
||||
max_duration_ms: Option<u32>,
|
||||
modified_after_unix_ms: Option<i64>,
|
||||
modified_before_unix_ms: Option<i64>,
|
||||
media_artist: Option<String>,
|
||||
media_title: Option<String>,
|
||||
}
|
||||
impl Filters {
|
||||
fn options(self, root: &Path) -> Result<SearchOptions> {
|
||||
@@ -63,6 +65,8 @@ impl Filters {
|
||||
max_duration_ms: self.max_duration_ms,
|
||||
modified_after_unix_ms: self.modified_after_unix_ms,
|
||||
modified_before_unix_ms: self.modified_before_unix_ms,
|
||||
media_artist: self.media_artist,
|
||||
media_title: self.media_title,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -87,6 +91,10 @@ fn main() -> Result<()> {
|
||||
let mut lexical = SearchIndex::open(&args.db)?;
|
||||
validate_database(&args.db, &root)?;
|
||||
lexical.index_folder(&root)?;
|
||||
ensure!(
|
||||
lexical.extracted_source_hash(&root.join("Supplier Agreement.pdf").to_string_lossy())?.is_some(),
|
||||
"Capture PDF was not genuinely extracted; set SEARCHI_EXTRACT_WORKER to the verified document helper"
|
||||
);
|
||||
lexical.prewarm()?;
|
||||
let tools = FfmpegTools::new(args.ffmpeg, args.ffprobe)?;
|
||||
let mut hybrid = HybridSearch::open(&args.db, &args.models, &args.worker, 1)?;
|
||||
@@ -287,7 +295,7 @@ impl App {
|
||||
json!({"model":"Google EmbeddingGemma 2","ready":true,"model_directory":self.models,"worker_ready":true,"downloading":false,"embedding":false,"device_policy":"cpu","error":null,"downloaded_bytes":864676480u64,"total_bytes":864676480u64,"generation":status.generation,"chunks":status.chunks,"stale":status.stale,"pending":status.pending,"warming":false,"worker_info":self.hybrid.worker_info(),"media_tools_ready":true,"media_setup_error":null,"media_failures":status.failures}),
|
||||
)
|
||||
}
|
||||
"search_measured" | "hybrid_search_measured" => {
|
||||
"search_measured" | "hybrid_search_measured" | "semantic_search_raw" => {
|
||||
let query = args["query"].as_str().context("Query required")?;
|
||||
ensure!(query.len() <= 4096, "Query too large");
|
||||
let filters: Filters = if args["filters"].is_null() {
|
||||
@@ -297,7 +305,9 @@ impl App {
|
||||
};
|
||||
let options = filters.options(&self.root)?;
|
||||
let debug = args["debugPerformance"].as_bool().unwrap_or(false);
|
||||
if name == "search_measured" {
|
||||
if name == "semantic_search_raw" {
|
||||
Ok(serde_json::to_value(self.hybrid.search_semantic(query, 50, &options)?)?)
|
||||
} else if name == "search_measured" {
|
||||
Ok(serde_json::to_value(
|
||||
self.lexical
|
||||
.search_with_options_measured(query, 50, &options, debug)?,
|
||||
|
||||
@@ -92,6 +92,28 @@ try {
|
||||
.evaluate((image) => image.complete && image.naturalWidth > 0),
|
||||
"Brand asset failed to load",
|
||||
);
|
||||
// Capture the actual ready/empty production layout before typing. This is
|
||||
// browser-rendered UI, not native window chrome or synthesized app results.
|
||||
await page.waitForFunction(() =>
|
||||
document.documentElement.dataset.launcherLayout === "compact" &&
|
||||
document.querySelector("#query")?.value === "",
|
||||
);
|
||||
const compactPath = resolve(out, "launcher-empty.png");
|
||||
const compactBounds = await page.locator("#app").boundingBox();
|
||||
assert(compactBounds && compactBounds.width <= 720 && compactBounds.height <= 112,
|
||||
"Ready empty launcher must remain a compact bar");
|
||||
await page.locator("#app").screenshot({ path: compactPath, animations: "disabled" });
|
||||
evidence.push({
|
||||
file: "launcher-empty.png",
|
||||
sha256: createHash("sha256").update(await readFile(compactPath)).digest("hex"),
|
||||
query: "",
|
||||
mode: "empty-ready",
|
||||
result_count: 0,
|
||||
selected_filename: null,
|
||||
theme: "light",
|
||||
capture_bounds: compactBounds,
|
||||
state_assertions: { semantic_ready: true, compact_empty_layout: true, native_pixels: false },
|
||||
});
|
||||
async function query(text, debug = false) {
|
||||
const awaited = page.waitForResponse(
|
||||
(response) => {
|
||||
@@ -151,6 +173,10 @@ try {
|
||||
() => !document.querySelector("#preview")?.hidden,
|
||||
);
|
||||
if (text === "cancel the supplier partnership") {
|
||||
assert(
|
||||
!body.results.some((result) => ["Mountain Lake.jpg", "Night Pier.jpg"].includes(result.filename)),
|
||||
"Contract results must not contain the unrelated fixture photos",
|
||||
);
|
||||
assert.equal(
|
||||
latestMeasured.first_filename,
|
||||
"Supplier Agreement.pdf",
|
||||
@@ -225,9 +251,12 @@ try {
|
||||
literal_query_marks: true,
|
||||
actual_page_explanation: true,
|
||||
why_disclosure_collapsed_by_default: true,
|
||||
unrelated_photos_removed: true,
|
||||
};
|
||||
}
|
||||
if (text === "mountains reflected in calm water") {
|
||||
assert(body.results.every((result) => result.match_kind === "semantic_image"),
|
||||
"Visual search shortlist must not contain the unrelated fixture documents");
|
||||
assert.equal(
|
||||
latestMeasured.first_filename,
|
||||
"Mountain Lake.jpg",
|
||||
@@ -238,7 +267,7 @@ try {
|
||||
0,
|
||||
"Meaning-only photo result must not invent word-level alignment",
|
||||
);
|
||||
latestMeasured.ui_checks = { meaning_only_image_has_no_text_marks: true };
|
||||
latestMeasured.ui_checks = { meaning_only_image_has_no_text_marks: true, unrelated_documents_removed: true };
|
||||
}
|
||||
await page.evaluate(
|
||||
() =>
|
||||
@@ -249,8 +278,12 @@ try {
|
||||
}
|
||||
async function screenshot(name, queryText) {
|
||||
assert.deepEqual(failures, [], "Frontend page errors occurred");
|
||||
const pane = await page.locator(".results-pane").boundingBox();
|
||||
const preview = await page.locator("#preview").boundingBox();
|
||||
assert(pane && preview && preview.x >= pane.x + pane.width - 1,
|
||||
"Wide result and preview panes must remain side by side");
|
||||
const path = resolve(out, `${name}.png`);
|
||||
await page.screenshot({ path, animations: "disabled", fullPage: false });
|
||||
await page.locator("#app").screenshot({ path, animations: "disabled" });
|
||||
const selected = await page.locator("#preview-title").innerText();
|
||||
assert.equal(
|
||||
selected,
|
||||
@@ -272,7 +305,9 @@ try {
|
||||
matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light",
|
||||
),
|
||||
summary: await page.locator("#result-summary").innerText(),
|
||||
capture_bounds: await page.locator("#app").boundingBox(),
|
||||
state_assertions: {
|
||||
wide_split_preview: true,
|
||||
semantic_ready: true,
|
||||
hybrid_final: true,
|
||||
image_visible: name === "media-search",
|
||||
@@ -366,6 +401,10 @@ try {
|
||||
{
|
||||
kind: "browser-rendered-production-ui-real-cpu-engine",
|
||||
source,
|
||||
frontend_build: {
|
||||
version: JSON.parse(await readFile(resolve(repository, "apps/desktop/package.json"), "utf8")).version,
|
||||
index_sha256: createHash("sha256").update(await readFile(resolve(repository, "apps/desktop/dist/index.html"))).digest("hex"),
|
||||
},
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
runner: { playwright: "1.64.0", chromium: browser.version() },
|
||||
backend: {
|
||||
|
||||
@@ -203,7 +203,8 @@ def package(root, executable, output, cli=None):
|
||||
"Windows needs WebView2. The Microsoft C++ runtime ships with these helpers; Visual Studio is not required.\n"
|
||||
"First launch downloads approximately 865 MB of verified Google model files.\n"
|
||||
"Local spoken-passage recognition adds approximately 149 MB of verified speech models.\n"
|
||||
"Choose Add folder to index your own files; ordinary search stays local.\n"
|
||||
"Use Ctrl+Shift+Space for the floating search bar. Local file discovery runs automatically.\n"
|
||||
"Change automatic indexing or add a folder in Settings; ordinary search stays local.\n"
|
||||
"This is an unsigned development build, not a certified production release.\n")
|
||||
if cli is not None:
|
||||
archive.writestr("AGENT-SETUP.txt",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||