$ ozhungu guide

Ozhungu — Guide

Setup, the plan contract, guardrails, undo, architecture, troubleshooting.

First run (v0.5 — Files-by-Google-style UI)

v0.7 — Downloads now works. The Clean tab lists common folders (Downloads, DCIM, Pictures, Documents, Music, Movies, WhatsApp, Telegram). Android's SAF picker refuses the Downloads root on Android 11+, so the first tap asks once for All-files access; afterwards Ozhungu reads and moves with plain file I/O. Prefer zero permissions? Choose a folder still works SAF-only for any folder Android will grant.
  1. 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.
  2. 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.
  3. 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.
  4. Clean tab. Tap Get suggestions (optionally with a nudge in the text box, e.g. "put invoices from March under Bills/2026").
  5. 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.
  6. Apply N on the sticky bottom bar, then Undo if you don't like the result.
Refinement works as a conversation: with suggestions on screen, type "no — keep the photos where they are and only sort the PDFs" into the same box and ask again. With the Zo backend the server holds the thread; with an OpenAI-compatible backend the app keeps a local transcript (system + last two turns) in memory only.
Every folder is visible. The Browse tab lists all sub-folders (any depth) with file counts — tap one to open it. On the Clean tab, Move into lets you tick the folders to use as destinations; the decision model offers your picks first, and LLM backends get them in the prompt.

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:

FieldExample
Base URLhttps://openrouter.ai/api/v1 · https://api.deepseek.com/v1 · http://192.168.1.20:11434/v1 (Ollama on your LAN)
API keystored in EncryptedSharedPreferences; blank for local servers that don't check
Model idwhatever 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.

FieldExample
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 keyWorkers 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:

Read the badge as a hint. It's a text match over models.dev metadata, not a contractual claim about the provider's retention policy. Verify before trusting it with anything sensitive.

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:

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:

RuleRejection message
Only move/renameunsupported op '…'
Path stays inside the rootfrom/to must be relative and stay inside the folder ('..' or absolute paths rejected)
Source came from the scanfrom '…' was not in the scan
No self-moveto equals from
No overwrite of a scanned fileto would overwrite scanned file '…'
Target isn't an existing folderto is an existing folder ('…')
No duplicate targetsto duplicates an earlier action's target
No chainingto duplicates an earlier action's target / parent checks catch action→action feeds
Parent must not be a fileto's parent is a file ('…')
Non-empty file nameto 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:

  1. Move the file back to where it came from.
  2. If the original name differed (a rename), rename it back.
  3. 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

MainActivity thin UI: picker, buttons, review list, status ├─ scan/Scanner SAF tree walk → ScanResult(entries, uriByPath, dirPaths) ├─ plan/PromptBuilder builds the system prompt + capped folder listing ├─ plan/PlanParser balanced-JSON extraction → Plan(folders, actions) ├─ plan/PlanValidator the guardrail table above → ValidatedPlan(valid, invalid) ├─ brain/Planner interface: plan(prompt, scan) / cancel() │ ├─ brain/ZoPlanner /zo/ask via zo-kotlin, server-side continuity │ ├─ brain/OaiPlanner /chat/completions, SSE, local Transcript (testable seam) │ └─ brain/ModelsDev provider catalog fetch + filter + 24 h cache ├─ exec/Executor ensurePath → moveDocument → renameDocument; undo() ├─ exec/Journal JSON codec for the undo batch └─ saf/SafFs DocumentsContract wrapper: children, ensurePath, move, rename

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
LayerLine coverage
PlanParser96.1%
PlanValidator97.1%
PromptBuilder94.1%
OaiPlanner.Sse / Transcript, JournalCodec, plan grammar100%
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 pathsAndroid-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 deviceStays on the device
File names, sizes, modified datesFile 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 anyAPI 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

SymptomCause / fix
"Scan first"No folder grant yet, or the grant was revoked. Re-pick the folder.
Scan stops around 600 filesBy design — the cap keeps prompts (and bills) sane. Point it at a subfolder.
Expected files missingThey'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 rejectedRead 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 endpointCheck base URL includes the API version path (/v1) and that the key is for that provider.
Nothing listed in the model pickermodels.dev unreachable, or the 24 h cache is empty and the fetch failed. Retry; local endpoints don't need the picker at all.

Roadmap

Nothing on this list adds delete. If you want that, you want a different app.