For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Rover — a 100% offline AI-powered Finder alternative for macOS — cross-platform desktop app, demos in airplane mode for GDG AI Hack Milano 2026 (MSI "Cut the Cord" track).
Architecture (locked): Tauri 2 + Rust main process spawns a Python FastAPI sidecar (uvicorn on 127.0.0.1:8765) and serves a Vite/React/TypeScript webview. Native OS interactions (folder picker, reveal in Finder, file move with confirmation, open) live in Rust commands invoked from the renderer via @tauri-apps/api; ML/parsing (parse → chunk → embed → FAISS → search → summarize) lives in Python; renderer is dumb UI. Talks to Ollama at localhost:11434 for qwen3:4b (gen) and nomic-embed-text (embed).
Why Tauri over Electron: Bundle ~20 MB vs ~150 MB. Native menus and dialogs without a Chromium translation layer. Strongest signal on the "ottimizzazione tecnica" rubric pillar (30%). Cost: ~10 min Rust toolchain bootstrap + steeper IPC plumbing. Accepted.
Tech Stack:
- Renderer: React 18 · Vite 6 · TypeScript 5 · Tailwind 3
- Shell: Tauri 2 · Rust stable ·
tauri-plugin-dialog·tauri-plugin-shell·tauri-plugin-fs - Sidecar: Python 3.12 · FastAPI · uvicorn · faiss-cpu · pypdf · python-docx · httpx · tiktoken
- Models:
qwen3:4b(Q4_K_M) +nomic-embed-textvia local Ollama
Hardware budget on M4 Pro / 24 GB: qwen3:4b ≈ 2.6 GB · nomic-embed-text ≈ 274 MB · FAISS ≈ 30 MB · sidecar+renderer+tauri ≈ 200 MB. Plenty of headroom.
Process boundaries:
- Python sidecar = read-only information layer. Parses files, builds index, returns search/summary. Never mutates user folders.
- Rust main = native OS write/dialog layer. All
dialog::pick_folder,shell::open,Command("open", "-R"),fs::write,fs::rename. Every mutation requires a native confirm dialog. - Renderer = stateless React UI. Calls Python via
fetch(search/index/summarize), calls Rust viainvoke()(native ops).
Time budget: ~22 wall hours of work in a 20h window — priority cuts in Phase 8 are real and pre-planned.
gdgaihack-2026/
├── PLAN.md
├── README.md
├── .gitignore
├── package.json ← vite, react, tailwind, @tauri-apps/api, @tauri-apps/cli
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.ts
├── postcss.config.js
├── index.html
├── src/ ← React renderer
│ ├── main.tsx
│ ├── App.tsx
│ ├── index.css
│ ├── api.ts ← typed fetch wrappers (Python sidecar)
│ ├── tauri.ts ← typed invoke wrappers (Rust commands)
│ ├── types.ts
│ └── components/
│ ├── SearchBar.tsx
│ ├── FolderPicker.tsx
│ ├── FileList.tsx
│ ├── FileRow.tsx
│ ├── AIPanel.tsx
│ └── StatusBar.tsx
├── src-tauri/ ← Rust shell
│ ├── Cargo.toml
│ ├── build.rs
│ ├── tauri.conf.json
│ ├── capabilities/
│ │ └── default.json
│ ├── icons/ ← generated by `tauri icon`
│ └── src/
│ ├── main.rs
│ ├── lib.rs ← Tauri builder, plugins, command registration
│ ├── sidecar.rs ← uvicorn spawn/kill, port discovery, health probe
│ └── commands.rs ← #[tauri::command] handlers
├── scripts/
│ ├── setup.sh ← rustup + ollama pull + python venv + npm install
│ ├── airplane-check.sh
│ └── demo-reset.sh
├── backend/ ← Python sidecar (unchanged from prior plans)
│ ├── pyproject.toml
│ ├── requirements.txt
│ ├── main.py ← FastAPI: /health /index /search /summarize
│ ├── ollama_client.py
│ ├── parsing.py
│ ├── chunking.py
│ ├── indexer.py
│ ├── retriever.py
│ ├── store.py
│ ├── models.py
│ ├── config.py
│ └── tests/
│ ├── conftest.py
│ ├── test_parsing.py
│ ├── test_chunking.py
│ ├── test_ollama_client.py
│ ├── test_store.py
│ ├── test_indexer.py
│ ├── test_retriever.py
│ └── test_api.py
└── demo-files/
├── projects/
│ ├── alpha-budget-Q3.md
│ ├── alpha-notes.md
│ ├── alpha-contract-draft.docx
│ └── beta-roadmap.md
├── meetings/
│ ├── standup-2026-05-08.md
│ └── retro-2026-04-30.md
└── random/
├── ricetta-tiramisu.txt
└── vacanze-2025.md
Boundaries: parsing knows nothing about embeddings. indexer orchestrates parse + chunk + embed. retriever is read-only. src-tauri/src/sidecar.rs owns Python lifecycle. src-tauri/src/commands.rs owns every native call. Renderer never touches fs directly.
// src/tauri.ts — typed wrappers around invoke()
import { invoke } from "@tauri-apps/api/core";
export const tauri = {
pickFolder: () => invoke<string | null>("pick_folder"),
revealInFinder: (path: string) => invoke<void>("reveal_in_finder", { path }),
openFile: (path: string) => invoke<void>("open_file", { path }),
createNote: (folder: string, title: string, body: string) =>
invoke<{ path: string }>("create_note", { folder, title, body }),
confirmMove: (src: string, dst: string) =>
invoke<boolean>("confirm_move", { src, dst }),
moveFile: (src: string, dst: string) =>
invoke<{ newPath: string }>("move_file", { src, dst }),
backendUrl: () => invoke<string>("backend_url"),
};Sidecar status is event-based (tauri::Manager::emit): renderer listens for "sidecar-status" events with payloads "starting" | "ready" | "error".
- Backend (Python): pytest with a
FakeEmbedder(no Ollama in CI). One live test gated byOLLAMA_LIVE=1. - Rust:
cargo testforcommands.rspure logic (note path generation, confirm dialog response mapping). UI integration manual. - Renderer: Vitest for
api.ts/tauri.tsshape; UI validated by manual demo loop. - Demo gate (must pass before "done"):
airplane-check.shreturns 0 with Wi-Fi off; full flow (pick folder → index → search → summarize → create note → reveal in Finder) completes < 10s on demo corpus.
- Step 1: Install rustup non-interactively
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
. "$HOME/.cargo/env"
rustc --version && cargo --versionExpected: rustc 1.83+ and cargo 1.83+.
- Step 2: Add the Apple Silicon target (already default on M4 Pro, but explicit)
rustup target add aarch64-apple-darwin- Step 3: Verify Tauri prereqs
xcode-select --install 2>&1 | head -1 # noop if installedExpected: "command line tools already installed" or quiet exit.
Files: Create package.json, tsconfig.json, vite.config.ts, tailwind.config.ts, postcss.config.js, index.html, src-tauri/Cargo.toml, src-tauri/tauri.conf.json, src-tauri/build.rs, src-tauri/capabilities/default.json, src-tauri/src/main.rs, src-tauri/src/lib.rs, scripts.
- Step 1: Write
package.json
{
"name": "rover",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "concurrently -k -n tauri,sidecar -c magenta,green \"tauri dev\" \"npm:sidecar:dev\"",
"sidecar:dev": "cd backend && . .venv/bin/activate && uvicorn main:app --host 127.0.0.1 --port 8765 --reload",
"test": "vitest run",
"test:py": "cd backend && . .venv/bin/activate && pytest -v"
},
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.0.1",
"@tauri-apps/plugin-shell": "^2.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.1.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.7.2",
"vite": "^6.0.1",
"vitest": "^2.1.6"
}
}- Step 2: Write
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: "127.0.0.1",
hmr: { protocol: "ws", host: "127.0.0.1", port: 1421 },
},
envPrefix: ["VITE_", "TAURI_"],
build: { target: "es2022", outDir: "dist", emptyOutDir: true },
});- Step 3: Write
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"esModuleInterop": true,
"isolatedModules": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"types": ["vite/client", "node"]
},
"include": ["src", "vite.config.ts"]
}- Step 4: Write
tailwind.config.ts+postcss.config.js
// tailwind.config.ts
import type { Config } from "tailwindcss";
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
bg: "#0a0a0a",
surface: "#111113",
border: "#1f1f23",
muted: "#6b6b73",
text: "#e8e8ea",
accent: "#22d3ee",
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
},
},
},
plugins: [],
} satisfies Config;// postcss.config.js
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };- Step 5: Write
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rover</title>
</head>
<body class="h-full">
<div id="root" class="h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>- Step 6: Write
src-tauri/Cargo.toml
[package]
name = "rover"
version = "0.1.0"
description = "Offline AI Finder alternative"
edition = "2021"
[lib]
name = "rover_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "process", "time"] }
thiserror = "2"
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
strip = true- Step 7: Write
src-tauri/tauri.conf.json
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Rover",
"version": "0.1.0",
"identifier": "app.rover.demo",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Rover",
"width": 1280,
"height": 820,
"minWidth": 980,
"minHeight": 620,
"decorations": true,
"titleBarStyle": "Overlay",
"backgroundColor": "#0a0a0a"
}
],
"security": { "csp": null }
},
"bundle": {
"active": true,
"targets": ["dmg", "app"],
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.icns"],
"resources": ["../backend/**/*"],
"macOS": { "minimumSystemVersion": "12.0" }
}
}- Step 8: Write
src-tauri/build.rs
fn main() {
tauri_build::build()
}- Step 9: Write
src-tauri/capabilities/default.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capabilities for Rover desktop window",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"dialog:allow-open",
"dialog:allow-message",
"shell:default",
"shell:allow-open",
"fs:default"
]
}- Step 10: Write
src-tauri/src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
rover_lib::run()
}- Step 11: Generate placeholder icons
# tauri's icon generator needs a 1024x1024 source png
mkdir -p src-tauri/icons
# Cheap placeholder: solid square. Real icon ships in Phase 7 polish.
python3 -c "from PIL import Image; Image.new('RGB',(1024,1024),(10,10,10)).save('src-tauri/icons/source.png')" 2>/dev/null \
|| sips -z 1024 1024 -s format png /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/AppIcon.icns --out src-tauri/icons/source.png 2>/dev/null \
|| true
npx tauri icon src-tauri/icons/source.png || echo "icon generation deferred to Phase 7"- Step 12: Write
backend/requirements.txt+backend/pyproject.toml(same as before)
fastapi==0.115.5
uvicorn[standard]==0.32.1
httpx==0.28.1
pydantic==2.10.3
faiss-cpu==1.9.0.post1
numpy==2.1.3
pypdf==5.1.0
python-docx==1.1.2
tiktoken==0.8.0
pytest==8.3.4
pytest-asyncio==0.24.0
# backend/pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]- Step 13: Write scripts
# scripts/setup.sh
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
if ! command -v rustc >/dev/null 2>&1; then
echo "==> Installing Rust toolchain"
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
. "$HOME/.cargo/env"
fi
echo "==> Pulling Ollama models"
ollama pull qwen3:4b
ollama pull nomic-embed-text
echo "==> Python venv + deps"
cd "$ROOT/backend"
python3 -m venv .venv
. .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
echo "==> Node deps"
cd "$ROOT"
npm install
echo "==> Done. Run: npm run tauri:dev"# scripts/airplane-check.sh
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HITS=$(grep -RInE 'https?://(?!localhost|127\.0\.0\.1|sh\.rustup\.rs|schema\.tauri\.app)' \
--include='*.py' --include='*.ts' --include='*.tsx' --include='*.rs' \
"$ROOT/backend" "$ROOT/src" "$ROOT/src-tauri/src" 2>/dev/null \
| grep -vE '(test_|//|#|\.example|README)' || true)
if [ -n "$HITS" ]; then
echo "FAIL — external URLs found:"; echo "$HITS"; exit 1
fi
echo "OK — no external network references in source."# scripts/demo-reset.sh
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
rm -rf "$ROOT/backend/data"
echo "Index wiped. Re-index demo-files/ via the UI."- Step 14: Make scripts executable + npm install + first cargo check + commit
chmod +x scripts/*.sh
npm install
(cd src-tauri && cargo check) # ~3-7 min cold; fetches and compiles deps
git add package.json tsconfig.json vite.config.ts tailwind.config.ts \
postcss.config.js index.html scripts/ \
src-tauri/Cargo.toml src-tauri/build.rs src-tauri/tauri.conf.json \
src-tauri/capabilities/ src-tauri/src/main.rs src-tauri/icons/ \
backend/requirements.txt backend/pyproject.toml
git commit -m "chore: bootstrap tauri + vite + python skeleton"
git push- Step 1: Pull models (if not already done by setup.sh)
ollama pull qwen3:4b
ollama pull nomic-embed-text- Step 2: Smoke test embeddings
curl -s http://localhost:11434/api/embeddings \
-d '{"model":"nomic-embed-text","prompt":"hello"}' \
| python3 -c "import sys,json; print('dim:', len(json.load(sys.stdin)['embedding']))"Expected: dim: 768
- Step 3: Smoke test generate
curl -s http://localhost:11434/api/generate \
-d '{"model":"qwen3:4b","prompt":"Reply in one word: ok","stream":false}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['response'][:80])"Expected: short reply containing "ok".
The Python backend is identical regardless of shell choice. Each task is TDD: write a failing test, run it red, implement, run it green, commit.
Files: Create backend/config.py, backend/models.py
- Step 1: Write
backend/config.py
from pathlib import Path
import os
BACKEND_DIR = Path(__file__).resolve().parent
DATA_DIR = BACKEND_DIR / "data"
INDEX_PATH = DATA_DIR / "index.faiss"
META_PATH = DATA_DIR / "metadata.json"
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434")
EMBED_MODEL = os.getenv("EMBED_MODEL", "nomic-embed-text")
GEN_MODEL = os.getenv("GEN_MODEL", "qwen3:4b")
EMBED_DIM = 768
CHUNK_TOKENS = 512
CHUNK_OVERLAP = 64
TOP_K = 8
SUPPORTED_EXT = {".pdf", ".md", ".markdown", ".txt", ".docx"}
DATA_DIR.mkdir(parents=True, exist_ok=True)- Step 2: Write
backend/models.py
from pydantic import BaseModel
class IndexRequest(BaseModel):
folder: str
class IndexResponse(BaseModel):
files_indexed: int
chunks: int
elapsed_ms: int
class SearchRequest(BaseModel):
query: str
top_k: int = 8
class SearchHit(BaseModel):
path: str
filename: str
chunk_text: str
chunk_index: int
score: float
class SearchResponse(BaseModel):
hits: list[SearchHit]
elapsed_ms: int
class SummarizeRequest(BaseModel):
path: str
class SummarizeResponse(BaseModel):
summary: str
elapsed_ms: int- Step 3: Commit
git add backend/config.py backend/models.py
git commit -m "feat(backend): config + pydantic schemas"Files: Create backend/parsing.py, backend/tests/__init__.py, backend/tests/conftest.py, backend/tests/test_parsing.py
-
Step 1: Write
backend/tests/__init__.py(empty) -
Step 2: Write
backend/tests/conftest.py
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))- Step 3: Write failing test
backend/tests/test_parsing.py
from pathlib import Path
from parsing import parse_file
def test_parse_txt(tmp_path: Path):
f = tmp_path / "x.txt"; f.write_text("plain body")
assert parse_file(f) == "plain body"
def test_parse_md(tmp_path: Path):
f = tmp_path / "x.md"; f.write_text("# Title\nbody")
out = parse_file(f)
assert "Title" in out and "body" in out
def test_parse_unknown_returns_empty(tmp_path: Path):
f = tmp_path / "x.xyz"; f.write_text("ignored")
assert parse_file(f) == ""- Step 4: Run — expect failure
cd backend && . .venv/bin/activate && pytest tests/test_parsing.py -v- Step 5: Implement
backend/parsing.py
from pathlib import Path
from pypdf import PdfReader
from docx import Document
def _read_txt(p: Path) -> str:
return p.read_text(encoding="utf-8", errors="ignore")
def _read_pdf(p: Path) -> str:
reader = PdfReader(str(p))
return "\n".join((page.extract_text() or "") for page in reader.pages)
def _read_docx(p: Path) -> str:
doc = Document(str(p))
return "\n".join(par.text for par in doc.paragraphs)
def parse_file(path: Path) -> str:
ext = path.suffix.lower()
if ext in {".txt", ".md", ".markdown"}: return _read_txt(path)
if ext == ".pdf": return _read_pdf(path)
if ext == ".docx": return _read_docx(path)
return ""-
Step 6: Run — expect 3 passed
-
Step 7: Commit
git add backend/parsing.py backend/tests/
git commit -m "feat(backend): file parsing (txt/md/pdf/docx)"Files: Create backend/chunking.py, backend/tests/test_chunking.py
- Step 1: Write failing test
# backend/tests/test_chunking.py
from chunking import chunk_text
def test_short_text_one_chunk():
chunks = chunk_text("hello world", max_tokens=512, overlap=64)
assert chunks == ["hello world"]
def test_empty_returns_empty():
assert chunk_text("", max_tokens=512, overlap=64) == []
def test_long_text_splits():
body = " ".join(f"word{i}" for i in range(2000))
chunks = chunk_text(body, max_tokens=100, overlap=20)
assert len(chunks) > 1-
Step 2: Run — expect failure
-
Step 3: Implement
backend/chunking.py
import tiktoken
_ENC = tiktoken.get_encoding("cl100k_base")
def chunk_text(text: str, max_tokens: int = 512, overlap: int = 64) -> list[str]:
text = text.strip()
if not text: return []
tokens = _ENC.encode(text)
if len(tokens) <= max_tokens: return [text]
step = max_tokens - overlap
chunks: list[str] = []
for start in range(0, len(tokens), step):
chunks.append(_ENC.decode(tokens[start : start + max_tokens]))
if start + max_tokens >= len(tokens): break
return chunks-
Step 4: Run — expect 3 passed
-
Step 5: Commit
git add backend/chunking.py backend/tests/test_chunking.py
git commit -m "feat(backend): token-aware chunking with overlap"Files: Create backend/ollama_client.py, backend/tests/test_ollama_client.py
- Step 1: Write failing test
# backend/tests/test_ollama_client.py
import pytest, httpx
from ollama_client import OllamaClient
@pytest.mark.asyncio
async def test_embed(monkeypatch):
async def fake_post(self, url, json):
return httpx.Response(200, json={"embedding": [0.1] * 768},
request=httpx.Request("POST", url))
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
v = await OllamaClient(host="http://x").embed("hello")
assert len(v) == 768
@pytest.mark.asyncio
async def test_generate(monkeypatch):
async def fake_post(self, url, json):
return httpx.Response(200, json={"response": "ok"},
request=httpx.Request("POST", url))
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
out = await OllamaClient(host="http://x").generate("hi")
assert out == "ok"-
Step 2: Run — expect failure
-
Step 3: Implement
backend/ollama_client.py
import httpx
from config import OLLAMA_HOST, EMBED_MODEL, GEN_MODEL
class OllamaClient:
def __init__(self, host: str = OLLAMA_HOST,
embed_model: str = EMBED_MODEL, gen_model: str = GEN_MODEL):
self.host = host.rstrip("/")
self.embed_model = embed_model
self.gen_model = gen_model
self._client = httpx.AsyncClient(timeout=120.0)
async def embed(self, text: str) -> list[float]:
r = await self._client.post(
f"{self.host}/api/embeddings",
json={"model": self.embed_model, "prompt": text})
r.raise_for_status()
return r.json()["embedding"]
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
return [await self.embed(t) for t in texts]
async def generate(self, prompt: str, system: str | None = None) -> str:
payload = {"model": self.gen_model, "prompt": prompt, "stream": False}
if system: payload["system"] = system
r = await self._client.post(f"{self.host}/api/generate", json=payload)
r.raise_for_status()
return r.json()["response"]
async def aclose(self) -> None:
await self._client.aclose()-
Step 4: Run — expect 2 passed
-
Step 5: Commit
git add backend/ollama_client.py backend/tests/test_ollama_client.py
git commit -m "feat(backend): async ollama client"Files: Create backend/store.py, backend/tests/test_store.py
- Step 1: Write failing test
# backend/tests/test_store.py
import numpy as np
from pathlib import Path
from store import VectorStore
def test_add_and_search(tmp_path: Path):
s = VectorStore(dim=4, index_path=tmp_path / "i.faiss", meta_path=tmp_path / "m.json")
s.add(np.array([[1, 0, 0, 0], [0, 1, 0, 0]], dtype=np.float32),
[{"path": "a", "filename": "a", "chunk": "a", "chunk_index": 0},
{"path": "b", "filename": "b", "chunk": "b", "chunk_index": 0}])
s.save()
s2 = VectorStore(dim=4, index_path=tmp_path / "i.faiss", meta_path=tmp_path / "m.json")
s2.load()
hits = s2.search(np.array([1, 0, 0, 0], dtype=np.float32), k=1)
assert hits[0][0]["path"] == "a"
def test_empty(tmp_path: Path):
s = VectorStore(dim=4, index_path=tmp_path / "i.faiss", meta_path=tmp_path / "m.json")
assert s.search(np.zeros(4, dtype=np.float32), k=5) == []-
Step 2: Run — expect failure
-
Step 3: Implement
backend/store.py
import json
from pathlib import Path
import faiss
import numpy as np
class VectorStore:
def __init__(self, dim: int, index_path: Path, meta_path: Path):
self.dim = dim
self.index_path = index_path
self.meta_path = meta_path
self.index = faiss.IndexFlatIP(dim)
self.meta: list[dict] = []
def add(self, vectors: np.ndarray, metas: list[dict]) -> None:
assert vectors.shape[1] == self.dim
assert vectors.shape[0] == len(metas)
v = vectors.astype(np.float32).copy()
faiss.normalize_L2(v)
self.index.add(v)
self.meta.extend(metas)
def search(self, query_vec: np.ndarray, k: int = 8) -> list[tuple[dict, float]]:
if self.index.ntotal == 0: return []
q = query_vec.reshape(1, -1).astype(np.float32).copy()
faiss.normalize_L2(q)
scores, ids = self.index.search(q, min(k, self.index.ntotal))
out: list[tuple[dict, float]] = []
for i, s in zip(ids[0], scores[0]):
if i == -1: continue
out.append((self.meta[i], float(s)))
return out
def save(self) -> None:
self.index_path.parent.mkdir(parents=True, exist_ok=True)
faiss.write_index(self.index, str(self.index_path))
self.meta_path.write_text(json.dumps(self.meta, ensure_ascii=False))
def load(self) -> None:
if self.index_path.exists():
self.index = faiss.read_index(str(self.index_path))
if self.meta_path.exists():
self.meta = json.loads(self.meta_path.read_text())
def reset(self) -> None:
self.index = faiss.IndexFlatIP(self.dim)
self.meta = []-
Step 4: Run — expect 2 passed
-
Step 5: Commit
git add backend/store.py backend/tests/test_store.py
git commit -m "feat(backend): faiss vector store"Files: Create backend/indexer.py, backend/tests/test_indexer.py
- Step 1: Write failing test
# backend/tests/test_indexer.py
import pytest
from pathlib import Path
from indexer import Indexer
from store import VectorStore
class FakeEmbedder:
async def embed(self, text: str):
h = abs(hash(text[:20])) % 1000
v = [0.0] * 8; v[h % 8] = 1.0; return v
async def embed_batch(self, texts):
return [await self.embed(t) for t in texts]
@pytest.mark.asyncio
async def test_index_folder(tmp_path: Path):
(tmp_path / "a.txt").write_text("alpha project budget Q3")
(tmp_path / "b.md").write_text("tiramisu recipe eggs sugar")
store = VectorStore(dim=8, index_path=tmp_path / "i.faiss", meta_path=tmp_path / "m.json")
idx = Indexer(embedder=FakeEmbedder(), store=store, dim=8)
stats = await idx.index_folder(tmp_path)
assert stats["files_indexed"] == 2
assert stats["chunks"] >= 2
assert store.index.ntotal >= 2-
Step 2: Run — expect failure
-
Step 3: Implement
backend/indexer.py
import time
from pathlib import Path
import numpy as np
from chunking import chunk_text
from parsing import parse_file
from config import SUPPORTED_EXT, CHUNK_TOKENS, CHUNK_OVERLAP
class Indexer:
def __init__(self, embedder, store, dim: int):
self.embedder = embedder
self.store = store
self.dim = dim
async def index_folder(self, root: Path) -> dict:
t0 = time.time()
self.store.reset()
files = [p for p in root.rglob("*")
if p.is_file() and p.suffix.lower() in SUPPORTED_EXT]
all_vecs: list[list[float]] = []
all_meta: list[dict] = []
for f in files:
text = parse_file(f)
if not text.strip(): continue
chunks = chunk_text(text, max_tokens=CHUNK_TOKENS, overlap=CHUNK_OVERLAP)
if not chunks: continue
vecs = await self.embedder.embed_batch(chunks)
for i, (c, v) in enumerate(zip(chunks, vecs)):
all_vecs.append(v)
all_meta.append({
"path": str(f.resolve()),
"filename": f.name,
"chunk": c,
"chunk_index": i,
})
if all_vecs:
self.store.add(np.array(all_vecs, dtype=np.float32), all_meta)
self.store.save()
return {
"files_indexed": len(files),
"chunks": len(all_vecs),
"elapsed_ms": int((time.time() - t0) * 1000),
}-
Step 4: Run — expect pass
-
Step 5: Commit
git add backend/indexer.py backend/tests/test_indexer.py
git commit -m "feat(backend): indexer with injectable embedder"Files: Create backend/retriever.py, backend/tests/test_retriever.py
- Step 1: Write failing test
# backend/tests/test_retriever.py
import pytest
from pathlib import Path
import numpy as np
from retriever import Retriever
from store import VectorStore
class FixedEmbedder:
async def embed(self, text: str):
return [1.0, 0.0, 0.0, 0.0]
@pytest.mark.asyncio
async def test_retrieve(tmp_path: Path):
store = VectorStore(dim=4, index_path=tmp_path / "i.faiss", meta_path=tmp_path / "m.json")
store.add(np.array([[1, 0, 0, 0], [0, 1, 0, 0]], dtype=np.float32),
[{"path": "a", "filename": "a", "chunk": "alpha", "chunk_index": 0},
{"path": "b", "filename": "b", "chunk": "beta", "chunk_index": 0}])
hits = await Retriever(FixedEmbedder(), store).search("anything", k=2)
assert hits[0]["path"] == "a"
assert hits[0]["score"] > hits[1]["score"]-
Step 2: Run — expect failure
-
Step 3: Implement
backend/retriever.py
import numpy as np
class Retriever:
def __init__(self, embedder, store):
self.embedder = embedder
self.store = store
async def search(self, query: str, k: int = 8) -> list[dict]:
if not query.strip(): return []
q = await self.embedder.embed(query)
return [
{
"path": m["path"],
"filename": m["filename"],
"chunk_text": m["chunk"],
"chunk_index": m["chunk_index"],
"score": score,
}
for m, score in self.store.search(np.array(q, dtype=np.float32), k=k)
]-
Step 4: Run — expect pass
-
Step 5: Commit
git add backend/retriever.py backend/tests/test_retriever.py
git commit -m "feat(backend): retriever"Files: Create backend/main.py, backend/tests/test_api.py
- Step 1: Write failing test
# backend/tests/test_api.py
from pathlib import Path
from fastapi.testclient import TestClient
from main import app, get_app_state, AppState
from store import VectorStore
class FakeEmbedder:
async def embed(self, text: str):
h = abs(hash(text[:20])) % 1000
v = [0.0] * 8; v[h % 8] = 1.0; return v
async def embed_batch(self, texts):
return [await self.embed(t) for t in texts]
class FakeGen:
async def generate(self, prompt: str, system=None):
return "FAKE SUMMARY"
def make_state(tmp_path: Path) -> AppState:
return AppState(
embedder=FakeEmbedder(), generator=FakeGen(),
store=VectorStore(dim=8, index_path=tmp_path / "i.faiss", meta_path=tmp_path / "m.json"),
dim=8,
)
def test_health():
assert TestClient(app).get("/health").json()["ok"] is True
def test_index_then_search(tmp_path: Path):
state = make_state(tmp_path)
app.dependency_overrides[get_app_state] = lambda: state
folder = tmp_path / "src"; folder.mkdir()
(folder / "alpha-budget.md").write_text("alpha project budget Q3 figures")
(folder / "recipe.txt").write_text("tiramisu eggs sugar")
c = TestClient(app)
assert c.post("/index", json={"folder": str(folder)}).json()["files_indexed"] == 2
hits = c.post("/search", json={"query": "alpha project budget Q3 figures", "top_k": 2}).json()["hits"]
assert hits and "alpha-budget" in hits[0]["filename"]
app.dependency_overrides.clear()-
Step 2: Run — expect failure
-
Step 3: Implement
backend/main.py
import time
from dataclasses import dataclass
from pathlib import Path
from fastapi import FastAPI, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from config import INDEX_PATH, META_PATH, EMBED_DIM, TOP_K
from models import (IndexRequest, IndexResponse, SearchRequest, SearchResponse,
SummarizeRequest, SummarizeResponse)
from ollama_client import OllamaClient
from store import VectorStore
from indexer import Indexer
from retriever import Retriever
from parsing import parse_file
@dataclass
class AppState:
embedder: object
generator: object
store: VectorStore
dim: int
_state: AppState | None = None
def get_app_state() -> AppState:
global _state
if _state is None:
client = OllamaClient()
store = VectorStore(dim=EMBED_DIM, index_path=INDEX_PATH, meta_path=META_PATH)
store.load()
_state = AppState(embedder=client, generator=client, store=store, dim=EMBED_DIM)
return _state
@asynccontextmanager
async def lifespan(app: FastAPI):
s = get_app_state()
try:
await s.embedder.embed("warmup")
await s.generator.generate("ok", system="reply: ok")
except Exception:
pass
yield
app = FastAPI(title="Rover", lifespan=lifespan)
app.add_middleware(CORSMiddleware,
allow_origins=["http://localhost:1420", "http://127.0.0.1:1420",
"tauri://localhost", "https://tauri.localhost"],
allow_methods=["*"], allow_headers=["*"])
@app.get("/health")
def health(): return {"ok": True}
@app.post("/index", response_model=IndexResponse)
async def index_folder(req: IndexRequest, state: AppState = Depends(get_app_state)):
folder = Path(req.folder).expanduser().resolve()
if not folder.exists() or not folder.is_dir():
raise HTTPException(400, f"folder not found: {folder}")
stats = await Indexer(state.embedder, state.store, state.dim).index_folder(folder)
return IndexResponse(**stats)
@app.post("/search", response_model=SearchResponse)
async def search(req: SearchRequest, state: AppState = Depends(get_app_state)):
t0 = time.time()
hits = await Retriever(state.embedder, state.store).search(req.query, k=req.top_k or TOP_K)
return SearchResponse(hits=hits, elapsed_ms=int((time.time() - t0) * 1000))
@app.post("/summarize", response_model=SummarizeResponse)
async def summarize(req: SummarizeRequest, state: AppState = Depends(get_app_state)):
t0 = time.time()
p = Path(req.path).expanduser().resolve()
if not p.exists(): raise HTTPException(404, "file not found")
text = parse_file(p)[:8000]
if not text.strip(): raise HTTPException(422, "could not extract text")
prompt = (f"Summarize the following document in 5-8 bullet points, "
f"in the same language as the source. Output markdown.\n\n---\n{text}\n---")
summary = await state.generator.generate(prompt,
system="You are a precise document summarizer. Be concise.")
return SummarizeResponse(summary=summary, elapsed_ms=int((time.time() - t0) * 1000))- Step 4: Run all backend tests — expect green
cd backend && . .venv/bin/activate && pytest -v- Step 5: Live smoke (Ollama up)
uvicorn main:app --host 127.0.0.1 --port 8765 &
sleep 3
curl -s http://127.0.0.1:8765/health
curl -s -X POST http://127.0.0.1:8765/index \
-H 'Content-Type: application/json' \
-d "{\"folder\":\"$(pwd)/../demo-files\"}"
curl -s -X POST http://127.0.0.1:8765/search \
-H 'Content-Type: application/json' \
-d '{"query":"presentazione budget progetto alpha","top_k":3}' | python3 -m json.tool
kill %1Expected: top hit relates to alpha budget.
- Step 6: Commit
git add backend/main.py backend/tests/test_api.py
git commit -m "feat(backend): fastapi surface (health/index/search/summarize)"
git pushFiles: Create src-tauri/src/sidecar.rs
- Step 1: Write
src-tauri/src/sidecar.rs
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Mutex;
use std::time::Duration;
use tauri::{AppHandle, Manager};
use tokio::process::{Child, Command};
use tokio::time::sleep;
pub const SIDECAR_PORT: u16 = 8765;
pub struct SidecarHandle(pub Mutex<Option<Child>>);
fn backend_dir(app: &AppHandle) -> PathBuf {
if cfg!(debug_assertions) {
// dev: backend/ next to project root
let mut p = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
if p.ends_with("src-tauri") { p.pop(); }
p.push("backend");
p
} else {
// prod: bundled under Resources/
let mut p = app.path().resource_dir().expect("resource_dir");
p.push("backend");
p
}
}
fn python_bin(backend: &PathBuf) -> PathBuf {
let venv = backend.join(".venv/bin/python3");
if venv.exists() { venv } else { PathBuf::from("python3") }
}
pub async fn start(app: &AppHandle) -> Result<String, String> {
let backend = backend_dir(app);
let py = python_bin(&backend);
let child = Command::new(py)
.arg("-m").arg("uvicorn")
.arg("main:app")
.arg("--host").arg("127.0.0.1")
.arg("--port").arg(SIDECAR_PORT.to_string())
.current_dir(&backend)
.env("PYTHONUNBUFFERED", "1")
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| format!("spawn failed: {e}"))?;
let state = app.state::<SidecarHandle>();
*state.0.lock().unwrap() = Some(child);
let url = format!("http://127.0.0.1:{SIDECAR_PORT}");
for _ in 0..60 {
if let Ok(r) = reqwest::get(format!("{url}/health")).await {
if r.status().is_success() {
return Ok(url);
}
}
sleep(Duration::from_millis(500)).await;
}
Err("sidecar failed to become healthy in 30s".into())
}
pub fn stop(app: &AppHandle) {
if let Some(state) = app.try_state::<SidecarHandle>() {
if let Some(mut child) = state.0.lock().unwrap().take() {
let _ = child.start_kill();
}
}
}- Step 2: Commit
git add src-tauri/src/sidecar.rs
git commit -m "feat(tauri): python sidecar lifecycle with health probe"Files: Create src-tauri/src/commands.rs
- Step 1: Write
src-tauri/src/commands.rs
use chrono::Local;
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tauri::{AppHandle, Manager};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use tauri_plugin_shell::ShellExt;
use crate::sidecar::SIDECAR_PORT;
#[derive(Serialize)]
pub struct CreatedNote { pub path: String }
#[derive(Serialize)]
pub struct MoveResult {
#[serde(rename = "newPath")]
pub new_path: String,
}
#[tauri::command]
pub async fn backend_url() -> String {
format!("http://127.0.0.1:{SIDECAR_PORT}")
}
#[tauri::command]
pub async fn pick_folder(app: AppHandle) -> Option<String> {
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog().file()
.set_title("Select a folder to index")
.pick_folder(move |p| { let _ = tx.send(p); });
rx.await.ok().flatten().map(|p| p.to_string())
}
#[tauri::command]
pub fn reveal_in_finder(path: String) -> Result<(), String> {
let p = Path::new(&path);
if !p.exists() { return Err(format!("path not found: {path}")); }
#[cfg(target_os = "macos")]
{ Command::new("open").args(["-R", &path]).status().map_err(|e| e.to_string())?; }
#[cfg(target_os = "linux")]
{ Command::new("xdg-open").arg(p.parent().unwrap_or(p)).status().map_err(|e| e.to_string())?; }
#[cfg(target_os = "windows")]
{ Command::new("explorer").args(["/select,", &path]).status().map_err(|e| e.to_string())?; }
Ok(())
}
#[tauri::command]
pub async fn open_file(app: AppHandle, path: String) -> Result<(), String> {
app.shell().open(path, None).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn create_note(folder: String, title: String, body: String) -> Result<CreatedNote, String> {
let safe: String = title.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect::<String>()
.trim_matches('_').to_string();
let safe = if safe.is_empty() { "note".into() } else { safe };
let stamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
let mut out: PathBuf = PathBuf::from(&folder);
out.push(format!("{safe}-{stamp}.md"));
fs::write(&out, body).map_err(|e| format!("write failed: {e}"))?;
Ok(CreatedNote { path: out.to_string_lossy().into_owned() })
}
#[tauri::command]
pub async fn confirm_move(app: AppHandle, src: String, dst: String) -> bool {
let (tx, rx) = tokio::sync::oneshot::channel();
let src_name = Path::new(&src).file_name()
.map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let detail = format!("From:\n{}\n\nTo:\n{}",
Path::new(&src).parent().map(|p| p.to_string_lossy().into_owned()).unwrap_or_default(),
dst);
app.dialog()
.message(detail)
.title(format!("Move {src_name}?"))
.kind(MessageDialogKind::Warning)
.buttons(MessageDialogButtons::OkCancelCustom("Move".into(), "Cancel".into()))
.show(move |answered| { let _ = tx.send(answered); });
rx.await.unwrap_or(false)
}
#[tauri::command]
pub fn move_file(src: String, dst: String) -> Result<MoveResult, String> {
let src_p = Path::new(&src);
let dst_p = Path::new(&dst);
if !src_p.exists() { return Err(format!("source missing: {src}")); }
if !dst_p.is_dir() { return Err(format!("destination not a folder: {dst}")); }
let target = dst_p.join(src_p.file_name().ok_or("invalid source name")?);
if target.exists() { return Err(format!("target already exists: {}", target.display())); }
fs::rename(src_p, &target).map_err(|e| format!("rename failed: {e}"))?;
Ok(MoveResult { new_path: target.to_string_lossy().into_owned() })
}- Step 2: Commit
git add src-tauri/src/commands.rs
git commit -m "feat(tauri): native commands (folder/reveal/open/note/move)"Files: Create src-tauri/src/lib.rs
- Step 1: Write
src-tauri/src/lib.rs
mod commands;
mod sidecar;
use std::sync::Mutex;
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
.manage(sidecar::SidecarHandle(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![
commands::backend_url,
commands::pick_folder,
commands::reveal_in_finder,
commands::open_file,
commands::create_note,
commands::confirm_move,
commands::move_file,
])
.setup(|app| {
let handle = app.handle().clone();
let _ = handle.emit("sidecar-status", "starting");
tauri::async_runtime::spawn(async move {
match sidecar::start(&handle).await {
Ok(url) => {
println!("sidecar ready at {url}");
let _ = handle.emit("sidecar-status", "ready");
}
Err(e) => {
eprintln!("sidecar error: {e}");
let _ = handle.emit("sidecar-status", "error");
}
}
});
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
sidecar::stop(window.app_handle());
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}- Step 2: Build & launch
# In one terminal: keep sidecar warm during dev
cd backend && . .venv/bin/activate && uvicorn main:app --host 127.0.0.1 --port 8765 --reload &
# Rebuild Rust + start Tauri dev (incremental cargo build ~30s, first run ~5min)
cd .. && npm run tauri devExpected: native window opens with default Vite content; Rust logs "sidecar ready at http://127.0.0.1:8765".
- Step 3: Commit
git add src-tauri/src/lib.rs
git commit -m "feat(tauri): wire builder, plugins, sidecar setup"
git pushFiles: Create src/main.tsx, src/index.css, src/types.ts, src/api.ts, src/tauri.ts
- Step 1: Write
src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #root { height: 100%; }
body {
@apply bg-bg text-text font-sans antialiased;
-webkit-font-smoothing: antialiased;
}- Step 2: Write
src/types.ts
export type SearchHit = {
path: string;
filename: string;
chunk_text: string;
chunk_index: number;
score: number;
};
export type SearchResponse = { hits: SearchHit[]; elapsed_ms: number };
export type IndexResponse = { files_indexed: number; chunks: number; elapsed_ms: number };
export type SummarizeResponse = { summary: string; elapsed_ms: number };- Step 3: Write
src/tauri.ts
import { invoke } from "@tauri-apps/api/core";
export const tauri = {
pickFolder: () => invoke<string | null>("pick_folder"),
revealInFinder: (path: string) => invoke<void>("reveal_in_finder", { path }),
openFile: (path: string) => invoke<void>("open_file", { path }),
createNote: (folder: string, title: string, body: string) =>
invoke<{ path: string }>("create_note", { folder, title, body }),
confirmMove: (src: string, dst: string) =>
invoke<boolean>("confirm_move", { src, dst }),
moveFile: (src: string, dst: string) =>
invoke<{ newPath: string }>("move_file", { src, dst }),
backendUrl: () => invoke<string>("backend_url"),
};- Step 4: Write
src/api.ts
import type { IndexResponse, SearchResponse, SummarizeResponse } from "./types";
import { tauri } from "./tauri";
let baseCache: string | null = null;
async function base(): Promise<string> {
if (baseCache) return baseCache;
baseCache = await tauri.backendUrl();
return baseCache;
}
async function post<T>(path: string, body: unknown): Promise<T> {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), 60_000);
try {
const r = await fetch(`${await base()}${path}`, {
method: "POST", signal: ctl.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`${path} ${r.status}: ${await r.text()}`);
return r.json() as Promise<T>;
} finally { clearTimeout(t); }
}
export const api = {
health: async () => fetch(`${await base()}/health`).then((r) => r.json()),
index: (folder: string) => post<IndexResponse>("/index", { folder }),
search: (query: string, top_k = 12) => post<SearchResponse>("/search", { query, top_k }),
summarize: (path: string) => post<SummarizeResponse>("/summarize", { path }),
};- Step 5: Write
src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode><App /></React.StrictMode>
);- Step 6: Commit
git add src/main.tsx src/index.css src/types.ts src/api.ts src/tauri.ts
git commit -m "feat(renderer): entry + types + api/tauri clients"Files: Create src/App.tsx, src/components/StatusBar.tsx
- Step 1: Write
src/components/StatusBar.tsx
import { useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
export default function StatusBar({ info }: { info: string }) {
const [status, setStatus] = useState<"starting" | "ready" | "error">("starting");
useEffect(() => {
const unsub = listen<string>("sidecar-status", (e) => {
setStatus(e.payload as "starting" | "ready" | "error");
});
return () => { unsub.then((u) => u()); };
}, []);
const dot = status === "ready" ? "bg-emerald-400" : status === "error" ? "bg-red-400" : "bg-amber-400";
return (
<footer className="col-span-3 border-t border-border px-4 text-xs text-muted flex items-center justify-between">
<span className="flex items-center gap-2">
<span className={`inline-block w-2 h-2 rounded-full ${dot}`} />
sidecar: {status} · airplane mode ✓
</span>
<span className="font-mono">{info}</span>
<span>qwen3:4b · nomic-embed-text · faiss · tauri</span>
</footer>
);
}- Step 2: Write minimal
src/App.tsx(placeholders, components added next tasks)
import { useState } from "react";
import type { SearchHit } from "./types";
import StatusBar from "./components/StatusBar";
export default function App() {
const [info] = useState<string>("");
const [_hits] = useState<SearchHit[]>([]);
return (
<div className="grid grid-cols-[260px_1fr_400px] grid-rows-[40px_1fr_28px] h-full">
<header className="col-span-3 border-b border-border flex items-center px-4 gap-3 select-none"
data-tauri-drag-region>
<span className="font-mono text-accent text-sm pl-16">Rover</span>
<span className="text-muted text-xs">local · offline · private</span>
</header>
<aside className="border-r border-border p-3">[FolderPicker]</aside>
<main className="p-4 overflow-auto flex flex-col gap-4">[Search + List]</main>
<aside className="border-l border-border p-3">[AIPanel]</aside>
<StatusBar info={info} />
</div>
);
}-
Step 3: Manual smoke —
npm run tauri dev, see dark 3-column shell, footer dot starts amber, becomes green when sidecar ready. -
Step 4: Commit
git add src/App.tsx src/components/StatusBar.tsx
git commit -m "feat(renderer): app shell with sidecar-status listener"Files: Create src/components/FolderPicker.tsx, modify src/App.tsx
- Step 1: Write
src/components/FolderPicker.tsx
import { useState } from "react";
import { api } from "../api";
import { tauri } from "../tauri";
type Props = { onIndexed: (folder: string, files: number, chunks: number) => void };
export default function FolderPicker({ onIndexed }: Props) {
const [folder, setFolder] = useState<string>("");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState("");
async function pick() {
const f = await tauri.pickFolder();
if (f) setFolder(f);
}
async function index() {
if (!folder) return;
setBusy(true); setMsg("indexing…");
try {
const r = await api.index(folder);
setMsg(`${r.files_indexed} files · ${r.chunks} chunks · ${r.elapsed_ms}ms`);
onIndexed(folder, r.files_indexed, r.chunks);
} catch (e) {
setMsg(`error: ${(e as Error).message}`);
} finally { setBusy(false); }
}
return (
<div className="flex flex-col gap-2">
<label className="text-[10px] text-muted uppercase tracking-widest">Folder</label>
<button onClick={pick}
className="bg-surface border border-border hover:border-accent rounded px-2 py-1.5 text-xs font-mono text-left truncate">
{folder || "Choose folder…"}
</button>
<button onClick={index} disabled={busy || !folder}
className="bg-accent/10 hover:bg-accent/20 border border-accent/40 text-accent rounded px-3 py-1.5 text-sm disabled:opacity-40 disabled:cursor-not-allowed">
{busy ? "Indexing…" : "Index folder"}
</button>
{msg && <p className="text-[11px] text-muted leading-tight">{msg}</p>}
</div>
);
}-
Step 2: Wire into
App.tsx— addimport FolderPicker from "./components/FolderPicker";and replace[FolderPicker]placeholder with<FolderPicker onIndexed={() => {}} />. -
Step 3: Manual test — click "Choose folder…", native macOS dialog opens, select
demo-files/, click "Index folder", see counts. -
Step 4: Commit
git add src/components/FolderPicker.tsx src/App.tsx
git commit -m "feat(renderer): folder picker via tauri dialog"Files: Create src/components/SearchBar.tsx, src/components/FileList.tsx, src/components/FileRow.tsx, modify src/App.tsx
- Step 1: Write
src/components/SearchBar.tsx
import { useState } from "react";
type Props = { onSearch: (q: string) => void; busy: boolean };
export default function SearchBar({ onSearch, busy }: Props) {
const [q, setQ] = useState("");
return (
<form onSubmit={(e) => { e.preventDefault(); if (q.trim()) onSearch(q.trim()); }}>
<input
autoFocus type="text" value={q} onChange={(e) => setQ(e.target.value)}
placeholder="Find anything in your files… 'budget presentation project alpha'"
className="w-full bg-surface border border-border rounded-lg px-4 py-3 text-base
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/40
placeholder:text-muted disabled:opacity-50"
disabled={busy}
/>
</form>
);
}- Step 2: Write
src/components/FileRow.tsx
import type { SearchHit } from "../types";
type Props = { hit: SearchHit; selected: boolean; onSelect: () => void };
const ICON: Record<string, string> = {
pdf: "📕", md: "📝", markdown: "📝", txt: "📄", docx: "📘",
};
const ext = (n: string) => { const i = n.lastIndexOf("."); return i >= 0 ? n.slice(i + 1).toLowerCase() : ""; };
export default function FileRow({ hit, selected, onSelect }: Props) {
const pct = Math.round(Math.max(0, Math.min(1, hit.score)) * 100);
return (
<button onClick={onSelect}
className={`w-full text-left bg-surface border rounded-lg p-3 transition
${selected ? "border-accent" : "border-border hover:border-muted"}`}>
<div className="flex items-center gap-2">
<span className="text-lg">{ICON[ext(hit.filename)] ?? "📦"}</span>
<span className="font-medium text-sm truncate">{hit.filename}</span>
<span className="ml-auto text-[11px] text-muted font-mono">{pct}%</span>
</div>
<div className="mt-1 text-[11px] text-muted font-mono truncate">{hit.path}</div>
<div className="mt-2 h-1 bg-border rounded">
<div className="h-1 bg-accent rounded" style={{ width: `${pct}%` }} />
</div>
<p className="mt-2 text-xs text-text/80 line-clamp-3">{hit.chunk_text}</p>
</button>
);
}- Step 3: Write
src/components/FileList.tsx
import type { SearchHit } from "../types";
import FileRow from "./FileRow";
type Props = { hits: SearchHit[]; selected: SearchHit | null; onSelect: (h: SearchHit) => void };
export default function FileList({ hits, selected, onSelect }: Props) {
if (hits.length === 0)
return <div className="text-muted text-sm py-12 text-center">No results yet — index a folder and search.</div>;
return (
<div className="flex flex-col gap-2">
{hits.map((h, i) => (
<FileRow key={`${h.path}-${h.chunk_index}-${i}`}
hit={h}
selected={!!selected && selected.path === h.path && selected.chunk_index === h.chunk_index}
onSelect={() => onSelect(h)} />
))}
</div>
);
}- Step 4: Update
src/App.tsxto wire search
import { useState } from "react";
import type { SearchHit } from "./types";
import { api } from "./api";
import FolderPicker from "./components/FolderPicker";
import SearchBar from "./components/SearchBar";
import FileList from "./components/FileList";
import StatusBar from "./components/StatusBar";
export default function App() {
const [hits, setHits] = useState<SearchHit[]>([]);
const [selected, setSelected] = useState<SearchHit | null>(null);
const [busy, setBusy] = useState(false);
const [info, setInfo] = useState<string>("");
async function doSearch(q: string) {
setBusy(true); setInfo("");
try {
const r = await api.search(q, 12);
setHits(r.hits);
setSelected(r.hits[0] ?? null);
setInfo(`${r.hits.length} hits · ${r.elapsed_ms}ms`);
} catch (e) {
setInfo(`error: ${(e as Error).message}`);
} finally { setBusy(false); }
}
return (
<div className="grid grid-cols-[260px_1fr_400px] grid-rows-[40px_1fr_28px] h-full">
<header className="col-span-3 border-b border-border flex items-center px-4 gap-3 select-none"
data-tauri-drag-region>
<span className="font-mono text-accent text-sm pl-16">Rover</span>
<span className="text-muted text-xs">local · offline · private</span>
</header>
<aside className="border-r border-border p-3">
<FolderPicker onIndexed={() => {}} />
</aside>
<main className="p-4 overflow-auto flex flex-col gap-4">
<SearchBar onSearch={doSearch} busy={busy} />
<FileList hits={hits} selected={selected} onSelect={setSelected} />
</main>
<aside className="border-l border-border p-3 overflow-auto">
{selected
? <pre className="text-xs whitespace-pre-wrap font-mono">{selected.chunk_text}</pre>
: <div className="text-muted text-sm">Select a result to preview.</div>}
</aside>
<StatusBar info={info} />
</div>
);
}-
Step 5: Manual test — pick
demo-files/, index, search "budget alpha", verify ranked list with chunk previews. -
Step 6: Commit
git add src/components/SearchBar.tsx src/components/FileList.tsx \
src/components/FileRow.tsx src/App.tsx
git commit -m "feat(renderer): search bar, file list, ranked results"Files: Create src/components/AIPanel.tsx, modify src/App.tsx
- Step 1: Write
src/components/AIPanel.tsx
import { useState } from "react";
import type { SearchHit } from "../types";
import { api } from "../api";
import { tauri } from "../tauri";
type Props = { selected: SearchHit | null };
export default function AIPanel({ selected }: Props) {
const [summary, setSummary] = useState<string>("");
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<string>("");
async function summarize() {
if (!selected) return;
setBusy("summarizing"); setMsg(""); setSummary("");
try {
const r = await api.summarize(selected.path);
setSummary(r.summary);
setMsg(`generated in ${r.elapsed_ms}ms`);
} catch (e) { setMsg(`error: ${(e as Error).message}`); }
finally { setBusy(null); }
}
async function reveal() {
if (!selected) return;
try { await tauri.revealInFinder(selected.path); }
catch (e) { setMsg((e as Error).message); }
}
async function open() {
if (!selected) return;
try { await tauri.openFile(selected.path); }
catch (e) { setMsg((e as Error).message); }
}
async function createNote() {
if (!selected || !summary) return;
setBusy("note");
try {
const folder = selected.path.replace(/\/[^/]+$/, "");
const r = await tauri.createNote(folder, `summary-of-${selected.filename}`,
`# Summary of ${selected.filename}\n\n_Source: ${selected.path}_\n\n${summary}\n`);
setMsg(`✓ note created: ${r.path}`);
} catch (e) { setMsg((e as Error).message); }
finally { setBusy(null); }
}
if (!selected)
return <div className="text-muted text-sm">Select a result to act on it.</div>;
return (
<div className="flex flex-col gap-3 h-full">
<div>
<div className="text-[10px] uppercase text-muted tracking-widest">Selected</div>
<div className="font-medium text-sm truncate">{selected.filename}</div>
<div className="text-[10px] font-mono text-muted truncate">{selected.path}</div>
</div>
<div className="flex flex-wrap gap-1.5">
<button onClick={reveal} className="px-2 py-1 text-[11px] border border-border rounded hover:border-accent">Reveal in Finder</button>
<button onClick={open} className="px-2 py-1 text-[11px] border border-border rounded hover:border-accent">Open</button>
<button onClick={summarize} disabled={busy === "summarizing"}
className="px-2 py-1 text-[11px] border border-border rounded hover:border-accent disabled:opacity-40">
{busy === "summarizing" ? "Summarizing…" : "Summarize"}
</button>
<button onClick={createNote} disabled={!summary || busy === "note"}
className="px-2 py-1 text-[11px] border border-border rounded hover:border-accent disabled:opacity-40">
Create Note
</button>
</div>
{msg && <div className="text-[11px] text-amber-300/80 break-all">{msg}</div>}
<div className="flex-1 overflow-auto bg-surface border border-border rounded p-3 text-xs whitespace-pre-wrap">
{summary || <span className="text-muted">Chunk preview:{"\n\n"}{selected.chunk_text}</span>}
</div>
</div>
);
}-
Step 2: Wire into
App.tsx— replace right<aside>body with<AIPanel selected={selected} />(and add the import). -
Step 3: End-to-end manual test
npm run tauri dev- Pick
demo-files/ - Click "Index folder" → counts appear
- Type "budget alpha" → results
- Click first result
- Click "Summarize" → markdown bullets appear (<10s on M4 Pro)
- Click "Create Note" → toast shows new path; verify
.mdexists next to source - Click "Reveal in Finder" → Finder opens with file selected
- Click "Open" → file opens in default app
-
Step 4: Commit
git add src/components/AIPanel.tsx src/App.tsx
git commit -m "feat(renderer): AI panel — summarize, note, reveal, open"
git pushFiles: Create 7 markdown/txt files + 1 docx in demo-files/
-
Step 1: Write
demo-files/projects/alpha-budget-Q3.mdRealistic Q3 2026 budget for "Project Alpha": 200-400 words, line items in EUR, owners (Marco, Luisa), section "Capex vs Opex", date references, totals. Mix Italian + English.
-
Step 2: Write
demo-files/projects/alpha-notes.mdOngoing notes referencing Alpha decisions, blocker on Vendor X, next milestone date.
-
Step 3: Write
demo-files/projects/beta-roadmap.mdProject Beta milestones with target dates Q3-Q4 2026. NO mention of "alpha" or "budget" — semantic distractor.
-
Step 4: Write
demo-files/meetings/standup-2026-05-08.mdToday's standup notes. References Alpha sprint review.
-
Step 5: Write
demo-files/meetings/retro-2026-04-30.mdSprint retrospective bullets.
-
Step 6: Write
demo-files/random/ricetta-tiramisu.txtItalian tiramisu recipe (eggs, sugar, mascarpone, ladyfingers). Pure semantic distractor.
-
Step 7: Write
demo-files/random/vacanze-2025.mdTravel notes from 2025 with destinations and costs. Distractor.
-
Step 8: Generate
demo-files/projects/alpha-contract-draft.docx
cat > /tmp/build_docx.py <<'PY'
from docx import Document
d = Document()
d.add_heading("Project Alpha — Service Agreement Draft", 0)
d.add_paragraph("This Agreement is entered into between ACME S.r.l. and Vendor X "
"for the delivery of Project Alpha consulting services in Q3 2026.")
d.add_paragraph("Total contract value: EUR 84,500. Payment in three milestones: "
"30% on signature, 40% at midterm review, 30% on delivery.")
d.add_paragraph("Confidentiality: standard NDA terms apply. Term: 6 months from execution.")
d.add_paragraph("Signatories: Marco Bianchi (ACME), Luisa Rossi (Vendor X).")
d.save("demo-files/projects/alpha-contract-draft.docx")
PY
cd backend && . .venv/bin/activate && cd .. && python3 /tmp/build_docx.py- Step 9: Manual semantic check
After indexing in the running app, the query "presentazione budget progetto alpha" must rank alpha-budget-Q3.md first (NOT tiramisu, NOT vacanze).
- Step 10: Commit
git add demo-files/
git commit -m "feat(demo): synthetic corpus with semantic distractors"- Step 1: Run airplane-check
./scripts/airplane-check.shExpected: OK — no external network references in source.
-
Step 2: Wi-Fi OFF, full demo loop
- System Settings → disable Wi-Fi
npm run tauri dev- Pick demo-files/ → Index → Search "budget alpha" → Summarize → Create Note → Reveal
- Confirm every step works identically
-
Step 3: Commit (allow-empty if no code change)
git commit --allow-empty -m "test: airplane mode verified"
git push- Step 1: Build & bundle
# Tauri prod build:
# 1. runs `npm run build` (Vite output to dist/)
# 2. compiles Rust release binary
# 3. bundles .app + .dmg using config in tauri.conf.json
npx tauri buildExpected output: src-tauri/target/release/bundle/dmg/Rover_0.1.0_aarch64.dmg and src-tauri/target/release/bundle/macos/Rover.app.
- Step 2: Smoke test the .app
open src-tauri/target/release/bundle/macos/Rover.appThe app launches, sidecar starts (using Resources/backend/.venv/bin/python3 if bundled, falling back to system python3). Full demo flow works.
- Step 3: Decide on Python distribution
Two options for shipping the sidecar:
-
Option A (hackathon-friendly): ship the
backend/.venvdirectory bundled as resource. Add totauri.conf.json→bundle.resources:"resources": ["../backend/**/*"]
⚠️ This already includes.venvsince the glob is broad. Add a.tauriignoreif needed to exclude__pycache__/,.pytest_cache/,data/. The bundled .app will be ~250 MB but self-contained for the demo machine. -
Option B (proper): PyInstaller-bundle the backend into a standalone executable, register as
externalBin. ~3-4 hours of work. Skip for the hackathon unless Phase 8 buffer is fully spare.
For the demo, use Option A.
- Step 4: If 6.1 burns >90 min, abort .dmg
npm run tauri dev is a perfectly fine demo target. Don't sink the demo for a packaging artifact.
- Step 5: Commit any tauri.conf tweaks
git add src-tauri/tauri.conf.json
git commit -m "build: bundle python venv as resource for self-contained .app"
git pushFiles: Create README.md
-
Step 1: Write README
Sections:
- What it is ("Rover — offline AI Finder alternative", 2-3 lines + screenshot)
- Why local (rubric pitch: airplane mode, zero cloud, $0 inference, ~20 MB shell)
- Architecture (ASCII diagram showing Tauri → Rust → Python sidecar → Ollama)
- Install (
./scripts/setup.sh— single command, idempotent) - Run dev (
npm run tauri dev) - Build (
npx tauri build) - Demo script for judges (literal click-by-click for the 3-min pitch)
- Airplane mode proof (
./scripts/airplane-check.shoutput) - Tech stack & model footprint
- Out of scope (defends against rubric whataboutism)
-
Step 2: Capture 1 screenshot
mkdir -p docs
# After app is running with results visible:
screencapture -x docs/screenshot.png- Step 3: Commit
git add README.md docs/
git commit -m "docs: README with demo script + screenshot"
git pushReserved for: bug whack-a-mole · demo corpus tweaks · icon polish · code-signing if there's appetite.
| # | Action | Pass criterion |
|---|---|---|
| 1 | ./scripts/airplane-check.sh |
exit 0 |
| 2 | npm run tauri dev |
window opens, footer dot turns green within 30s |
| 3 | Pick demo-files/, click Index |
files_indexed = 8, chunks > 8 |
| 4 | Search "presentazione budget progetto alpha" | top hit = alpha-budget-Q3.md, score > 0.55 |
| 5 | Click result → Summarize | markdown bullets in <10s on M4 Pro |
| 6 | Click "Create Note" | new .md appears in demo-files/projects/ |
| 7 | Click "Reveal in Finder" | macOS Finder reveals file with selection |
| 8 | Click "Open" | file opens in default app |
| 9 | Wi-Fi OFF, repeat 2–8 | identical behavior |
| 10 | cd backend && pytest -v |
all green |
| 11 | cd src-tauri && cargo test |
all green |
At hour 14, backend works but Tauri shell flaky:
- Drop Tauri packaging step. Run
npm run tauri devfor the demo. - All features stay; only loss is the
.dmgartifact.
At hour 16, Tauri/Rust IPC broken:
- Pivot to running backend separately (
uvicorn) and opening Vite dev server in browser. Same demo loop, no native polish. - Honest fallback. Mention it once in pitch ("dev shell — production ships as Tauri .dmg").
At hour 18, only backend works:
- Demo via CLI:
python3 -m demo_cliscript that takes a query and prints ranked hits + summary. - Less sexy but proves the actual technical thesis (offline AI on filesystem).
At hour 19+, nothing UI works:
- Show
pytest -vgreen + airplane-check.sh + a recorded demo from earlier. Acknowledge fight, present what shipped.
File watcher · auto-tagging · batch rename · TTS/STT · multi-folder simultaneous indexing · drag&drop · cloud anything · authentication · multi-user · code-signing/notarization · Windows/Linux .dmg-equivalent build (Mac demo only) · PyInstaller sidecar bundling.