Vibecode Saner.AI
track this build4 phases, 12 steps, beginner friendly0%The visible AI personal knowledge loop is buildable, but a credible replacement needs more than the first screen. Saner.AI earns its keep through polish, sync, importers, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Saner.AI. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Saner.AI · indie build 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. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Storage | Markdown files in a folder you choose, one per note, with front matter | the files are the record and everything else in this build is derived from them | | Index | SQLite with FTS5, rebuildable from the folder by one command | a search index is a cache, and treating it as the truth is how a notes app eats a year of writing | | Capture | A global hotkey window that writes the file before it renders anything else | the value of a capture tool is measured in the thoughts it did not lose | | Links | Wiki-style links resolved through an identifier in the front matter | renaming a note must not break every link into it | | AI | Optional, one model behind one interface, answering only with quotes from your notes | with no model configured the app is complete, and that has to be a real mode | | Shell | Tauri 2 with a small web front end | a desktop process that owns a folder, not a server that owns your notes | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Rust toolchain and the Tauri 2 prerequisites** · free - 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. - Verify: cargo --version prints and a blank Tauri 2 application launches - [ ] **Node.js 22 or newer** · free - Why: Builds the front end and runs its tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A SQLite build with FTS5 enabled** · free - 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. - Verify: A CREATE VIRTUAL TABLE USING fts5 statement succeeds - [ ] **A folder of real notes, including the messy ones** · free - 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 - [ ] **A local or hosted model endpoint for the optional answering panel** (optional) · free 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 - [ ] **Your folder location, index refresh interval and retrieval threshold** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Saner.AI, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Saner.AI The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local notes app where capture reaches the disk instantly and the files stay yours to replace Saner.AI. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Storage: Markdown files in a folder you choose, one per note, with front matter · the files are the record and everything else in this build is derived from them - Index: SQLite with FTS5, rebuildable from the folder by one command · a search index is a cache, and treating it as the truth is how a notes app eats a year of writing - Capture: A global hotkey window that writes the file before it renders anything else · the value of a capture tool is measured in the thoughts it did not lose - Links: Wiki-style links resolved through an identifier in the front matter · renaming a note must not break every link into it - AI: Optional, one model behind one interface, answering only with quotes from your notes · with no model configured the app is complete, and that has to be a real mode - Shell: Tauri 2 with a small web front end · a desktop process that owns a folder, not a server that owns your notes ### Data model (create this before Phase 1) - 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 ### Phase 1 · Capture that writes before it draws Build: A global hotkey opens a small window. On save, write a Markdown file with front matter carrying an identifier, a title and a creation time, to a temporary file and then rename it into place. No database write happens in the capture path at all. Measure and record the time from hotkey to a file existing on disk. Trap: 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. Done when: A capture writes a complete file with front matter before the indexer is called, asserted by killing the process immediately after save and finding the file. Writes go to a temporary file and are renamed, so a crash mid-write never leaves a truncated note, tested by injecting a failure. Two captures inside the same second produce two files rather than one overwriting the other. The hotkey window accepts text within a stated budget from a cold start. Do not build yet: No index, no search, no links, no model. ### Phase 2 · An index you can throw away Build: Build an FTS5 external content index over the notes table. Keep it current for writes the app makes, reconcile files changed outside the app by comparing mtime and content hash on start and on file watch events, and provide a full rebuild as one command that the test suite runs. Show the index's age somewhere visible. Trap: 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. Done when: Deleting the database and rebuilding reproduces identical search results, asserted against a fixture folder. A note edited outside the app is re-indexed within a stated interval and the stale result is gone, tested by writing to the file directly. A note deleted outside the app disappears from search and its inbound links become broken rather than pointing at nothing. Rebuilding ten thousand notes completes within a stated time. Do not build yet: No links panel, no model, no sync. ### Phase 3 · Links, backlinks and the note that does not exist yet Build: Resolve wiki-style links through the identifier in the front matter, with the title as the display label and former titles kept as aliases. Compute backlinks at index time and show them beside the note. List unresolved links as a queue of notes waiting to be written, with one key to create the note the link points at. Trap: 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. Done when: Renaming a note leaves every inbound link working, tested by renaming and re-resolving. A link written against a former title resolves through the alias table. Backlinks appear for a note that links to a note that does not exist yet, and creating it from the queue attaches the existing backlinks. Deleting a note turns its inbound links into listed broken links rather than errors. Do not build yet: No model, no export, no sync. ### Phase 4 · An optional model that has to cite Build: With a model configured, chunk notes, embed them, retrieve for a question and answer with inline citations naming the note and offset of every quote used. Refuse to answer when retrieval returns nothing above a stated threshold. Store the question, the answer, the model and the chunk identifiers. With no model configured, the same panel offers search and explains why the answer button is absent. Trap: 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. Done when: A question with no relevant notes produces a refusal naming the threshold, not an answer, tested with an empty corpus. Every answer renders its citations and each citation opens the note at the quoted offset. Removing the model configuration leaves search, links and capture fully working with the reason shown in the panel, tested by unsetting it. Answers are stored with the model name and the chunk identifiers they used. Do not build yet: No export, backup or conflict handling yet. ### Phase 5 · Portability, because this is where notes go to die Build: Version the front matter schema, migrate on open, and keep a fixture note from every past version in the test suite. Sync through the file system and keep both sides of a simultaneous edit as a conflict copy beside the note. Provide a backup command and a delete that removes the file and its derived rows together. Export is the folder itself, and the README says so. Trap: 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. Done when: A note written under every past front matter version opens and migrates, asserted against stored fixtures. A simultaneous edit on two machines leaves both versions on disk and one entry in a conflicts list, tested with two copies of the folder. Deleting a note removes the file and its derived rows and its backlinks become broken rather than dangling. A fresh install pointed at the folder reproduces the full index with no other state. Do not build yet: Nothing after this. ### Out of scope (and why) - Real multi-device sync with merging, which is a distributed systems project and the reason the paid product has a subscription. - Collaborative editing and sharing, which turn a private folder into a service with accounts and permissions. - Native mobile clients, which are a second and a third application with their own release cycles. - Importers for every proprietary note format, which are individually small and collectively endless. ### README must contain - That the Markdown files are the record and the database is a cache you can delete at any time - That an FTS5 external content index does not maintain itself, and how outside edits are reconciled - That links resolve through an identifier in the front matter, so renaming a note breaks nothing - That the model is optional, that an answer without a citation is refused, and what the app does without one - That a sync conflict is kept as a copy beside the note rather than resolved by picking a side ===== AGENTS.md ===== # Agent instructions · Saner.AI indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Markdown files in a folder you choose, one per note, with front matter, SQLite with FTS5, rebuildable from the folder by one command, A global hotkey window that writes the file before it renders anything else, Wiki-style links resolved through an identifier in the front matter, Optional, one model behind one interface, answering only with quotes from your notes, Tauri 2 with a small web front end. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Saner.AI 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Capture that writes before it draws A thought is on disk before the window has finished appearing. ### Steps 1. Write to a temporary file and rename The rename is the only moment the note becomes real. Files: `src-tauri/src/capture.rs`, `tests/atomic_write.rs` 2. Put the identifier in the front matter It is what survives a rename, a move and a reorganisation. Files: `src-tauri/src/frontmatter.rs` 3. Keep the database out of the capture path Indexing is a consequence of a capture, never a precondition. Files: `src-tauri/src/capture.rs` ### Done when - [ ] The file exists before the indexer is called - [ ] A crash mid-write leaves no truncated note - [ ] Two captures in one second make two files - [ ] Cold-start capture meets the stated budget ### Watch 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. ## Phase 2 · An index you can throw away Search is fast, and deleting the database costs nothing but a rebuild. ### Steps 1. Trigger on the app's own writes Cheap, correct, and only half the problem. Files: `src-tauri/src/index/triggers.sql` 2. Reconcile outside edits by mtime and hash The other half, and the one that makes search trustworthy. Files: `src-tauri/src/index/watch.rs`, `tests/external_edit.rs` 3. Make rebuild one command, and run it in tests A rebuild nobody has run is a rebuild that does not work. Files: `src-tauri/src/index/rebuild.rs` ### Done when - [ ] A rebuilt index matches the original results - [ ] An outside edit is re-indexed within the interval - [ ] An outside delete leaves broken links, not phantoms - [ ] Rebuild of ten thousand notes meets its budget ### Watch 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. ## Phase 3 · Links, backlinks and the note that does not exist yet Linking to something unwritten is useful rather than broken. ### Steps 1. Resolve by identifier, display by title The two are different things and conflating them is the bug. Files: `src-tauri/src/links/resolve.rs`, `tests/rename.rs` 2. Keep former titles as aliases People write links by hand and do not update them. Files: `src-tauri/src/links/aliases.rs` 3. Make unresolved links a working queue The gaps are the most useful list in a notes app. Files: `src/ui/Unresolved.tsx` ### Done when - [ ] A rename keeps inbound links working - [ ] A link to a former title resolves - [ ] Creating a missing note inherits its backlinks - [ ] Deleted notes leave listed broken links ### Watch 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. ## Phase 4 · An optional model that has to cite An answer without a quote from your own notes is refused. ### Steps 1. Refuse below the retrieval threshold The refusal is the feature, and it needs a number. Files: `src-tauri/src/ai/answer.rs`, `tests/refusal.rs` 2. Cite to a note and an offset A citation you cannot click is a claim. Files: `src/ui/Answer.tsx` 3. Keep the app complete with no model Tested by unsetting the configuration and running the suite. Files: `src-tauri/src/ai/mod.rs` ### Done when - [ ] An empty corpus produces a refusal, not an answer - [ ] Every citation opens the quoted offset - [ ] The app works fully with no model configured - [ ] Answers store their model and chunk identifiers ### Watch 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. ## Not in this build - Multi-device sync with merging. - Collaborative editing and sharing. - Native mobile clients. - Importers for proprietary note formats. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The folder that is the record. Everything else can be rebuilt from it. NOTES_DIR=~/Documents/notes # Required. Disposable by design. Deleting it triggers a rebuild. INDEX_DB=~/.local/share/notes/index.db # Required. How long to wait after a file changes before re-indexing it. WATCH_DEBOUNCE_MS=500 # Required. Reconcile by mtime and hash at startup. Never assume the index is current. REBUILD_ON_START=hash # Optional. Optional. Absent means search only, which is a complete mode. AI_ENDPOINT=http://localhost:11434 # Optional · secret. Only if the endpoint needs one. Note text is sent there, so choose deliberately. AI_API_KEY=sk-...redacted # Required. Below this, the panel refuses to answer rather than guessing. RETRIEVAL_THRESHOLD=0.35 # Required. Appended to the losing side of a simultaneous edit, which is kept rather than discarded. CONFLICT_SUFFIX=.conflict
You are building a lean indie version of Saner.AI. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Saner.AI · indie build 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. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Storage | Markdown files in a folder you choose, one per note, with front matter | the files are the record and everything else in this build is derived from them | | Index | SQLite with FTS5, rebuildable from the folder by one command | a search index is a cache, and treating it as the truth is how a notes app eats a year of writing | | Capture | A global hotkey window that writes the file before it renders anything else | the value of a capture tool is measured in the thoughts it did not lose | | Links | Wiki-style links resolved through an identifier in the front matter | renaming a note must not break every link into it | | AI | Optional, one model behind one interface, answering only with quotes from your notes | with no model configured the app is complete, and that has to be a real mode | | Shell | Tauri 2 with a small web front end | a desktop process that owns a folder, not a server that owns your notes | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Rust toolchain and the Tauri 2 prerequisites** · free - 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. - Verify: cargo --version prints and a blank Tauri 2 application launches - [ ] **Node.js 22 or newer** · free - Why: Builds the front end and runs its tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A SQLite build with FTS5 enabled** · free - 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. - Verify: A CREATE VIRTUAL TABLE USING fts5 statement succeeds - [ ] **A folder of real notes, including the messy ones** · free - 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 - [ ] **A local or hosted model endpoint for the optional answering panel** (optional) · free 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 - [ ] **Your folder location, index refresh interval and retrieval threshold** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Saner.AI, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Saner.AI The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local notes app where capture reaches the disk instantly and the files stay yours to replace Saner.AI. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Storage: Markdown files in a folder you choose, one per note, with front matter · the files are the record and everything else in this build is derived from them - Index: SQLite with FTS5, rebuildable from the folder by one command · a search index is a cache, and treating it as the truth is how a notes app eats a year of writing - Capture: A global hotkey window that writes the file before it renders anything else · the value of a capture tool is measured in the thoughts it did not lose - Links: Wiki-style links resolved through an identifier in the front matter · renaming a note must not break every link into it - AI: Optional, one model behind one interface, answering only with quotes from your notes · with no model configured the app is complete, and that has to be a real mode - Shell: Tauri 2 with a small web front end · a desktop process that owns a folder, not a server that owns your notes ### Data model (create this before Phase 1) - 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 ### Phase 1 · Capture that writes before it draws Build: A global hotkey opens a small window. On save, write a Markdown file with front matter carrying an identifier, a title and a creation time, to a temporary file and then rename it into place. No database write happens in the capture path at all. Measure and record the time from hotkey to a file existing on disk. Trap: 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. Done when: A capture writes a complete file with front matter before the indexer is called, asserted by killing the process immediately after save and finding the file. Writes go to a temporary file and are renamed, so a crash mid-write never leaves a truncated note, tested by injecting a failure. Two captures inside the same second produce two files rather than one overwriting the other. The hotkey window accepts text within a stated budget from a cold start. Do not build yet: No index, no search, no links, no model. ### Phase 2 · An index you can throw away Build: Build an FTS5 external content index over the notes table. Keep it current for writes the app makes, reconcile files changed outside the app by comparing mtime and content hash on start and on file watch events, and provide a full rebuild as one command that the test suite runs. Show the index's age somewhere visible. Trap: 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. Done when: Deleting the database and rebuilding reproduces identical search results, asserted against a fixture folder. A note edited outside the app is re-indexed within a stated interval and the stale result is gone, tested by writing to the file directly. A note deleted outside the app disappears from search and its inbound links become broken rather than pointing at nothing. Rebuilding ten thousand notes completes within a stated time. Do not build yet: No links panel, no model, no sync. ### Phase 3 · Links, backlinks and the note that does not exist yet Build: Resolve wiki-style links through the identifier in the front matter, with the title as the display label and former titles kept as aliases. Compute backlinks at index time and show them beside the note. List unresolved links as a queue of notes waiting to be written, with one key to create the note the link points at. Trap: 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. Done when: Renaming a note leaves every inbound link working, tested by renaming and re-resolving. A link written against a former title resolves through the alias table. Backlinks appear for a note that links to a note that does not exist yet, and creating it from the queue attaches the existing backlinks. Deleting a note turns its inbound links into listed broken links rather than errors. Do not build yet: No model, no export, no sync. ### Phase 4 · An optional model that has to cite Build: With a model configured, chunk notes, embed them, retrieve for a question and answer with inline citations naming the note and offset of every quote used. Refuse to answer when retrieval returns nothing above a stated threshold. Store the question, the answer, the model and the chunk identifiers. With no model configured, the same panel offers search and explains why the answer button is absent. Trap: 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. Done when: A question with no relevant notes produces a refusal naming the threshold, not an answer, tested with an empty corpus. Every answer renders its citations and each citation opens the note at the quoted offset. Removing the model configuration leaves search, links and capture fully working with the reason shown in the panel, tested by unsetting it. Answers are stored with the model name and the chunk identifiers they used. Do not build yet: No export, backup or conflict handling yet. ### Phase 5 · Portability, because this is where notes go to die Build: Version the front matter schema, migrate on open, and keep a fixture note from every past version in the test suite. Sync through the file system and keep both sides of a simultaneous edit as a conflict copy beside the note. Provide a backup command and a delete that removes the file and its derived rows together. Export is the folder itself, and the README says so. Trap: 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. Done when: A note written under every past front matter version opens and migrates, asserted against stored fixtures. A simultaneous edit on two machines leaves both versions on disk and one entry in a conflicts list, tested with two copies of the folder. Deleting a note removes the file and its derived rows and its backlinks become broken rather than dangling. A fresh install pointed at the folder reproduces the full index with no other state. Do not build yet: Nothing after this. ### Out of scope (and why) - Real multi-device sync with merging, which is a distributed systems project and the reason the paid product has a subscription. - Collaborative editing and sharing, which turn a private folder into a service with accounts and permissions. - Native mobile clients, which are a second and a third application with their own release cycles. - Importers for every proprietary note format, which are individually small and collectively endless. ### README must contain - That the Markdown files are the record and the database is a cache you can delete at any time - That an FTS5 external content index does not maintain itself, and how outside edits are reconciled - That links resolve through an identifier in the front matter, so renaming a note breaks nothing - That the model is optional, that an answer without a citation is refused, and what the app does without one - That a sync conflict is kept as a copy beside the note rather than resolved by picking a side ===== AGENTS.md ===== # Agent instructions · Saner.AI indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Markdown files in a folder you choose, one per note, with front matter, SQLite with FTS5, rebuildable from the folder by one command, A global hotkey window that writes the file before it renders anything else, Wiki-style links resolved through an identifier in the front matter, Optional, one model behind one interface, answering only with quotes from your notes, Tauri 2 with a small web front end. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · Saner.AI 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Capture that writes before it draws A thought is on disk before the window has finished appearing. ### Steps 1. Write to a temporary file and rename The rename is the only moment the note becomes real. Files: `src-tauri/src/capture.rs`, `tests/atomic_write.rs` 2. Put the identifier in the front matter It is what survives a rename, a move and a reorganisation. Files: `src-tauri/src/frontmatter.rs` 3. Keep the database out of the capture path Indexing is a consequence of a capture, never a precondition. Files: `src-tauri/src/capture.rs` ### Done when - [ ] The file exists before the indexer is called - [ ] A crash mid-write leaves no truncated note - [ ] Two captures in one second make two files - [ ] Cold-start capture meets the stated budget ### Watch 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. ## Phase 2 · An index you can throw away Search is fast, and deleting the database costs nothing but a rebuild. ### Steps 1. Trigger on the app's own writes Cheap, correct, and only half the problem. Files: `src-tauri/src/index/triggers.sql` 2. Reconcile outside edits by mtime and hash The other half, and the one that makes search trustworthy. Files: `src-tauri/src/index/watch.rs`, `tests/external_edit.rs` 3. Make rebuild one command, and run it in tests A rebuild nobody has run is a rebuild that does not work. Files: `src-tauri/src/index/rebuild.rs` ### Done when - [ ] A rebuilt index matches the original results - [ ] An outside edit is re-indexed within the interval - [ ] An outside delete leaves broken links, not phantoms - [ ] Rebuild of ten thousand notes meets its budget ### Watch 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. ## Phase 3 · Links, backlinks and the note that does not exist yet Linking to something unwritten is useful rather than broken. ### Steps 1. Resolve by identifier, display by title The two are different things and conflating them is the bug. Files: `src-tauri/src/links/resolve.rs`, `tests/rename.rs` 2. Keep former titles as aliases People write links by hand and do not update them. Files: `src-tauri/src/links/aliases.rs` 3. Make unresolved links a working queue The gaps are the most useful list in a notes app. Files: `src/ui/Unresolved.tsx` ### Done when - [ ] A rename keeps inbound links working - [ ] A link to a former title resolves - [ ] Creating a missing note inherits its backlinks - [ ] Deleted notes leave listed broken links ### Watch 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. ## Phase 4 · An optional model that has to cite An answer without a quote from your own notes is refused. ### Steps 1. Refuse below the retrieval threshold The refusal is the feature, and it needs a number. Files: `src-tauri/src/ai/answer.rs`, `tests/refusal.rs` 2. Cite to a note and an offset A citation you cannot click is a claim. Files: `src/ui/Answer.tsx` 3. Keep the app complete with no model Tested by unsetting the configuration and running the suite. Files: `src-tauri/src/ai/mod.rs` ### Done when - [ ] An empty corpus produces a refusal, not an answer - [ ] Every citation opens the quoted offset - [ ] The app works fully with no model configured - [ ] Answers store their model and chunk identifiers ### Watch 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. ## Not in this build - Multi-device sync with merging. - Collaborative editing and sharing. - Native mobile clients. - Importers for proprietary note formats. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The folder that is the record. Everything else can be rebuilt from it. NOTES_DIR=~/Documents/notes # Required. Disposable by design. Deleting it triggers a rebuild. INDEX_DB=~/.local/share/notes/index.db # Required. How long to wait after a file changes before re-indexing it. WATCH_DEBOUNCE_MS=500 # Required. Reconcile by mtime and hash at startup. Never assume the index is current. REBUILD_ON_START=hash # Optional. Optional. Absent means search only, which is a complete mode. AI_ENDPOINT=http://localhost:11434 # Optional · secret. Only if the endpoint needs one. Note text is sent there, so choose deliberately. AI_API_KEY=sk-...redacted # Required. Below this, the panel refuses to answer rather than guessing. RETRIEVAL_THRESHOLD=0.35 # Required. Appended to the losing side of a simultaneous edit, which is kept rather than discarded. CONFLICT_SUFFIX=.conflict
You are building a production product version of Saner.AI. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Saner.AI · product brief ## Problem The visible AI personal knowledge loop is buildable, but a credible replacement needs more than the first screen. Saner.AI earns its keep through polish, sync, importers, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A notes app where a captured thought is on disk before anything can lose it, search tells the truth about files edited anywhere, renaming breaks nothing, the model refuses rather than invents, and the folder alone is enough to rebuild the whole thing. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - desktop development machine - Node.js 22 and Rust toolchain - local filesystem access ## Explicit non-goals for v1 - 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 ## Success criteria - A capture is confirmed on disk before the indexer runs - A rebuilt index is confirmed to match the original results - An outside edit is confirmed re-indexed within the interval - The app is confirmed complete with no model configured - Every past front matter version is confirmed to open and migrate ===== BRIEF.md ===== # Build brief · Saner.AI The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local notes app where capture reaches the disk instantly and the files stay yours to replace Saner.AI. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Storage: Markdown files in a folder you choose, one per note, with front matter · the files are the record and everything else in this build is derived from them - Index: SQLite with FTS5, rebuildable from the folder by one command · a search index is a cache, and treating it as the truth is how a notes app eats a year of writing - Capture: A global hotkey window that writes the file before it renders anything else · the value of a capture tool is measured in the thoughts it did not lose - Links: Wiki-style links resolved through an identifier in the front matter · renaming a note must not break every link into it - AI: Optional, one model behind one interface, answering only with quotes from your notes · with no model configured the app is complete, and that has to be a real mode - Shell: Tauri 2 with a small web front end · a desktop process that owns a folder, not a server that owns your notes ### Data model (create this before Phase 1) - 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 ### Phase 1 · Capture that writes before it draws Build: A global hotkey opens a small window. On save, write a Markdown file with front matter carrying an identifier, a title and a creation time, to a temporary file and then rename it into place. No database write happens in the capture path at all. Measure and record the time from hotkey to a file existing on disk. Trap: 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. Done when: A capture writes a complete file with front matter before the indexer is called, asserted by killing the process immediately after save and finding the file. Writes go to a temporary file and are renamed, so a crash mid-write never leaves a truncated note, tested by injecting a failure. Two captures inside the same second produce two files rather than one overwriting the other. The hotkey window accepts text within a stated budget from a cold start. Do not build yet: No index, no search, no links, no model. ### Phase 2 · An index you can throw away Build: Build an FTS5 external content index over the notes table. Keep it current for writes the app makes, reconcile files changed outside the app by comparing mtime and content hash on start and on file watch events, and provide a full rebuild as one command that the test suite runs. Show the index's age somewhere visible. Trap: 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. Done when: Deleting the database and rebuilding reproduces identical search results, asserted against a fixture folder. A note edited outside the app is re-indexed within a stated interval and the stale result is gone, tested by writing to the file directly. A note deleted outside the app disappears from search and its inbound links become broken rather than pointing at nothing. Rebuilding ten thousand notes completes within a stated time. Do not build yet: No links panel, no model, no sync. ### Phase 3 · Links, backlinks and the note that does not exist yet Build: Resolve wiki-style links through the identifier in the front matter, with the title as the display label and former titles kept as aliases. Compute backlinks at index time and show them beside the note. List unresolved links as a queue of notes waiting to be written, with one key to create the note the link points at. Trap: 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. Done when: Renaming a note leaves every inbound link working, tested by renaming and re-resolving. A link written against a former title resolves through the alias table. Backlinks appear for a note that links to a note that does not exist yet, and creating it from the queue attaches the existing backlinks. Deleting a note turns its inbound links into listed broken links rather than errors. Do not build yet: No model, no export, no sync. ### Phase 4 · An optional model that has to cite Build: With a model configured, chunk notes, embed them, retrieve for a question and answer with inline citations naming the note and offset of every quote used. Refuse to answer when retrieval returns nothing above a stated threshold. Store the question, the answer, the model and the chunk identifiers. With no model configured, the same panel offers search and explains why the answer button is absent. Trap: 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. Done when: A question with no relevant notes produces a refusal naming the threshold, not an answer, tested with an empty corpus. Every answer renders its citations and each citation opens the note at the quoted offset. Removing the model configuration leaves search, links and capture fully working with the reason shown in the panel, tested by unsetting it. Answers are stored with the model name and the chunk identifiers they used. Do not build yet: No export, backup or conflict handling yet. ### Phase 5 · Portability, because this is where notes go to die Build: Version the front matter schema, migrate on open, and keep a fixture note from every past version in the test suite. Sync through the file system and keep both sides of a simultaneous edit as a conflict copy beside the note. Provide a backup command and a delete that removes the file and its derived rows together. Export is the folder itself, and the README says so. Trap: 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. Done when: A note written under every past front matter version opens and migrates, asserted against stored fixtures. A simultaneous edit on two machines leaves both versions on disk and one entry in a conflicts list, tested with two copies of the folder. Deleting a note removes the file and its derived rows and its backlinks become broken rather than dangling. A fresh install pointed at the folder reproduces the full index with no other state. Do not build yet: Nothing after this. ### Out of scope (and why) - Real multi-device sync with merging, which is a distributed systems project and the reason the paid product has a subscription. - Collaborative editing and sharing, which turn a private folder into a service with accounts and permissions. - Native mobile clients, which are a second and a third application with their own release cycles. - Importers for every proprietary note format, which are individually small and collectively endless. ### README must contain - That the Markdown files are the record and the database is a cache you can delete at any time - That an FTS5 external content index does not maintain itself, and how outside edits are reconciled - That links resolve through an identifier in the front matter, so renaming a note breaks nothing - That the model is optional, that an answer without a citation is refused, and what the app does without one - That a sync conflict is kept as a copy beside the note rather than resolved by picking a side ===== ARCHITECTURE.md ===== # Architecture · Saner.AI ## Stack | Part | Choice | Why | | --- | --- | --- | | Storage | Markdown files in a folder you choose, one per note, with front matter | the files are the record and everything else in this build is derived from them | | Index | SQLite with FTS5, rebuildable from the folder by one command | a search index is a cache, and treating it as the truth is how a notes app eats a year of writing | | Capture | A global hotkey window that writes the file before it renders anything else | the value of a capture tool is measured in the thoughts it did not lose | | Links | Wiki-style links resolved through an identifier in the front matter | renaming a note must not break every link into it | | AI | Optional, one model behind one interface, answering only with quotes from your notes | with no model configured the app is complete, and that has to be a real mode | | Shell | Tauri 2 with a small web front end | a desktop process that owns a folder, not a server that owns your notes | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Capture | Atomic file writes with front matter. | Any editor surface, provided the file is written before anything else happens. | | Index | FTS5 maintenance and reconciliation. | Any index, provided it is rebuildable from the folder by one command. | | Links | Identifier resolution, aliases and backlinks. | Any syntax, provided resolution never depends on the file name. | | Answers | Retrieval, citation and refusal. | Any model, provided an answer below the threshold is refused rather than produced. | | Portability | Schema migration, conflicts, backup and deletion. | Any sync transport, provided both sides of a conflict survive. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `NOTES_DIR` · required · The folder that is the record. Everything else can be rebuilt from it. - `INDEX_DB` · required · Disposable by design. Deleting it triggers a rebuild. - `WATCH_DEBOUNCE_MS` · required · How long to wait after a file changes before re-indexing it. - `REBUILD_ON_START` · required · Reconcile by mtime and hash at startup. Never assume the index is current. - `AI_ENDPOINT` · optional · Optional. Absent means search only, which is a complete mode. - `AI_API_KEY` · optional, secret · Only if the endpoint needs one. Note text is sent there, so choose deliberately. - `RETRIEVAL_THRESHOLD` · required · Below this, the panel refuses to answer rather than guessing. - `CONFLICT_SUFFIX` · required · Appended to the losing side of a simultaneous edit, which is kept rather than discarded. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Saner.AI product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Markdown files in a folder you choose, one per note, with front matter, SQLite with FTS5, rebuildable from the folder by one command, A global hotkey window that writes the file before it renders anything else, Wiki-style links resolved through an identifier in the front matter, Optional, one model behind one interface, answering only with quotes from your notes, Tauri 2 with a small web front end. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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 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. - 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. - 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. - 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. ===== MILESTONES.md ===== # Delivery milestones · Saner.AI Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Capture that writes before it draws A thought is on disk before the window has finished appearing. ### Steps 1. Write to a temporary file and rename The rename is the only moment the note becomes real. Files: `src-tauri/src/capture.rs`, `tests/atomic_write.rs` 2. Put the identifier in the front matter It is what survives a rename, a move and a reorganisation. Files: `src-tauri/src/frontmatter.rs` 3. Keep the database out of the capture path Indexing is a consequence of a capture, never a precondition. Files: `src-tauri/src/capture.rs` ### Done when - [ ] The file exists before the indexer is called - [ ] A crash mid-write leaves no truncated note - [ ] Two captures in one second make two files - [ ] Cold-start capture meets the stated budget ### Watch 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. ## M2 · An index you can throw away Search is fast, and deleting the database costs nothing but a rebuild. ### Steps 1. Trigger on the app's own writes Cheap, correct, and only half the problem. Files: `src-tauri/src/index/triggers.sql` 2. Reconcile outside edits by mtime and hash The other half, and the one that makes search trustworthy. Files: `src-tauri/src/index/watch.rs`, `tests/external_edit.rs` 3. Make rebuild one command, and run it in tests A rebuild nobody has run is a rebuild that does not work. Files: `src-tauri/src/index/rebuild.rs` ### Done when - [ ] A rebuilt index matches the original results - [ ] An outside edit is re-indexed within the interval - [ ] An outside delete leaves broken links, not phantoms - [ ] Rebuild of ten thousand notes meets its budget ### Watch 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. ## M3 · Links, backlinks and the note that does not exist yet Linking to something unwritten is useful rather than broken. ### Steps 1. Resolve by identifier, display by title The two are different things and conflating them is the bug. Files: `src-tauri/src/links/resolve.rs`, `tests/rename.rs` 2. Keep former titles as aliases People write links by hand and do not update them. Files: `src-tauri/src/links/aliases.rs` 3. Make unresolved links a working queue The gaps are the most useful list in a notes app. Files: `src/ui/Unresolved.tsx` ### Done when - [ ] A rename keeps inbound links working - [ ] A link to a former title resolves - [ ] Creating a missing note inherits its backlinks - [ ] Deleted notes leave listed broken links ### Watch 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. ## M4 · An optional model that has to cite An answer without a quote from your own notes is refused. ### Steps 1. Refuse below the retrieval threshold The refusal is the feature, and it needs a number. Files: `src-tauri/src/ai/answer.rs`, `tests/refusal.rs` 2. Cite to a note and an offset A citation you cannot click is a claim. Files: `src/ui/Answer.tsx` 3. Keep the app complete with no model Tested by unsetting the configuration and running the suite. Files: `src-tauri/src/ai/mod.rs` ### Done when - [ ] An empty corpus produces a refusal, not an answer - [ ] Every citation opens the quoted offset - [ ] The app works fully with no model configured - [ ] Answers store their model and chunk identifiers ### Watch 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. ## M5 · Portability, because this is where notes go to die (production only) Leaving costs nothing, and files written years ago still open. ### Steps 1. Version the front matter and keep fixtures Every past version, in the suite, forever. Files: `src-tauri/src/migrate.rs`, `tests/fixtures/` 2. Keep both sides of a conflict A merge that picks a winner loses a paragraph silently. Files: `src-tauri/src/sync/conflict.rs` 3. Prove a fresh install rebuilds from the folder That is the whole claim, so it is a test rather than a sentence. Files: `tests/fresh_install.rs` ### Done when - [ ] Every past front matter version opens and migrates - [ ] A simultaneous edit leaves both sides on disk - [ ] Deletion removes file and derived rows together - [ ] A fresh install rebuilds everything from the folder ### Watch 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. ===== OPERATIONS.md ===== # Operations · Saner.AI ## Backup Back up the folder. The index is deliberately disposable and the conflict copies are part of the record, so a backup that excludes files with the conflict suffix is a backup missing the paragraphs somebody is looking for. ## Restore Restore the folder, point a fresh install at it and let the index rebuild. The restore has succeeded when search results match the pre-restore fixture, which is the test the suite already runs. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a desktop tool the checks live in the suite: the index reconciling an outside edit, a rebuild matching the original results, every past front matter version opening, and the cold capture time on the largest folder. ## Incident checklist A search result that opens the wrong text means reconciliation is failing, and the index age shown in the interface is the first thing to look at. A note that seems to have vanished is usually a conflict copy, which is why conflicts are a list rather than a silent rename. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A capture is confirmed on disk before the indexer runs - [ ] A rebuilt index is confirmed to match the original results - [ ] An outside edit is confirmed re-indexed within the interval - [ ] The app is confirmed complete with no model configured - [ ] Every past front matter version is confirmed to open and migrate ## Launch constraint Do not market omitted Saner.AI capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The folder that is the record. Everything else can be rebuilt from it. NOTES_DIR=~/Documents/notes # Required. Disposable by design. Deleting it triggers a rebuild. INDEX_DB=~/.local/share/notes/index.db # Required. How long to wait after a file changes before re-indexing it. WATCH_DEBOUNCE_MS=500 # Required. Reconcile by mtime and hash at startup. Never assume the index is current. REBUILD_ON_START=hash # Optional. Optional. Absent means search only, which is a complete mode. AI_ENDPOINT=http://localhost:11434 # Optional · secret. Only if the endpoint needs one. Note text is sent there, so choose deliberately. AI_API_KEY=sk-...redacted # Required. Below this, the panel refuses to answer rather than guessing. RETRIEVAL_THRESHOLD=0.35 # Required. Appended to the losing side of a simultaneous edit, which is kept rather than discarded. CONFLICT_SUFFIX=.conflict
# Saner.AI · indie build 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. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Storage | Markdown files in a folder you choose, one per note, with front matter | the files are the record and everything else in this build is derived from them | | Index | SQLite with FTS5, rebuildable from the folder by one command | a search index is a cache, and treating it as the truth is how a notes app eats a year of writing | | Capture | A global hotkey window that writes the file before it renders anything else | the value of a capture tool is measured in the thoughts it did not lose | | Links | Wiki-style links resolved through an identifier in the front matter | renaming a note must not break every link into it | | AI | Optional, one model behind one interface, answering only with quotes from your notes | with no model configured the app is complete, and that has to be a real mode | | Shell | Tauri 2 with a small web front end | a desktop process that owns a folder, not a server that owns your notes | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Rust toolchain and the Tauri 2 prerequisites** · free - 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. - Verify: cargo --version prints and a blank Tauri 2 application launches - [ ] **Node.js 22 or newer** · free - Why: Builds the front end and runs its tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A SQLite build with FTS5 enabled** · free - 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. - Verify: A CREATE VIRTUAL TABLE USING fts5 statement succeeds - [ ] **A folder of real notes, including the messy ones** · free - 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 - [ ] **A local or hosted model endpoint for the optional answering panel** (optional) · free 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 - [ ] **Your folder location, index refresh interval and retrieval threshold** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for Saner.AI, and the README should say so rather than pretend.
# Build brief · Saner.AI The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local notes app where capture reaches the disk instantly and the files stay yours to replace Saner.AI. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Storage: Markdown files in a folder you choose, one per note, with front matter · the files are the record and everything else in this build is derived from them - Index: SQLite with FTS5, rebuildable from the folder by one command · a search index is a cache, and treating it as the truth is how a notes app eats a year of writing - Capture: A global hotkey window that writes the file before it renders anything else · the value of a capture tool is measured in the thoughts it did not lose - Links: Wiki-style links resolved through an identifier in the front matter · renaming a note must not break every link into it - AI: Optional, one model behind one interface, answering only with quotes from your notes · with no model configured the app is complete, and that has to be a real mode - Shell: Tauri 2 with a small web front end · a desktop process that owns a folder, not a server that owns your notes ### Data model (create this before Phase 1) - 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 ### Phase 1 · Capture that writes before it draws Build: A global hotkey opens a small window. On save, write a Markdown file with front matter carrying an identifier, a title and a creation time, to a temporary file and then rename it into place. No database write happens in the capture path at all. Measure and record the time from hotkey to a file existing on disk. Trap: 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. Done when: A capture writes a complete file with front matter before the indexer is called, asserted by killing the process immediately after save and finding the file. Writes go to a temporary file and are renamed, so a crash mid-write never leaves a truncated note, tested by injecting a failure. Two captures inside the same second produce two files rather than one overwriting the other. The hotkey window accepts text within a stated budget from a cold start. Do not build yet: No index, no search, no links, no model. ### Phase 2 · An index you can throw away Build: Build an FTS5 external content index over the notes table. Keep it current for writes the app makes, reconcile files changed outside the app by comparing mtime and content hash on start and on file watch events, and provide a full rebuild as one command that the test suite runs. Show the index's age somewhere visible. Trap: 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. Done when: Deleting the database and rebuilding reproduces identical search results, asserted against a fixture folder. A note edited outside the app is re-indexed within a stated interval and the stale result is gone, tested by writing to the file directly. A note deleted outside the app disappears from search and its inbound links become broken rather than pointing at nothing. Rebuilding ten thousand notes completes within a stated time. Do not build yet: No links panel, no model, no sync. ### Phase 3 · Links, backlinks and the note that does not exist yet Build: Resolve wiki-style links through the identifier in the front matter, with the title as the display label and former titles kept as aliases. Compute backlinks at index time and show them beside the note. List unresolved links as a queue of notes waiting to be written, with one key to create the note the link points at. Trap: 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. Done when: Renaming a note leaves every inbound link working, tested by renaming and re-resolving. A link written against a former title resolves through the alias table. Backlinks appear for a note that links to a note that does not exist yet, and creating it from the queue attaches the existing backlinks. Deleting a note turns its inbound links into listed broken links rather than errors. Do not build yet: No model, no export, no sync. ### Phase 4 · An optional model that has to cite Build: With a model configured, chunk notes, embed them, retrieve for a question and answer with inline citations naming the note and offset of every quote used. Refuse to answer when retrieval returns nothing above a stated threshold. Store the question, the answer, the model and the chunk identifiers. With no model configured, the same panel offers search and explains why the answer button is absent. Trap: 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. Done when: A question with no relevant notes produces a refusal naming the threshold, not an answer, tested with an empty corpus. Every answer renders its citations and each citation opens the note at the quoted offset. Removing the model configuration leaves search, links and capture fully working with the reason shown in the panel, tested by unsetting it. Answers are stored with the model name and the chunk identifiers they used. Do not build yet: No export, backup or conflict handling yet. ### Phase 5 · Portability, because this is where notes go to die Build: Version the front matter schema, migrate on open, and keep a fixture note from every past version in the test suite. Sync through the file system and keep both sides of a simultaneous edit as a conflict copy beside the note. Provide a backup command and a delete that removes the file and its derived rows together. Export is the folder itself, and the README says so. Trap: 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. Done when: A note written under every past front matter version opens and migrates, asserted against stored fixtures. A simultaneous edit on two machines leaves both versions on disk and one entry in a conflicts list, tested with two copies of the folder. Deleting a note removes the file and its derived rows and its backlinks become broken rather than dangling. A fresh install pointed at the folder reproduces the full index with no other state. Do not build yet: Nothing after this. ### Out of scope (and why) - Real multi-device sync with merging, which is a distributed systems project and the reason the paid product has a subscription. - Collaborative editing and sharing, which turn a private folder into a service with accounts and permissions. - Native mobile clients, which are a second and a third application with their own release cycles. - Importers for every proprietary note format, which are individually small and collectively endless. ### README must contain - That the Markdown files are the record and the database is a cache you can delete at any time - That an FTS5 external content index does not maintain itself, and how outside edits are reconciled - That links resolve through an identifier in the front matter, so renaming a note breaks nothing - That the model is optional, that an answer without a citation is refused, and what the app does without one - That a sync conflict is kept as a copy beside the note rather than resolved by picking a side
# Agent instructions · Saner.AI indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Markdown files in a folder you choose, one per note, with front matter, SQLite with FTS5, rebuildable from the folder by one command, A global hotkey window that writes the file before it renders anything else, Wiki-style links resolved through an identifier in the front matter, Optional, one model behind one interface, answering only with quotes from your notes, Tauri 2 with a small web front end. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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 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. - 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. - 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.
# Build plan · Saner.AI 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Capture that writes before it draws A thought is on disk before the window has finished appearing. ### Steps 1. Write to a temporary file and rename The rename is the only moment the note becomes real. Files: `src-tauri/src/capture.rs`, `tests/atomic_write.rs` 2. Put the identifier in the front matter It is what survives a rename, a move and a reorganisation. Files: `src-tauri/src/frontmatter.rs` 3. Keep the database out of the capture path Indexing is a consequence of a capture, never a precondition. Files: `src-tauri/src/capture.rs` ### Done when - [ ] The file exists before the indexer is called - [ ] A crash mid-write leaves no truncated note - [ ] Two captures in one second make two files - [ ] Cold-start capture meets the stated budget ### Watch 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. ## Phase 2 · An index you can throw away Search is fast, and deleting the database costs nothing but a rebuild. ### Steps 1. Trigger on the app's own writes Cheap, correct, and only half the problem. Files: `src-tauri/src/index/triggers.sql` 2. Reconcile outside edits by mtime and hash The other half, and the one that makes search trustworthy. Files: `src-tauri/src/index/watch.rs`, `tests/external_edit.rs` 3. Make rebuild one command, and run it in tests A rebuild nobody has run is a rebuild that does not work. Files: `src-tauri/src/index/rebuild.rs` ### Done when - [ ] A rebuilt index matches the original results - [ ] An outside edit is re-indexed within the interval - [ ] An outside delete leaves broken links, not phantoms - [ ] Rebuild of ten thousand notes meets its budget ### Watch 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. ## Phase 3 · Links, backlinks and the note that does not exist yet Linking to something unwritten is useful rather than broken. ### Steps 1. Resolve by identifier, display by title The two are different things and conflating them is the bug. Files: `src-tauri/src/links/resolve.rs`, `tests/rename.rs` 2. Keep former titles as aliases People write links by hand and do not update them. Files: `src-tauri/src/links/aliases.rs` 3. Make unresolved links a working queue The gaps are the most useful list in a notes app. Files: `src/ui/Unresolved.tsx` ### Done when - [ ] A rename keeps inbound links working - [ ] A link to a former title resolves - [ ] Creating a missing note inherits its backlinks - [ ] Deleted notes leave listed broken links ### Watch 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. ## Phase 4 · An optional model that has to cite An answer without a quote from your own notes is refused. ### Steps 1. Refuse below the retrieval threshold The refusal is the feature, and it needs a number. Files: `src-tauri/src/ai/answer.rs`, `tests/refusal.rs` 2. Cite to a note and an offset A citation you cannot click is a claim. Files: `src/ui/Answer.tsx` 3. Keep the app complete with no model Tested by unsetting the configuration and running the suite. Files: `src-tauri/src/ai/mod.rs` ### Done when - [ ] An empty corpus produces a refusal, not an answer - [ ] Every citation opens the quoted offset - [ ] The app works fully with no model configured - [ ] Answers store their model and chunk identifiers ### Watch 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. ## Not in this build - Multi-device sync with merging. - Collaborative editing and sharing. - Native mobile clients. - Importers for proprietary note formats. ## After v1, if you want it - 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
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The folder that is the record. Everything else can be rebuilt from it. NOTES_DIR=~/Documents/notes # Required. Disposable by design. Deleting it triggers a rebuild. INDEX_DB=~/.local/share/notes/index.db # Required. How long to wait after a file changes before re-indexing it. WATCH_DEBOUNCE_MS=500 # Required. Reconcile by mtime and hash at startup. Never assume the index is current. REBUILD_ON_START=hash # Optional. Optional. Absent means search only, which is a complete mode. AI_ENDPOINT=http://localhost:11434 # Optional · secret. Only if the endpoint needs one. Note text is sent there, so choose deliberately. AI_API_KEY=sk-...redacted # Required. Below this, the panel refuses to answer rather than guessing. RETRIEVAL_THRESHOLD=0.35 # Required. Appended to the losing side of a simultaneous edit, which is kept rather than discarded. CONFLICT_SUFFIX=.conflict
# Saner.AI · product brief ## Problem The visible AI personal knowledge loop is buildable, but a credible replacement needs more than the first screen. Saner.AI earns its keep through polish, sync, importers, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A notes app where a captured thought is on disk before anything can lose it, search tells the truth about files edited anywhere, renaming breaks nothing, the model refuses rather than invents, and the folder alone is enough to rebuild the whole thing. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - desktop development machine - Node.js 22 and Rust toolchain - local filesystem access ## Explicit non-goals for v1 - 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 ## Success criteria - A capture is confirmed on disk before the indexer runs - A rebuilt index is confirmed to match the original results - An outside edit is confirmed re-indexed within the interval - The app is confirmed complete with no model configured - Every past front matter version is confirmed to open and migrate
# Build brief · Saner.AI The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local notes app where capture reaches the disk instantly and the files stay yours to replace Saner.AI. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Storage: Markdown files in a folder you choose, one per note, with front matter · the files are the record and everything else in this build is derived from them - Index: SQLite with FTS5, rebuildable from the folder by one command · a search index is a cache, and treating it as the truth is how a notes app eats a year of writing - Capture: A global hotkey window that writes the file before it renders anything else · the value of a capture tool is measured in the thoughts it did not lose - Links: Wiki-style links resolved through an identifier in the front matter · renaming a note must not break every link into it - AI: Optional, one model behind one interface, answering only with quotes from your notes · with no model configured the app is complete, and that has to be a real mode - Shell: Tauri 2 with a small web front end · a desktop process that owns a folder, not a server that owns your notes ### Data model (create this before Phase 1) - 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 ### Phase 1 · Capture that writes before it draws Build: A global hotkey opens a small window. On save, write a Markdown file with front matter carrying an identifier, a title and a creation time, to a temporary file and then rename it into place. No database write happens in the capture path at all. Measure and record the time from hotkey to a file existing on disk. Trap: 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. Done when: A capture writes a complete file with front matter before the indexer is called, asserted by killing the process immediately after save and finding the file. Writes go to a temporary file and are renamed, so a crash mid-write never leaves a truncated note, tested by injecting a failure. Two captures inside the same second produce two files rather than one overwriting the other. The hotkey window accepts text within a stated budget from a cold start. Do not build yet: No index, no search, no links, no model. ### Phase 2 · An index you can throw away Build: Build an FTS5 external content index over the notes table. Keep it current for writes the app makes, reconcile files changed outside the app by comparing mtime and content hash on start and on file watch events, and provide a full rebuild as one command that the test suite runs. Show the index's age somewhere visible. Trap: 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. Done when: Deleting the database and rebuilding reproduces identical search results, asserted against a fixture folder. A note edited outside the app is re-indexed within a stated interval and the stale result is gone, tested by writing to the file directly. A note deleted outside the app disappears from search and its inbound links become broken rather than pointing at nothing. Rebuilding ten thousand notes completes within a stated time. Do not build yet: No links panel, no model, no sync. ### Phase 3 · Links, backlinks and the note that does not exist yet Build: Resolve wiki-style links through the identifier in the front matter, with the title as the display label and former titles kept as aliases. Compute backlinks at index time and show them beside the note. List unresolved links as a queue of notes waiting to be written, with one key to create the note the link points at. Trap: 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. Done when: Renaming a note leaves every inbound link working, tested by renaming and re-resolving. A link written against a former title resolves through the alias table. Backlinks appear for a note that links to a note that does not exist yet, and creating it from the queue attaches the existing backlinks. Deleting a note turns its inbound links into listed broken links rather than errors. Do not build yet: No model, no export, no sync. ### Phase 4 · An optional model that has to cite Build: With a model configured, chunk notes, embed them, retrieve for a question and answer with inline citations naming the note and offset of every quote used. Refuse to answer when retrieval returns nothing above a stated threshold. Store the question, the answer, the model and the chunk identifiers. With no model configured, the same panel offers search and explains why the answer button is absent. Trap: 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. Done when: A question with no relevant notes produces a refusal naming the threshold, not an answer, tested with an empty corpus. Every answer renders its citations and each citation opens the note at the quoted offset. Removing the model configuration leaves search, links and capture fully working with the reason shown in the panel, tested by unsetting it. Answers are stored with the model name and the chunk identifiers they used. Do not build yet: No export, backup or conflict handling yet. ### Phase 5 · Portability, because this is where notes go to die Build: Version the front matter schema, migrate on open, and keep a fixture note from every past version in the test suite. Sync through the file system and keep both sides of a simultaneous edit as a conflict copy beside the note. Provide a backup command and a delete that removes the file and its derived rows together. Export is the folder itself, and the README says so. Trap: 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. Done when: A note written under every past front matter version opens and migrates, asserted against stored fixtures. A simultaneous edit on two machines leaves both versions on disk and one entry in a conflicts list, tested with two copies of the folder. Deleting a note removes the file and its derived rows and its backlinks become broken rather than dangling. A fresh install pointed at the folder reproduces the full index with no other state. Do not build yet: Nothing after this. ### Out of scope (and why) - Real multi-device sync with merging, which is a distributed systems project and the reason the paid product has a subscription. - Collaborative editing and sharing, which turn a private folder into a service with accounts and permissions. - Native mobile clients, which are a second and a third application with their own release cycles. - Importers for every proprietary note format, which are individually small and collectively endless. ### README must contain - That the Markdown files are the record and the database is a cache you can delete at any time - That an FTS5 external content index does not maintain itself, and how outside edits are reconciled - That links resolve through an identifier in the front matter, so renaming a note breaks nothing - That the model is optional, that an answer without a citation is refused, and what the app does without one - That a sync conflict is kept as a copy beside the note rather than resolved by picking a side
# Architecture · Saner.AI ## Stack | Part | Choice | Why | | --- | --- | --- | | Storage | Markdown files in a folder you choose, one per note, with front matter | the files are the record and everything else in this build is derived from them | | Index | SQLite with FTS5, rebuildable from the folder by one command | a search index is a cache, and treating it as the truth is how a notes app eats a year of writing | | Capture | A global hotkey window that writes the file before it renders anything else | the value of a capture tool is measured in the thoughts it did not lose | | Links | Wiki-style links resolved through an identifier in the front matter | renaming a note must not break every link into it | | AI | Optional, one model behind one interface, answering only with quotes from your notes | with no model configured the app is complete, and that has to be a real mode | | Shell | Tauri 2 with a small web front end | a desktop process that owns a folder, not a server that owns your notes | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Capture | Atomic file writes with front matter. | Any editor surface, provided the file is written before anything else happens. | | Index | FTS5 maintenance and reconciliation. | Any index, provided it is rebuildable from the folder by one command. | | Links | Identifier resolution, aliases and backlinks. | Any syntax, provided resolution never depends on the file name. | | Answers | Retrieval, citation and refusal. | Any model, provided an answer below the threshold is refused rather than produced. | | Portability | Schema migration, conflicts, backup and deletion. | Any sync transport, provided both sides of a conflict survive. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `NOTES_DIR` · required · The folder that is the record. Everything else can be rebuilt from it. - `INDEX_DB` · required · Disposable by design. Deleting it triggers a rebuild. - `WATCH_DEBOUNCE_MS` · required · How long to wait after a file changes before re-indexing it. - `REBUILD_ON_START` · required · Reconcile by mtime and hash at startup. Never assume the index is current. - `AI_ENDPOINT` · optional · Optional. Absent means search only, which is a complete mode. - `AI_API_KEY` · optional, secret · Only if the endpoint needs one. Note text is sent there, so choose deliberately. - `RETRIEVAL_THRESHOLD` · required · Below this, the panel refuses to answer rather than guessing. - `CONFLICT_SUFFIX` · required · Appended to the losing side of a simultaneous edit, which is kept rather than discarded. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Saner.AI product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Markdown files in a folder you choose, one per note, with front matter, SQLite with FTS5, rebuildable from the folder by one command, A global hotkey window that writes the file before it renders anything else, Wiki-style links resolved through an identifier in the front matter, Optional, one model behind one interface, answering only with quotes from your notes, Tauri 2 with a small web front end. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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 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. - 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. - 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. - 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.
# Delivery milestones · Saner.AI Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Capture that writes before it draws A thought is on disk before the window has finished appearing. ### Steps 1. Write to a temporary file and rename The rename is the only moment the note becomes real. Files: `src-tauri/src/capture.rs`, `tests/atomic_write.rs` 2. Put the identifier in the front matter It is what survives a rename, a move and a reorganisation. Files: `src-tauri/src/frontmatter.rs` 3. Keep the database out of the capture path Indexing is a consequence of a capture, never a precondition. Files: `src-tauri/src/capture.rs` ### Done when - [ ] The file exists before the indexer is called - [ ] A crash mid-write leaves no truncated note - [ ] Two captures in one second make two files - [ ] Cold-start capture meets the stated budget ### Watch 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. ## M2 · An index you can throw away Search is fast, and deleting the database costs nothing but a rebuild. ### Steps 1. Trigger on the app's own writes Cheap, correct, and only half the problem. Files: `src-tauri/src/index/triggers.sql` 2. Reconcile outside edits by mtime and hash The other half, and the one that makes search trustworthy. Files: `src-tauri/src/index/watch.rs`, `tests/external_edit.rs` 3. Make rebuild one command, and run it in tests A rebuild nobody has run is a rebuild that does not work. Files: `src-tauri/src/index/rebuild.rs` ### Done when - [ ] A rebuilt index matches the original results - [ ] An outside edit is re-indexed within the interval - [ ] An outside delete leaves broken links, not phantoms - [ ] Rebuild of ten thousand notes meets its budget ### Watch 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. ## M3 · Links, backlinks and the note that does not exist yet Linking to something unwritten is useful rather than broken. ### Steps 1. Resolve by identifier, display by title The two are different things and conflating them is the bug. Files: `src-tauri/src/links/resolve.rs`, `tests/rename.rs` 2. Keep former titles as aliases People write links by hand and do not update them. Files: `src-tauri/src/links/aliases.rs` 3. Make unresolved links a working queue The gaps are the most useful list in a notes app. Files: `src/ui/Unresolved.tsx` ### Done when - [ ] A rename keeps inbound links working - [ ] A link to a former title resolves - [ ] Creating a missing note inherits its backlinks - [ ] Deleted notes leave listed broken links ### Watch 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. ## M4 · An optional model that has to cite An answer without a quote from your own notes is refused. ### Steps 1. Refuse below the retrieval threshold The refusal is the feature, and it needs a number. Files: `src-tauri/src/ai/answer.rs`, `tests/refusal.rs` 2. Cite to a note and an offset A citation you cannot click is a claim. Files: `src/ui/Answer.tsx` 3. Keep the app complete with no model Tested by unsetting the configuration and running the suite. Files: `src-tauri/src/ai/mod.rs` ### Done when - [ ] An empty corpus produces a refusal, not an answer - [ ] Every citation opens the quoted offset - [ ] The app works fully with no model configured - [ ] Answers store their model and chunk identifiers ### Watch 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. ## M5 · Portability, because this is where notes go to die (production only) Leaving costs nothing, and files written years ago still open. ### Steps 1. Version the front matter and keep fixtures Every past version, in the suite, forever. Files: `src-tauri/src/migrate.rs`, `tests/fixtures/` 2. Keep both sides of a conflict A merge that picks a winner loses a paragraph silently. Files: `src-tauri/src/sync/conflict.rs` 3. Prove a fresh install rebuilds from the folder That is the whole claim, so it is a test rather than a sentence. Files: `tests/fresh_install.rs` ### Done when - [ ] Every past front matter version opens and migrates - [ ] A simultaneous edit leaves both sides on disk - [ ] Deletion removes file and derived rows together - [ ] A fresh install rebuilds everything from the folder ### Watch 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.
# Operations · Saner.AI ## Backup Back up the folder. The index is deliberately disposable and the conflict copies are part of the record, so a backup that excludes files with the conflict suffix is a backup missing the paragraphs somebody is looking for. ## Restore Restore the folder, point a fresh install at it and let the index rebuild. The restore has succeeded when search results match the pre-restore fixture, which is the test the suite already runs. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a desktop tool the checks live in the suite: the index reconciling an outside edit, a rebuild matching the original results, every past front matter version opening, and the cold capture time on the largest folder. ## Incident checklist A search result that opens the wrong text means reconciliation is failing, and the index age shown in the interface is the first thing to look at. A note that seems to have vanished is usually a conflict copy, which is why conflicts are a list rather than a silent rename. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A capture is confirmed on disk before the indexer runs - [ ] A rebuilt index is confirmed to match the original results - [ ] An outside edit is confirmed re-indexed within the interval - [ ] The app is confirmed complete with no model configured - [ ] Every past front matter version is confirmed to open and migrate ## Launch constraint Do not market omitted Saner.AI capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The folder that is the record. Everything else can be rebuilt from it. NOTES_DIR=~/Documents/notes # Required. Disposable by design. Deleting it triggers a rebuild. INDEX_DB=~/.local/share/notes/index.db # Required. How long to wait after a file changes before re-indexing it. WATCH_DEBOUNCE_MS=500 # Required. Reconcile by mtime and hash at startup. Never assume the index is current. REBUILD_ON_START=hash # Optional. Optional. Absent means search only, which is a complete mode. AI_ENDPOINT=http://localhost:11434 # Optional · secret. Only if the endpoint needs one. Note text is sent there, so choose deliberately. AI_API_KEY=sk-...redacted # Required. Below this, the panel refuses to answer rather than guessing. RETRIEVAL_THRESHOLD=0.35 # Required. Appended to the losing side of a simultaneous edit, which is kept rather than discarded. CONFLICT_SUFFIX=.conflict
$ choose a build depth, inspect the files, then open the complete pack in your agent
Saner.AI: People pay for a writing surface they trust for years, plus migration tools, sync, and tiny interaction details that disappear into habit.
xnative mobile clients
xconflict-safe multi-device sync
xmature importers and exporters
xcollaborative editing and sharing
Don't feel like building it? These folks already made it free.
OOpen NotebookA private source notebook with search and chat; capture is upload-first, not frictionless.open source↗all 3 free alternatives to Saner.AI →· no votes, no pay-to-list · just what's real
Saner.AI pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | 30 AI messages/month; 100 notes; 1 MB/PDF; 100 MB storage; 30 AI requests/month |
| starter | $8/user | — | 30 AI messages/day; 1,000 notes; 5 MB/PDF; 5 GB storage; 30 AI requests/day; team support |
| standard | $16/user | — | Unlimited* AI messages and notes; 10 MB/PDF; 100 GB storage; 100 AI requests/day |
free tier30 AI messages/month, 100 notes, 1 MB per PDF, 100 MB storage and 30 AI requests/month
billingmonthly only; no annual plan published
hidden costsStandard's 'unlimited' AI messages are subject to fair use, but the official page does not publish a numeric threshold. Daily AI-request caps remain 30 on Starter and 100 on Standard.
verified 2026-08-13 · source ↗
Vibecode Saner.AI
Kinda. The core of Saner.AI is buildable in a weekend with the prompt on this page, but there are real gaps: native mobile clients, conflict-safe multi-device sync. Read the honest list above before committing.
How much does Saner.AI cost?
Saner.AI's pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing Saner.AI?
Honestly: native mobile clients; conflict-safe multi-device sync; mature importers and exporters; collaborative editing and sharing. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Saner.AI?
Yes: Open Notebook (A private source notebook with search and chat; capture is upload-first, not frictionless.) Khoj (Search and chat across personal files, now self-host only because the cloud shut down.) Atomic (Capture, automatic connections and cited chat over your notes; bring the model yourself.) All 3 curated free alternatives are at vibecodeit.com/saner-ai/alternatives. The prompt is for when you want it exactly your way.