First run (v0.5 — Files-by-Google-style UI)
- Get started. The Clean tab lists Common folders — Downloads, DCIM, Pictures, Documents, Music, Movies, WhatsApp, Telegram. Tap one: the app asks for All-files access once (Android's SAF picker refuses the Downloads root and volume roots, so plain file access is the only way to reach them), then scans it. Or tap Choose a folder for the zero-permission SAF path — any grantable folder works there. Either grant is persisted across restarts.
- Set a backend from ⚙ Settings (top bar): radio-card backend choice, then a Zo access token, or an OpenAI-compatible base URL + key + model, with Test buttons that round-trip the endpoint and an in-place models.dev browser. See Backends.
- Browse tab. The scan runs automatically and fills the category tiles (Images, Videos, Audio, Documents, Archives, Apps) and the Recent list, just like Files by Google. Tap a tile to see that category's files; long-press files to select them if you want the plan scoped to just those.
- Clean tab. Tap Get suggestions (optionally with a nudge in the text box, e.g. "put invoices from March under Bills/2026").
- Review. Valid suggestions are ticked by default on card rows (file icon, move arrow, reason); rejected rows show the rule that killed them. Untick anything you don't want.
- Apply N on the sticky bottom bar, then Undo if you don't like the result.
Backends
Zo (/zo/ask) — default
Create an access token in the Zo app under Settings ▸ Advanced ▸ Access Tokens, then paste it into ⚙ Token / model. Leave the model field empty to use the account default, or name one explicitly (e.g. a DeepSeek or GLM model id) to keep plans cheap and fast.
Transport is streaming SSE through the zo-kotlin SDK (dev.zocomputer:ask), consumed as a composite build. Continuity is server-side, which is why refinements don't resend the folder listing.
Any OpenAI-compatible endpoint
In ⚙ Token / model, switch the backend and fill in:
| Field | Example |
|---|---|
| Base URL | https://openrouter.ai/api/v1 · https://api.deepseek.com/v1 · http://192.168.1.20:11434/v1 (Ollama on your LAN) |
| API key | stored in EncryptedSharedPreferences; blank for local servers that don't check |
| Model id | whatever the endpoint expects, e.g. qwen3-coder:30b |
Requests go to {base}/chat/completions with stream: true, and deltas are parsed from SSE as they arrive.
The Test buttons do a real round-trip against the configured backend — Zo lists your available models, OpenAI-compatible endpoints get a GET {base}/models — and show the result inline, so bad tokens or wrong base URLs surface before you scan anything.
Decision model (System One — Jev / Clef / OpenRouter)
Two flavors. Jev-compatible endpoint: any raw endpoint that takes
{state, questions} and returns answers with calibrated
probabilities (Cloudflare Workers AI Clef, self-hosted). OpenRouter: pick
the OpenRouter flavor and Ozhungu talks to the
Decisions API directly —
the model list (Jev, pplx-decider, Liquid D1, Solar-Decide, TEV and friends) is fetched
from OpenRouter's public endpoint, with ~typesafe/jev-latest as the default.
One OpenRouter API key covers every model on it.
old
The System One tier inverts the LLM flow: instead of asking a model to write a JSON plan, Ozhungu asks a decision model one typed choice question per file — "which folder does this belong in?" — with a candidate vocabulary derived from the scan itself (existing folders first, then canonical Pictures/Documents/Bills/…, plus an explicit __keep__ option). The model returns a calibrated probability for every option; no free-form text is ever parsed.
The whole listing goes as the request state, 64 questions per request (Workers AI's cap), so hundreds of files cost a handful of requests at token prices an order of magnitude below chat models. A file is moved only when argmax ≥ your confidence floor (default 60%) and ≠ __keep__ and it isn't already in that folder; everything else stays untouched. Duplicate targets get deterministic " (2)" suffixes; the resulting plan still passes the same PlanValidator guardrails and review checklist as LLM plans.
| Field | Example |
|---|---|
| Endpoint URL (full) | https://api.cloudflare.com/client/v4/accounts/<account>/ai/run/@cf/cloudflare/clef-flash — or a self-hosted Clef /v1/decisions URL |
| API key | Workers AI token; blank for self-hosted |
| Confidence floor (%) | 60 — answers below this leave the file in place |
Test connection sends a one-question noul round-trip to prove the wiring. Refinements don't apply to this backend — it's single-shot by design; switch to Zo or an OpenAI-compatible model for conversation.
Browsing the models.dev catalog
The Browse catalog button in the OpenAI-compatible section fetches models.dev/api.json (cached on disk for 24 hours, in-memory after that) and opens a full-screen picker:
- A search box filters across provider ids, names and model ids as you type; the list is grouped by provider, with provider headers clickable too (pick a header to fill just the base URL and type the model id yourself — useful for local servers that don't appear in the catalog).
- Only providers that publish an API base URL are listed, minus the SDKs whose endpoints aren't chat-completions-shaped.
- Providers whose models mention zero data retention are badged ★ and sorted to the top — up to 60 providers, 40 models each.
- Choosing a model fills the base URL and model id. The API key stays yours to paste.
The plan contract
The model is asked to reply with one JSON object and nothing else:
{
"plan": {
"folders": ["Bills/2026", "Photos/Trips"],
"actions": [
{ "op": "move", "from": "IMG_20240101_900.jpg", "to": "Photos/Trips/IMG_20240101_900.jpg", "reason": "dated camera file" },
{ "op": "rename", "from": "scan0043.pdf", "to": "Receipts/electricity-2026-03.pdf", "reason": "receipt scan" }
]
}
}
Rules the model is told, and that the validator then enforces regardless:
opismoveorrename— nothing else exists.- Every path is relative to the picked folder, no leading
/, no... frommust be a file that appeared in the scan.- Target folders may be new; the executor creates them.
- Every action carries a one-line reason, which is shown in the review list.
The parser is defensive: it extracts the first balanced JSON object from the reply, so a chatty model that wraps the plan in prose or a fenced code block still works.
Guardrail rules
These run locally, offline, after the model has spoken and before anything touches storage. A row failing any rule is marked rejected and cannot be applied:
| Rule | Rejection message |
|---|---|
| Only move/rename | unsupported op '…' |
| Path stays inside the root | from/to must be relative and stay inside the folder ('..' or absolute paths rejected) |
| Source came from the scan | from '…' was not in the scan |
| No self-move | to equals from |
| No overwrite of a scanned file | to would overwrite scanned file '…' |
| Target isn't an existing folder | to is an existing folder ('…') |
| No duplicate targets | to duplicates an earlier action's target |
| No chaining | to duplicates an earlier action's target / parent checks catch action→action feeds |
| Parent must not be a file | to's parent is a file ('…') |
| Non-empty file name | to has an empty file name |
The validator is pure Kotlin with no Android dependencies, which is why it's one of the most heavily tested parts of the codebase.
Journal & undo
Every applied action is recorded — source, destination, and the URI the move produced — and written to last_journal.json in the app's private files directory. Undo last apply replays the journal in reverse:
- Move the file back to where it came from.
- If the original name differed (a rename), rename it back.
- Report per-row: how many were undone, how many failed.
The journal is one batch deep — a new apply replaces it. Directories created by the plan are left in place; they're empty and harmless, and deleting them is a delete operation the app deliberately doesn't have.
Architecture
The seam that matters: everything above brain/ is provider-agnostic, and everything below exec/ is model-agnostic. Neither knows about the other.
Tests & coverage
make test # 41 JVM unit tests, no emulator
make lint # Android lint
make coverage # JaCoCo per-class line table (scripts/coverage_summary.py)
make verify # test + lint + assembleDebug
| Layer | Line coverage |
|---|---|
| PlanParser | 96.1% |
| PlanValidator | 97.1% |
| PromptBuilder | 94.1% |
| OaiPlanner.Sse / Transcript, JournalCodec, plan grammar | 100% |
| ModelsDev catalog parse (network + cache paths uncovered) | 64.3% |
| DecisionModel (System One core) | 97.4% |
| DecisionModel — System One core (vocabulary, batching, parse, decide) | 97.4% |
| MainActivity, SafFs, Scanner, Executor, Settings, OkHttp paths | Android-instrumentation boundary — untested by design |
CI runs tests + lint and publishes a debug APK plus the JaCoCo report as artifacts on every push. The honest number for the whole app is 27.1% of lines — the tested quarter is the quarter that decides what happens to your files, and the untested three-quarters is UI wiring and SAF plumbing that needs an emulator or device to exercise properly.
Privacy / egress
One thing leaves the device: the folder listing, sent to whichever backend you configured, at the moment you press Ask.
| Leaves the device | Stays on the device |
|---|---|
| File names, sizes, modified dates | File contents of anything large or binary |
| Up to 1200 characters of small text files (snippets help the model tell invoices from notes) | Anything outside the folder you picked |
| Your typed nudge, if any | API keys — EncryptedSharedPreferences, and only to the endpoint you set |
| — | The journal, and every decision the validator makes |
If a folder's names alone are sensitive to you, use a local endpoint (Ollama/LM Studio on your LAN via the OpenAI-compatible backend) or don't point Ozhungu at that folder. There is no telemetry, no analytics, no crash reporting.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| "Scan first" | No folder grant yet, or the grant was revoked. Re-pick the folder. |
| Scan stops around 600 files | By design — the cap keeps prompts (and bills) sane. Point it at a subfolder. |
| Expected files missing | They're probably in /Android/data, /Android/obb, a dot-directory, or deeper than 4 levels. All four are skipped. |
| "Plan parse failed" | The backend replied with no JSON object. Usually a small or over-chatty model. Try a stronger model id, or the Zo backend. |
| Everything rejected | Read the per-row reasons — most often the model invented paths that weren't in the scan. Refine the prompt, or re-scan. |
| "Apply failed: …" | The document provider refused the operation, or the folder moved. Re-scan; per-row failures are reported, not fatal. |
| 401 / 403 from the endpoint | Check base URL includes the API version path (/v1) and that the key is for that provider. |
| Nothing listed in the model picker | models.dev unreachable, or the 24 h cache is empty and the fetch failed. Retry; local endpoints don't need the picker at all. |
Roadmap
- Duplicate detection — hash-based, with a review step that still refuses to delete.
- Instrumented tests — an emulator suite for SAF operations and the executor, closing the coverage gap above.
- Shizuku integration — opt-in reach into
/Android/mediaand friends, without requesting All files access. - Rules mode — deterministic filename-pattern rules for the boring 80%, model only for the ambiguous rest.
- Plan diffs — show what a refinement changed instead of re-listing everything.
Nothing on this list adds delete. If you want that, you want a different app.