Build Saner.AI
KINDAback to the verdict
A local-first notes app whose hard problems are getting a captured thought onto disk before anything can lose it, keeping a search index honest about files edited outside the app, and making the optional model answer only from what you actually wrote.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why The application owns a folder on disk, which is a desktop job rather than a server one.
Get it rustup.rs, then the Tauri 2 prerequisites page for your platform. open ↗
Verify
cargo --version prints and a blank Tauri 2 application launches - installfree
Why Builds the front end and runs its tests.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - installfree
Why The search index depends on it, and a build without FTS5 fails in a confusing way at the first query rather than at startup.
Get it Most distributions ship it enabled; check before building anything on top of it. open ↗
Verify
A CREATE VIRTUAL TABLE USING fts5 statement succeeds - have readyfree
Why Rename handling, outside edits and broken links only misbehave on notes written by a person over time, and a generated corpus has none of that texture.
Get it Export from whatever you use now, or point the app at a folder you already write in, with a copy taken first.
Verify
At least a few hundred files, including some with links to notes that do not exist - API keyfree locally, pennies per question hosted
Why It is genuinely optional, and the suite has to pass with it removed, so treat it as an add-on rather than a dependency.
Get it A local runtime with a downloaded model, or any API key you are happy sending note text to.
Verify
One answer returns with citations, and removing the configuration leaves the app working - decidefree
Why All three are asserted in tests, and the retrieval threshold in particular decides how often the app refuses to answer.
Get it Write down where the notes live, how quickly outside edits must be picked up, and the similarity below which no answer is given.
Verify
All three appear in configuration and are named in tests
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- The notes are files. Every table below is derived and can be deleted without losing anything. - notes(path, uuid, title, created_at, modified_at, mtime, size, content_hash) · the uuid lives in the front matter, so a rename changes the path and nothing else - notes_fts(title, body) as an FTS5 external content table over notes · which, by design, does not keep itself in sync - links(from_uuid, target_text, resolved_uuid, is_broken) · broken is a state worth showing, not an error worth hiding - aliases(uuid, former_title) · so a link written against yesterday's title still lands - tags(uuid, tag, source) · from front matter and from inline markers, and both round-trip to the file - chunks(note_uuid, ordinal, text, start_offset, end_offset) and embeddings(chunk_id, model, vector) · created only when a model is configured - answers(question, answer, cited_chunk_ids_json, model, at) · every answer keeps its citations, so a wrong one can be traced to what it read
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
NOTES_DIR | required | ~/Documents/notes | The folder that is the record. Everything else can be rebuilt from it. |
INDEX_DB | required | ~/.local/share/notes/index.db | Disposable by design. Deleting it triggers a rebuild. |
WATCH_DEBOUNCE_MS | required | 500 | How long to wait after a file changes before re-indexing it. |
REBUILD_ON_START | required | hash | Reconcile by mtime and hash at startup. Never assume the index is current. |
AI_ENDPOINT | optional | http://localhost:11434 | Optional. Absent means search only, which is a complete mode. |
AI_API_KEYsecret | optional | sk-...redacted | Only if the endpoint needs one. Note text is sent there, so choose deliberately. |
RETRIEVAL_THRESHOLD | required | 0.35 | Below this, the panel refuses to answer rather than guessing. |
CONFLICT_SUFFIX | required | .conflict | Appended to the losing side of a simultaneous edit, which is kept rather than discarded. |
The build, in order
Capture that writes before it draws
A thought is on disk before the window has finished appearing.
The rename is the only moment the note becomes real.
Files
src-tauri/src/capture.rstests/atomic_write.rsIt is what survives a rename, a move and a reorganisation.
Files
src-tauri/src/frontmatter.rsIndexing is a consequence of a capture, never a precondition.
Files
src-tauri/src/capture.rs
done when · tick each as it passeswatch out- Writing the note into the database and flushing to disk on a timer loses whatever was captured when the process is killed or the machine sleeps, which for a capture tool is most of a day's thinking, and the loss is invisible because the list keeps showing the row until the next restart. Write the file first, atomically, and index afterwards, so that an indexer that never runs has cost you nothing.
An index you can throw away
Search is fast, and deleting the database costs nothing but a rebuild.
Cheap, correct, and only half the problem.
Files
src-tauri/src/index/triggers.sqlThe other half, and the one that makes search trustworthy.
Files
src-tauri/src/index/watch.rstests/external_edit.rsA rebuild nobody has run is a rebuild that does not work.
Files
src-tauri/src/index/rebuild.rs
done when · tick each as it passeswatch out- An FTS5 external content table does not maintain itself: the documentation is explicit that keeping the index consistent with the content is the caller's job, so a note edited in another editor leaves search confidently returning the old text and opening a note that no longer contains the phrase you searched for. Keep triggers for the app's own writes, reconcile outside edits by mtime and hash, and make a full rebuild a single tested command.
Links, backlinks and the note that does not exist yet
Linking to something unwritten is useful rather than broken.
The two are different things and conflating them is the bug.
Files
src-tauri/src/links/resolve.rstests/rename.rsPeople write links by hand and do not update them.
Files
src-tauri/src/links/aliases.rsThe gaps are the most useful list in a notes app.
Files
src/ui/Unresolved.tsx
done when · tick each as it passeswatch out- Resolving links by file name means renaming a note breaks every link into it, and because the link still renders as ordinary text the breakage stays silent until the day you go looking for something and find nothing. Resolve through the identifier, keep former titles as aliases so hand-written links still land, and show unresolved links as a list rather than hiding them.
An optional model that has to cite
An answer without a quote from your own notes is refused.
The refusal is the feature, and it needs a number.
Files
src-tauri/src/ai/answer.rstests/refusal.rsA citation you cannot click is a claim.
Files
src/ui/Answer.tsxTested by unsetting the configuration and running the suite.
Files
src-tauri/src/ai/mod.rs
done when · tick each as it passeswatch out- A model handed a question with nothing retrieved will answer from its training and produce something plausible about your own life that you never wrote, and because the tone matches the rest of the app it gets believed and then quoted back to you months later. Refuse to answer below the retrieval threshold, and render every claim beside the quote it came from.
Portability, because this is where notes go to dieproduct builder
Leaving costs nothing, and files written years ago still open.
Every past version, in the suite, forever.
Files
src-tauri/src/migrate.rstests/fixtures/A merge that picks a winner loses a paragraph silently.
Files
src-tauri/src/sync/conflict.rsThat is the whole claim, so it is a test rather than a sentence.
Files
tests/fresh_install.rs
done when · tick each as it passeswatch out- Letting a file sync service resolve a simultaneous edit picks one side and discards the other, and in a notes app the discarded side is a paragraph nobody will ever notice is missing. Detect divergence by content hash, write the loser as a conflict copy next to the note, and list conflicts as work to do rather than resolving them quietly.
That is the whole plan for Saner.AI. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Multi-device sync with merging.
- Collaborative editing and sharing.
- Native mobile clients.
- Importers for proprietary note formats.
- native mobile clients
- conflict-safe multi-device sync
- mature importers and exporters
- collaborative editing and sharing
- Daily notes and a scratch buffer, which are conventions over the same files rather than new storage
- A read-only publish of a chosen tag to static HTML, which the index already has everything for
- Local embeddings computed in the background, once the answering panel has proved it refuses correctly
Need the files? The project pack on the verdict page hands your agent the whole brief · more notes & knowledge.