Skip to content

Latest commit

 

History

History
2283 lines (1822 loc) · 71.2 KB

File metadata and controls

2283 lines (1822 loc) · 71.2 KB

Rover — Implementation Plan

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-text via 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 via invoke() (native ops).

Time budget: ~22 wall hours of work in a 20h window — priority cuts in Phase 8 are real and pre-planned.


File Structure

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.


IPC Contract (Rust commands ↔ Renderer)

// 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".


Test Strategy

  • Backend (Python): pytest with a FakeEmbedder (no Ollama in CI). One live test gated by OLLAMA_LIVE=1.
  • Rust: cargo test for commands.rs pure logic (note path generation, confirm dialog response mapping). UI integration manual.
  • Renderer: Vitest for api.ts/tauri.ts shape; UI validated by manual demo loop.
  • Demo gate (must pass before "done"): airplane-check.sh returns 0 with Wi-Fi off; full flow (pick folder → index → search → summarize → create note → reveal in Finder) completes < 10s on demo corpus.

Phase 0 — Bootstrap (60 min)

Task 0.1: Install Rust toolchain

  • 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 --version

Expected: 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 installed

Expected: "command line tools already installed" or quiet exit.


Task 0.2: Tauri scaffold + project bones

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

Task 0.3: Verify Ollama works

  • 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".


Phase 1 — Python Sidecar (4 hours, TDD)

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.

Task 1.1: Config + models

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"

Task 1.2: Parsing (TDD)

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)"

Task 1.3: Chunking (TDD)

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"

Task 1.4: Ollama client (TDD with mocked httpx)

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"

Task 1.5: Vector store (TDD)

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"

Task 1.6: Indexer (TDD)

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"

Task 1.7: Retriever (TDD)

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"

Task 1.8: FastAPI surface (TDD)

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 %1

Expected: 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 push

Phase 2 — Rust Shell (4 hours)

Task 2.1: Sidecar lifecycle in Rust

Files: 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"

Task 2.2: Native commands

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)"

Task 2.3: Wire lib.rs

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 dev

Expected: 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 push

Phase 3 — Renderer UI (4 hours)

Task 3.1: Renderer entry + global styles + API wrappers

Files: 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"

Task 3.2: App shell + StatusBar (sidecar-status event listener)

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 smokenpm 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"

Task 3.3: FolderPicker (native dialog via tauri command)

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 — add import 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"

Task 3.4: SearchBar + FileList + FileRow

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.tsx to 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"

Phase 4 — Actions & AIPanel (3 hours)

Task 4.1: AIPanel (summarize + create note + reveal + open)

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

    1. npm run tauri dev
    2. Pick demo-files/
    3. Click "Index folder" → counts appear
    4. Type "budget alpha" → results
    5. Click first result
    6. Click "Summarize" → markdown bullets appear (<10s on M4 Pro)
    7. Click "Create Note" → toast shows new path; verify .md exists next to source
    8. Click "Reveal in Finder" → Finder opens with file selected
    9. 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 push

Phase 5 — Demo Corpus & Airplane Mode (2 hours)

Task 5.1: Synthetic demo files

Files: Create 7 markdown/txt files + 1 docx in demo-files/

  • Step 1: Write demo-files/projects/alpha-budget-Q3.md

    Realistic 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.md

    Ongoing notes referencing Alpha decisions, blocker on Vendor X, next milestone date.

  • Step 3: Write demo-files/projects/beta-roadmap.md

    Project 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.md

    Today's standup notes. References Alpha sprint review.

  • Step 5: Write demo-files/meetings/retro-2026-04-30.md

    Sprint retrospective bullets.

  • Step 6: Write demo-files/random/ricetta-tiramisu.txt

    Italian tiramisu recipe (eggs, sugar, mascarpone, ladyfingers). Pure semantic distractor.

  • Step 7: Write demo-files/random/vacanze-2025.md

    Travel 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"

Task 5.2: Airplane mode verification

  • Step 1: Run airplane-check
./scripts/airplane-check.sh

Expected: OK — no external network references in source.

  • Step 2: Wi-Fi OFF, full demo loop

    1. System Settings → disable Wi-Fi
    2. npm run tauri dev
    3. Pick demo-files/ → Index → Search "budget alpha" → Summarize → Create Note → Reveal
    4. 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

Phase 6 — Package as .dmg (2 hours, high-impact)

Task 6.1: Production build

  • 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 build

Expected 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.app

The 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/.venv directory bundled as resource. Add to tauri.conf.jsonbundle.resources:

    "resources": ["../backend/**/*"]

    ⚠️ This already includes .venv since the glob is broad. Add a .tauriignore if 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 push

Phase 7 — README & Polish (1 hour)

Task 7.1: README

Files: Create README.md

  • Step 1: Write README

    Sections:

    1. What it is ("Rover — offline AI Finder alternative", 2-3 lines + screenshot)
    2. Why local (rubric pitch: airplane mode, zero cloud, $0 inference, ~20 MB shell)
    3. Architecture (ASCII diagram showing Tauri → Rust → Python sidecar → Ollama)
    4. Install (./scripts/setup.sh — single command, idempotent)
    5. Run dev (npm run tauri dev)
    6. Build (npx tauri build)
    7. Demo script for judges (literal click-by-click for the 3-min pitch)
    8. Airplane mode proof (./scripts/airplane-check.sh output)
    9. Tech stack & model footprint
    10. 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 push

Phase 8 — Buffer (2 hours)

Reserved for: bug whack-a-mole · demo corpus tweaks · icon polish · code-signing if there's appetite.


Manual Test Matrix (run before declaring "done")

# 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

Priority Cuts (if hours run out — pre-planned, no panic)

At hour 14, backend works but Tauri shell flaky:

  • Drop Tauri packaging step. Run npm run tauri dev for the demo.
  • All features stay; only loss is the .dmg artifact.

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_cli script 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 -v green + airplane-check.sh + a recorded demo from earlier. Acknowledge fight, present what shipped.

Out of Scope (do NOT build)

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.