This document explains why Sifty is shaped the way it is. For contributor ground rules see CONTRIBUTING.md; for usage see README.md.
- Safe by construction. A maintenance tool that can delete files must make destruction the hard path, not the default. Mistakes should be recoverable.
- Testable without a real Windows victim. Core logic runs against sandboxes, so we can iterate fast and prove safety on any machine.
- CLI now, GUI later, no rewrite. Logic lives in plain functions; the CLI is a thin shell over them. A future GUI/TUI imports the same functions.
- Private AI. The AI is local (Ollama) and sees only metadata. No file contents and nothing personal leave the machine.
┌──────────────────────┬──────────────────────┐
user → │ cli/ (Typer) │ tui/ (Textual) │ thin frontends
│ commands/*.py │ views/*.py │
├──────────────────────┴──────────────────────┤
│ core/ - engine (pure-ish), the gatekeeper │ testable core
│ models · safety · junk · disk · apps · │
│ updates · organize │
├───────────────────────┬──────────────────────┤
│ windows/ - OS calls │ infra/ - config, log │ primitives
│ admin·recyclebin·winget│ │
├───────────────────────┴──────────────────────┤
│ ai/ - advisory (+ agentic), degrades to no-op │ optional
└──────────────────────────────────────────────┘
The split is now across packages, not within a file:
core/<domain>.py: pure-ish functions that return data (scan() -> list[CategoryScan],find_duplicates() -> dict,plan_organization() -> list[Move]). No Typer/Textual; OS access goes throughwindows/. This is what tests, the TUI, and the AI agent all call.cli/commands/*.pyandtui/views/*.py: thin frontends that parse input / render and call core, handling the dry-run/confirm flow. No business logic worth testing.windows/: every direct OS call (Send2Trash, winget, registry, UAC).infra/: config + logging.console.pyholds the shared Rich helpers.
All destruction funnels through one function so there is a single place to audit
and a single place that can refuse. safety.trash():
- Calls
assert_safe()→is_protected(). - If dry-run, returns without touching disk.
- Otherwise sends the path to the Recycle Bin and appends to the audit log.
is_protected() uses two tiers of roots because "protect the whole drive"
and "protect the Windows directory" need different rules:
| Tier | Examples | Rule |
|---|---|---|
| Contents-protected | C:\Windows, Program Files, Program Files (x86), ProgramData, plus user extra_protected_paths |
Refuse the root, anything inside it, or any ancestor of it. |
| Self-protected | drive root (C:\), user profile root |
Refuse only the root itself (or an ancestor). Contents stay deletable; otherwise the whole disk would be off-limits. |
Callers that legitimately need to delete inside a contents-protected root pass
allow_subtrees to vouch for a specific path (e.g. junk cleaning passes
C:\Windows\Temp). This keeps the broad denylist strict while permitting the few
known-safe carve-outs.
Invariant: the only deletion primitive in the codebase is safety.trash().
Anything else (os.remove, shutil.rmtree, …) is a bug.
- junk: categories are data (
JunkCategory) built from the environment, each withrootsand anallow_subtreescarve-out. Scanning measures sizes; cleaning trashes top-level entries. The Downloads-installers category is config-gated off by default (those files are often wanted). - disk:
psutilfor volumes; size-then-SHA-256 two-pass for duplicates (hash only files that share a size, so most files are never hashed). - apps: reads the registry Uninstall + Run keys via
winreg(read-only, no admin needed) and the Startup folder; uninstalls shell out towinget. - updates:
wingethas no stable machine output, so we parse its fixed-column table by header offsets. This is the most brittle code in the repo and is the reasontest_updates.pyexists. - organize: plans
(src → dest)moves and previews them; moves are reversible (into subfolders) so they don't go through the Recycle Bin, but still default to dry-run._unique_dest()avoids clobbering.
ai/client.py wraps the Ollama HTTP API. is_available() is checked before every
use so a missing/stopped Ollama degrades to "no AI" rather than an error.
ai/advisor.py builds prompts from metadata only and is the sole place prompts
live; the advisor itself only explains and recommends, it never acts.
ai/agent.py is the agentic loop: the model may call tools from ai/tools.py,
each tagged read / low / high. Whether a call runs, prompts for confirmation,
or is blocked follows the global autonomy level plus optional per-tool policies
(ai/policy.py, stored in ai_state.json). The AI has no privileged path around
safety - every tool that deletes routes through core.safety.trash(), so protected
paths are refused and everything is audited whether a human or the agent triggered it.
ai/context.py builds the metadata-only machine snapshot, now including lightweight
learned preferences. core/ai_memory.py (a separate ai_memory.db, kept out of the
history.db undo ledger) persists the chat transcript and the tool skips those
preferences are derived from.
pyproject.tomlsetspythonpath = ["src"]so tests importsiftywithout an install step.- Safety/junk tests
monkeypatchthe protected-root environment variables andPath.home, then build fixtures undertmp_path. This makes the Windows-specific path logic deterministic and runnable on any OS. core.safety.send_to_trashis monkeypatched in tests so nothing is ever really deleted;windows/primitives are mocked.- The fragile winget parser is tested against a captured sample table.
- New capability: add
core/<name>.py(logic),cli/commands/<name>.py(handler) and atui/views/<name>.py, wired incli/app.pyand the TUI nav. The junk and disk modules are the pattern to copy. - New junk source: a
JunkCategoryinjunk.pywith itsallow_subtreescarve-out and a sandbox test intests/test_junk.py. - GUI: import the core functions; no logic needs to move.
- Packaging: PyInstaller single-file exe; the entry point is
packaging/exe_entry.py(seepackaging/README.md).