Vibecode Reflect
track this build4 phases, 12 steps, beginner friendly0%Reflect's solo core is compact: build a local-first networked notes app with fast capture, full-text search, backlinks, tags, and Markdown export. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on polish, sync, importers.
You are building a lean indie version of Reflect. 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 ===== # Reflect · indie build A local notes app whose hard problems are links that survive a rename, search that answers while you type on ten thousand notes, and an export to plain files that does not mangle titles the operating system will not accept. 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 | | --- | --- | --- | | Shell | Tauri 2 with React and TypeScript | a notes app that cannot reach the filesystem cannot export properly | | Storage | SQLite, one file, with notes identified by an id and never by a title | renaming a note must not break anything that points at it | | Links | Parsed on save into a links table, backlinks derived from it | recomputing on read means a graph that gets slower as it gets useful | | Search | An index built on write with a stated latency budget asserted in tests | the whole promise is that it answers before you stop typing | | Export | Markdown files with front matter carrying the id and the true title | the filesystem is not allowed to be the source of truth | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Builds the front end and runs the corpus and performance tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **The Rust toolchain** · free - Why: Tauri 2 needs it, and the global shortcut and warm capture window live on that side. - Get it: rustup.rs, then the platform build dependencies Tauri lists. - Verify: cargo --version prints a version and a dev build opens a window - [ ] **A generated corpus of ten thousand notes** · free - Why: Every performance claim in this build is meaningless on the twelve notes you wrote by hand, and the corpus is what makes the budgets falsifiable. - Get it: Write the generator in phase three, with realistic note lengths, link density and title collisions. - Verify: The corpus loads and the search and backlink tests run against it - [ ] **Your latency budgets, written before tuning** · free - Why: Search response and shortcut-to-cursor are the two numbers this app lives or dies by, and choosing them after measuring is just describing what you built. - Get it: Pick two numbers, write them in the documentation with the reason, and assert them in tests. - Verify: Both numbers appear in docs and in failing tests before the work starts ## 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. - Native mobile clients. - Collaborative editing and sharing. - Calendar integration. - native mobile clients - conflict-safe multi-device sync - mature importers and exporters - collaborative editing and sharing - calendar-provider edge cases and timezone correctness If one of those is essential to you, that is the reason to keep paying for Reflect, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Reflect 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-first networked notes app where every note can leave as a plain Markdown file with its links intact to replace Reflect. 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) - Shell: Tauri 2 with React and TypeScript · a notes app that cannot reach the filesystem cannot export properly - Storage: SQLite, one file, with notes identified by an id and never by a title · renaming a note must not break anything that points at it - Links: Parsed on save into a links table, backlinks derived from it · recomputing on read means a graph that gets slower as it gets useful - Search: An index built on write with a stated latency budget asserted in tests · the whole promise is that it answers before you stop typing - Export: Markdown files with front matter carrying the id and the true title · the filesystem is not allowed to be the source of truth ### Data model (create this before Phase 1) - notes(id, title, body, created_at, updated_at, deleted_at, daily_date) · id is stable for the note's life and never derived from the title - aliases(note_id, alias, created_at) · every title a note has ever had, so an old link still resolves - links(source_id, target_id, target_text, resolved, ordinal) · resolved false means a link to a note that does not exist yet - tags(note_id, tag) · flat, because a hierarchy is a second feature pretending to be one - search_index(note_id, ...) · rebuilt on write, with the shape chosen after measuring on a real corpus - export_manifest(note_id, filename, title, exported_at, collision_suffix) · the mapping between ids and the names the filesystem allowed - The title is a label. Everything that refers to a note refers to its id, which is why renaming is free and export needs a manifest. ### Phase 1 · Ids, not titles Build: Give every note an id at creation. Store the title as an ordinary field. When a title changes, record the previous one in the aliases table. Resolve a written link by looking for a current title first, then an alias, and record which one matched. Trap: Keying notes by title, as the file-per-note tools do, means renaming one either breaks every link pointing at it or forces a rewrite of every file that mentions it, and the copies you already exported keep the old name forever. Give each note an id, keep the old titles as aliases, and let the rename be a single row update. Done when: Renaming a note leaves every existing link resolving to it, tested with links written before the rename. A link written using a title the note had two renames ago still resolves, via the alias table. Two notes cannot hold the same current title, and attempting it is refused with the conflicting note shown. Deleting a note leaves links to it unresolved and visible rather than silently removed. Do not build yet: No backlinks, search, capture or export yet. ### Phase 2 · Links and backlinks, computed once Build: Parse links out of the body on save into the links table, replacing that note's rows in one transaction. Derive backlinks by querying the table. An unresolved link renders differently and creating the missing note resolves every link that was waiting for it. Trap: Computing backlinks by scanning every note's body when a note is opened is fine at two hundred notes and unusable at five thousand, and because it degrades gradually you will not notice until the app you rely on daily has become slow. Write the links table on save and query it on read. Done when: Opening a note with two hundred backlinks renders them within a stated budget on a corpus of ten thousand notes, measured and asserted. Creating a note whose title matches existing unresolved links resolves all of them and their sources show the new backlink, tested. Editing a body to remove a link removes exactly that row and no others. The unresolved link count is visible somewhere, because it is the most useful to-do list in a notes app. Do not build yet: No search, capture or export yet. ### Phase 3 · Search that answers while you type Build: Choose a latency budget, generate a corpus of ten thousand realistic notes, and assert the budget in the test suite before tuning anything. Index on write. Rank by title match, then recency, then body match, and show the matching line rather than the first line. Trap: Building the index on read, or rebuilding it wholesale on every save, makes typing in a large note stutter, and the reflex fix of debouncing the save is how people lose the paragraph they just wrote. Index incrementally on write, off the interaction path, and never trade a save for a search. Done when: A query against ten thousand generated notes returns within the stated budget, asserted in the suite rather than observed once. Typing in a large note does not delay keystrokes while indexing, measured. Results show the matching line with the term in context, not the note's first line. A search for a term that appears only in a title outranks one where it appears once in a long body. Do not build yet: No capture shortcuts or export yet. ### Phase 4 · Capture in under a second Build: A global shortcut opens a small capture window that is already focused, writes into today's daily note by default, and closes on save. The window must be ready without waiting for the main application to start. Everything on this path is keyboard-driven. Trap: Making capture open the full application means a cold start, a window arranging itself, and a cursor that arrives second, which is long enough for the thought to go, so the feature quietly stops being used. Keep a warm capture window, focus the input before painting anything else, and measure the time from shortcut to typable cursor. Done when: The time from pressing the shortcut to a typable cursor is measured and under the stated budget, on a cold application. The capture window saves and closes with a single keystroke and the text appears in today's daily note. The shortcut works when the main window is closed. Every action in the capture path has a keyboard route and none requires a pointer. Do not build yet: No export or import yet. ### Phase 5 · Markdown files the filesystem will accept Build: Export one Markdown file per note with front matter carrying the id, the true title and the aliases. Derive the filename by slugifying the title against the strictest rules you must support: Microsoft documents the reserved characters as less than, greater than, colon, double quote, forward slash, backslash, vertical bar, question mark and asterisk, reserves the device names CON, PRN, AUX, NUL, COM1 to COM9 and LPT1 to LPT9 including when followed by an extension so that NUL.txt is still NUL, says not to end a name with a space or a period, and says not to assume case sensitivity (checked against their naming documentation). Record every mapping in a manifest, and import reads the manifest and the front matter rather than the filenames. Trap: Naming each exported file after its title works on your machine and breaks on a colleague's: a note called 'OS/2 vs NT' becomes a path with a directory in it, a note called 'NUL' cannot be created on Windows at all, and 'Meeting' and 'meeting' overwrite each other on a default macOS volume, so notes silently vanish from the export. Slugify against the strict rules, resolve collisions with a recorded suffix, and keep the true title in front matter where it cannot be mangled. Done when: Exporting a corpus containing a title with a slash, a title that is a reserved device name, a title ending in a period, and two titles differing only in case produces one file per note with no collisions and no losses, verified by counting files against notes. Every exported file's front matter contains the note id and the unmangled title. Re-importing an export reproduces the same ids, links and aliases, tested by a round trip. The manifest maps every id to its filename and records each collision suffix and why it was applied. Do not build yet: Nothing after this. ### Out of scope (and why) - Conflict-safe multi-device sync, which is a distributed systems project and the single largest thing you are not getting for free here. - Native mobile clients, which are their own build, their own store review and their own capture story. - Collaborative editing and sharing, which changes the data model from the first line rather than being added later. - Calendar integration, which is a provider-specific timezone problem well out of proportion to the feature. ### README must contain - That notes are identified by an id and titles are labels, which is why renaming never breaks a link - That every past title is kept as an alias so older links keep resolving - The search and capture latency budgets, and that both are asserted in the test suite on a ten thousand note corpus - The filename rules the export follows, why reserved names and reserved characters exist, and where the manifest lives - That an export round trips: ids, links and aliases survive a wipe and a re-import ===== AGENTS.md ===== # Agent instructions · Reflect indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with React and TypeScript, SQLite, one file, with notes identified by an id and never by a title, Parsed on save into a links table, backlinks derived from it, An index built on write with a stated latency budget asserted in tests, Markdown files with front matter carrying the id and the true title. 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 - Keying notes by title, as the file-per-note tools do, means renaming one either breaks every link pointing at it or forces a rewrite of every file that mentions it, and the copies you already exported keep the old name forever. Give each note an id, keep the old titles as aliases, and let the rename be a single row update. - Computing backlinks by scanning every note's body when a note is opened is fine at two hundred notes and unusable at five thousand, and because it degrades gradually you will not notice until the app you rely on daily has become slow. Write the links table on save and query it on read. - Building the index on read, or rebuilding it wholesale on every save, makes typing in a large note stutter, and the reflex fix of debouncing the save is how people lose the paragraph they just wrote. Index incrementally on write, off the interaction path, and never trade a save for a search. - Making capture open the full application means a cold start, a window arranging itself, and a cursor that arrives second, which is long enough for the thought to go, so the feature quietly stops being used. Keep a warm capture window, focus the input before painting anything else, and measure the time from shortcut to typable cursor. ===== BUILD_PLAN.md ===== # Build plan · Reflect A local notes app whose hard problems are links that survive a rename, search that answers while you type on ten thousand notes, and an export to plain files that does not mangle titles the operating system will not accept. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Ids, not titles A note keeps its identity through every rename, and old links still resolve. ### Steps 1. Assign an id at creation Everything downstream depends on this one decision. Files: `src/notes/create.ts`, `src/db/schema.sql` 2. Keep every past title as an alias Renaming a note should not orphan the sentence that mentioned it. Files: `src/notes/rename.ts`, `tests/rename-links.test.ts` 3. Refuse duplicate current titles Ambiguous links are worse than rejected ones. Files: `src/notes/validate.ts` ### Done when - [ ] Links survive a rename - [ ] A link using a two-renames-ago title resolves - [ ] Duplicate current titles are refused - [ ] Links to a deleted note show as unresolved ### Watch out - Keying notes by title, as the file-per-note tools do, means renaming one either breaks every link pointing at it or forces a rewrite of every file that mentions it, and the copies you already exported keep the old name forever. Give each note an id, keep the old titles as aliases, and let the rename be a single row update. ## Phase 2 · Links and backlinks, computed once Backlinks appear instantly and a link to a note that does not exist yet is a first-class thing. ### Steps 1. Rewrite a note's link rows on save One transaction, so a crash cannot leave half a graph. Files: `src/links/index.ts` 2. Resolve waiting links when a note appears The moment that makes the unresolved link useful rather than annoying. Files: `src/links/resolve.ts`, `tests/resolve-on-create.test.ts` 3. Measure backlink rendering on a real corpus Ten thousand notes, not the twelve you wrote by hand. Files: `tests/backlink-perf.test.ts` ### Done when - [ ] Two hundred backlinks render within the budget at ten thousand notes - [ ] Creating a note resolves links that were waiting - [ ] Removing a link removes exactly one row - [ ] Unresolved links are visible and countable ### Watch out - Computing backlinks by scanning every note's body when a note is opened is fine at two hundred notes and unusable at five thousand, and because it degrades gradually you will not notice until the app you rely on daily has become slow. Write the links table on save and query it on read. ## Phase 3 · Search that answers while you type A query over ten thousand notes returns ranked results inside a budget you wrote down first. ### Steps 1. Write the budget down before tuning A number chosen after measurement is a description, not a target. Files: `tests/search-perf.test.ts`, `docs/budgets.md` 2. Generate a realistic corpus Ten thousand notes with real length distribution and link density. Files: `scripts/generate-corpus.ts` 3. Index off the typing path Never let indexing be a reason to delay a save. Files: `src/search/index.ts` ### Done when - [ ] Queries meet the budget at ten thousand notes, asserted - [ ] Typing is not delayed by indexing - [ ] Results show the matching line in context - [ ] Title matches outrank incidental body matches ### Watch out - Building the index on read, or rebuilding it wholesale on every save, makes typing in a large note stutter, and the reflex fix of debouncing the save is how people lose the paragraph they just wrote. Index incrementally on write, off the interaction path, and never trade a save for a search. ## Phase 4 · Capture in under a second A thought can be written before it is lost, from anywhere, with no navigation. ### Steps 1. Keep a warm capture window The whole feature is the latency, and the latency is the warm window. Files: `src-tauri/src/capture.rs` 2. Measure shortcut to cursor The number that decides whether anyone uses this. Files: `tests/capture-latency.test.ts` 3. Default to the daily note One fewer decision between a thought and a record of it. Files: `src/capture/target.ts` ### Done when - [ ] Shortcut to typable cursor is within budget from cold - [ ] One keystroke saves and closes - [ ] The shortcut works with the main window closed - [ ] Every capture action has a keyboard route ### Watch out - Making capture open the full application means a cold start, a window arranging itself, and a cursor that arrives second, which is long enough for the thought to go, so the feature quietly stops being used. Keep a warm capture window, focus the input before painting anything else, and measure the time from shortcut to typable cursor. ## Not in this build - Multi-device sync. - Native mobile clients. - Collaborative editing and sharing. - Calendar integration. ## After v1, if you want it - A graph view, which needs nothing new because the links table already exists - Transclusion of one note inside another, which is a render-time resolution of an id you already store - A second export format such as a single archive file, reusing the manifest ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The SQLite file and the search index. Back this up. DATA_DIR=~/.local/share/notes # Required. Markdown files and the manifest. Overwritten on each export. EXPORT_DIR=~/Documents/notes-export # Required. The global shortcut. Conflicts with another app are the commonest setup failure. CAPTURE_SHORTCUT=CmdOrCtrl+Shift+N # Required. Asserted in the suite against the generated corpus. SEARCH_BUDGET_MS=80 # Required. Shortcut to typable cursor, measured from a cold application. CAPTURE_BUDGET_MS=400 # Required. Strict applies the Windows rules everywhere, so an export is portable between machines. EXPORT_FILENAME_MODE=strict
You are building a lean indie version of Reflect. 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 ===== # Reflect · indie build A local notes app whose hard problems are links that survive a rename, search that answers while you type on ten thousand notes, and an export to plain files that does not mangle titles the operating system will not accept. 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 | | --- | --- | --- | | Shell | Tauri 2 with React and TypeScript | a notes app that cannot reach the filesystem cannot export properly | | Storage | SQLite, one file, with notes identified by an id and never by a title | renaming a note must not break anything that points at it | | Links | Parsed on save into a links table, backlinks derived from it | recomputing on read means a graph that gets slower as it gets useful | | Search | An index built on write with a stated latency budget asserted in tests | the whole promise is that it answers before you stop typing | | Export | Markdown files with front matter carrying the id and the true title | the filesystem is not allowed to be the source of truth | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Builds the front end and runs the corpus and performance tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **The Rust toolchain** · free - Why: Tauri 2 needs it, and the global shortcut and warm capture window live on that side. - Get it: rustup.rs, then the platform build dependencies Tauri lists. - Verify: cargo --version prints a version and a dev build opens a window - [ ] **A generated corpus of ten thousand notes** · free - Why: Every performance claim in this build is meaningless on the twelve notes you wrote by hand, and the corpus is what makes the budgets falsifiable. - Get it: Write the generator in phase three, with realistic note lengths, link density and title collisions. - Verify: The corpus loads and the search and backlink tests run against it - [ ] **Your latency budgets, written before tuning** · free - Why: Search response and shortcut-to-cursor are the two numbers this app lives or dies by, and choosing them after measuring is just describing what you built. - Get it: Pick two numbers, write them in the documentation with the reason, and assert them in tests. - Verify: Both numbers appear in docs and in failing tests before the work starts ## 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. - Native mobile clients. - Collaborative editing and sharing. - Calendar integration. - native mobile clients - conflict-safe multi-device sync - mature importers and exporters - collaborative editing and sharing - calendar-provider edge cases and timezone correctness If one of those is essential to you, that is the reason to keep paying for Reflect, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Reflect 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-first networked notes app where every note can leave as a plain Markdown file with its links intact to replace Reflect. 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) - Shell: Tauri 2 with React and TypeScript · a notes app that cannot reach the filesystem cannot export properly - Storage: SQLite, one file, with notes identified by an id and never by a title · renaming a note must not break anything that points at it - Links: Parsed on save into a links table, backlinks derived from it · recomputing on read means a graph that gets slower as it gets useful - Search: An index built on write with a stated latency budget asserted in tests · the whole promise is that it answers before you stop typing - Export: Markdown files with front matter carrying the id and the true title · the filesystem is not allowed to be the source of truth ### Data model (create this before Phase 1) - notes(id, title, body, created_at, updated_at, deleted_at, daily_date) · id is stable for the note's life and never derived from the title - aliases(note_id, alias, created_at) · every title a note has ever had, so an old link still resolves - links(source_id, target_id, target_text, resolved, ordinal) · resolved false means a link to a note that does not exist yet - tags(note_id, tag) · flat, because a hierarchy is a second feature pretending to be one - search_index(note_id, ...) · rebuilt on write, with the shape chosen after measuring on a real corpus - export_manifest(note_id, filename, title, exported_at, collision_suffix) · the mapping between ids and the names the filesystem allowed - The title is a label. Everything that refers to a note refers to its id, which is why renaming is free and export needs a manifest. ### Phase 1 · Ids, not titles Build: Give every note an id at creation. Store the title as an ordinary field. When a title changes, record the previous one in the aliases table. Resolve a written link by looking for a current title first, then an alias, and record which one matched. Trap: Keying notes by title, as the file-per-note tools do, means renaming one either breaks every link pointing at it or forces a rewrite of every file that mentions it, and the copies you already exported keep the old name forever. Give each note an id, keep the old titles as aliases, and let the rename be a single row update. Done when: Renaming a note leaves every existing link resolving to it, tested with links written before the rename. A link written using a title the note had two renames ago still resolves, via the alias table. Two notes cannot hold the same current title, and attempting it is refused with the conflicting note shown. Deleting a note leaves links to it unresolved and visible rather than silently removed. Do not build yet: No backlinks, search, capture or export yet. ### Phase 2 · Links and backlinks, computed once Build: Parse links out of the body on save into the links table, replacing that note's rows in one transaction. Derive backlinks by querying the table. An unresolved link renders differently and creating the missing note resolves every link that was waiting for it. Trap: Computing backlinks by scanning every note's body when a note is opened is fine at two hundred notes and unusable at five thousand, and because it degrades gradually you will not notice until the app you rely on daily has become slow. Write the links table on save and query it on read. Done when: Opening a note with two hundred backlinks renders them within a stated budget on a corpus of ten thousand notes, measured and asserted. Creating a note whose title matches existing unresolved links resolves all of them and their sources show the new backlink, tested. Editing a body to remove a link removes exactly that row and no others. The unresolved link count is visible somewhere, because it is the most useful to-do list in a notes app. Do not build yet: No search, capture or export yet. ### Phase 3 · Search that answers while you type Build: Choose a latency budget, generate a corpus of ten thousand realistic notes, and assert the budget in the test suite before tuning anything. Index on write. Rank by title match, then recency, then body match, and show the matching line rather than the first line. Trap: Building the index on read, or rebuilding it wholesale on every save, makes typing in a large note stutter, and the reflex fix of debouncing the save is how people lose the paragraph they just wrote. Index incrementally on write, off the interaction path, and never trade a save for a search. Done when: A query against ten thousand generated notes returns within the stated budget, asserted in the suite rather than observed once. Typing in a large note does not delay keystrokes while indexing, measured. Results show the matching line with the term in context, not the note's first line. A search for a term that appears only in a title outranks one where it appears once in a long body. Do not build yet: No capture shortcuts or export yet. ### Phase 4 · Capture in under a second Build: A global shortcut opens a small capture window that is already focused, writes into today's daily note by default, and closes on save. The window must be ready without waiting for the main application to start. Everything on this path is keyboard-driven. Trap: Making capture open the full application means a cold start, a window arranging itself, and a cursor that arrives second, which is long enough for the thought to go, so the feature quietly stops being used. Keep a warm capture window, focus the input before painting anything else, and measure the time from shortcut to typable cursor. Done when: The time from pressing the shortcut to a typable cursor is measured and under the stated budget, on a cold application. The capture window saves and closes with a single keystroke and the text appears in today's daily note. The shortcut works when the main window is closed. Every action in the capture path has a keyboard route and none requires a pointer. Do not build yet: No export or import yet. ### Phase 5 · Markdown files the filesystem will accept Build: Export one Markdown file per note with front matter carrying the id, the true title and the aliases. Derive the filename by slugifying the title against the strictest rules you must support: Microsoft documents the reserved characters as less than, greater than, colon, double quote, forward slash, backslash, vertical bar, question mark and asterisk, reserves the device names CON, PRN, AUX, NUL, COM1 to COM9 and LPT1 to LPT9 including when followed by an extension so that NUL.txt is still NUL, says not to end a name with a space or a period, and says not to assume case sensitivity (checked against their naming documentation). Record every mapping in a manifest, and import reads the manifest and the front matter rather than the filenames. Trap: Naming each exported file after its title works on your machine and breaks on a colleague's: a note called 'OS/2 vs NT' becomes a path with a directory in it, a note called 'NUL' cannot be created on Windows at all, and 'Meeting' and 'meeting' overwrite each other on a default macOS volume, so notes silently vanish from the export. Slugify against the strict rules, resolve collisions with a recorded suffix, and keep the true title in front matter where it cannot be mangled. Done when: Exporting a corpus containing a title with a slash, a title that is a reserved device name, a title ending in a period, and two titles differing only in case produces one file per note with no collisions and no losses, verified by counting files against notes. Every exported file's front matter contains the note id and the unmangled title. Re-importing an export reproduces the same ids, links and aliases, tested by a round trip. The manifest maps every id to its filename and records each collision suffix and why it was applied. Do not build yet: Nothing after this. ### Out of scope (and why) - Conflict-safe multi-device sync, which is a distributed systems project and the single largest thing you are not getting for free here. - Native mobile clients, which are their own build, their own store review and their own capture story. - Collaborative editing and sharing, which changes the data model from the first line rather than being added later. - Calendar integration, which is a provider-specific timezone problem well out of proportion to the feature. ### README must contain - That notes are identified by an id and titles are labels, which is why renaming never breaks a link - That every past title is kept as an alias so older links keep resolving - The search and capture latency budgets, and that both are asserted in the test suite on a ten thousand note corpus - The filename rules the export follows, why reserved names and reserved characters exist, and where the manifest lives - That an export round trips: ids, links and aliases survive a wipe and a re-import ===== AGENTS.md ===== # Agent instructions · Reflect indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with React and TypeScript, SQLite, one file, with notes identified by an id and never by a title, Parsed on save into a links table, backlinks derived from it, An index built on write with a stated latency budget asserted in tests, Markdown files with front matter carrying the id and the true title. 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 - Keying notes by title, as the file-per-note tools do, means renaming one either breaks every link pointing at it or forces a rewrite of every file that mentions it, and the copies you already exported keep the old name forever. Give each note an id, keep the old titles as aliases, and let the rename be a single row update. - Computing backlinks by scanning every note's body when a note is opened is fine at two hundred notes and unusable at five thousand, and because it degrades gradually you will not notice until the app you rely on daily has become slow. Write the links table on save and query it on read. - Building the index on read, or rebuilding it wholesale on every save, makes typing in a large note stutter, and the reflex fix of debouncing the save is how people lose the paragraph they just wrote. Index incrementally on write, off the interaction path, and never trade a save for a search. - Making capture open the full application means a cold start, a window arranging itself, and a cursor that arrives second, which is long enough for the thought to go, so the feature quietly stops being used. Keep a warm capture window, focus the input before painting anything else, and measure the time from shortcut to typable cursor. ===== BUILD_PLAN.md ===== # Build plan · Reflect A local notes app whose hard problems are links that survive a rename, search that answers while you type on ten thousand notes, and an export to plain files that does not mangle titles the operating system will not accept. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Ids, not titles A note keeps its identity through every rename, and old links still resolve. ### Steps 1. Assign an id at creation Everything downstream depends on this one decision. Files: `src/notes/create.ts`, `src/db/schema.sql` 2. Keep every past title as an alias Renaming a note should not orphan the sentence that mentioned it. Files: `src/notes/rename.ts`, `tests/rename-links.test.ts` 3. Refuse duplicate current titles Ambiguous links are worse than rejected ones. Files: `src/notes/validate.ts` ### Done when - [ ] Links survive a rename - [ ] A link using a two-renames-ago title resolves - [ ] Duplicate current titles are refused - [ ] Links to a deleted note show as unresolved ### Watch out - Keying notes by title, as the file-per-note tools do, means renaming one either breaks every link pointing at it or forces a rewrite of every file that mentions it, and the copies you already exported keep the old name forever. Give each note an id, keep the old titles as aliases, and let the rename be a single row update. ## Phase 2 · Links and backlinks, computed once Backlinks appear instantly and a link to a note that does not exist yet is a first-class thing. ### Steps 1. Rewrite a note's link rows on save One transaction, so a crash cannot leave half a graph. Files: `src/links/index.ts` 2. Resolve waiting links when a note appears The moment that makes the unresolved link useful rather than annoying. Files: `src/links/resolve.ts`, `tests/resolve-on-create.test.ts` 3. Measure backlink rendering on a real corpus Ten thousand notes, not the twelve you wrote by hand. Files: `tests/backlink-perf.test.ts` ### Done when - [ ] Two hundred backlinks render within the budget at ten thousand notes - [ ] Creating a note resolves links that were waiting - [ ] Removing a link removes exactly one row - [ ] Unresolved links are visible and countable ### Watch out - Computing backlinks by scanning every note's body when a note is opened is fine at two hundred notes and unusable at five thousand, and because it degrades gradually you will not notice until the app you rely on daily has become slow. Write the links table on save and query it on read. ## Phase 3 · Search that answers while you type A query over ten thousand notes returns ranked results inside a budget you wrote down first. ### Steps 1. Write the budget down before tuning A number chosen after measurement is a description, not a target. Files: `tests/search-perf.test.ts`, `docs/budgets.md` 2. Generate a realistic corpus Ten thousand notes with real length distribution and link density. Files: `scripts/generate-corpus.ts` 3. Index off the typing path Never let indexing be a reason to delay a save. Files: `src/search/index.ts` ### Done when - [ ] Queries meet the budget at ten thousand notes, asserted - [ ] Typing is not delayed by indexing - [ ] Results show the matching line in context - [ ] Title matches outrank incidental body matches ### Watch out - Building the index on read, or rebuilding it wholesale on every save, makes typing in a large note stutter, and the reflex fix of debouncing the save is how people lose the paragraph they just wrote. Index incrementally on write, off the interaction path, and never trade a save for a search. ## Phase 4 · Capture in under a second A thought can be written before it is lost, from anywhere, with no navigation. ### Steps 1. Keep a warm capture window The whole feature is the latency, and the latency is the warm window. Files: `src-tauri/src/capture.rs` 2. Measure shortcut to cursor The number that decides whether anyone uses this. Files: `tests/capture-latency.test.ts` 3. Default to the daily note One fewer decision between a thought and a record of it. Files: `src/capture/target.ts` ### Done when - [ ] Shortcut to typable cursor is within budget from cold - [ ] One keystroke saves and closes - [ ] The shortcut works with the main window closed - [ ] Every capture action has a keyboard route ### Watch out - Making capture open the full application means a cold start, a window arranging itself, and a cursor that arrives second, which is long enough for the thought to go, so the feature quietly stops being used. Keep a warm capture window, focus the input before painting anything else, and measure the time from shortcut to typable cursor. ## Not in this build - Multi-device sync. - Native mobile clients. - Collaborative editing and sharing. - Calendar integration. ## After v1, if you want it - A graph view, which needs nothing new because the links table already exists - Transclusion of one note inside another, which is a render-time resolution of an id you already store - A second export format such as a single archive file, reusing the manifest ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The SQLite file and the search index. Back this up. DATA_DIR=~/.local/share/notes # Required. Markdown files and the manifest. Overwritten on each export. EXPORT_DIR=~/Documents/notes-export # Required. The global shortcut. Conflicts with another app are the commonest setup failure. CAPTURE_SHORTCUT=CmdOrCtrl+Shift+N # Required. Asserted in the suite against the generated corpus. SEARCH_BUDGET_MS=80 # Required. Shortcut to typable cursor, measured from a cold application. CAPTURE_BUDGET_MS=400 # Required. Strict applies the Windows rules everywhere, so an export is portable between machines. EXPORT_FILENAME_MODE=strict
You are building a production product version of Reflect. 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 ===== # Reflect · product brief ## Problem Reflect's solo core is compact: build a local-first networked notes app with fast capture, full-text search, backlinks, tags, and Markdown export. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on polish, sync, importers. ## Product outcome A notes app that stays fast at ten thousand notes, never breaks a link when you rename something, captures a thought before you lose it, and exports to files that open on any machine. ## 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. - Native mobile clients. - Collaborative editing and sharing. - Calendar integration. - native mobile clients - conflict-safe multi-device sync - mature importers and exporters - collaborative editing and sharing - calendar-provider edge cases and timezone correctness ## Success criteria - Links are confirmed to survive a rename and a two-rename-old alias - Backlink and search budgets are confirmed on a ten thousand note corpus - Capture latency is confirmed from a cold start - Hostile titles are confirmed to export without collision or loss - An export is confirmed to round trip ids, links and aliases ===== BRIEF.md ===== # Build brief · Reflect 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-first networked notes app where every note can leave as a plain Markdown file with its links intact to replace Reflect. 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) - Shell: Tauri 2 with React and TypeScript · a notes app that cannot reach the filesystem cannot export properly - Storage: SQLite, one file, with notes identified by an id and never by a title · renaming a note must not break anything that points at it - Links: Parsed on save into a links table, backlinks derived from it · recomputing on read means a graph that gets slower as it gets useful - Search: An index built on write with a stated latency budget asserted in tests · the whole promise is that it answers before you stop typing - Export: Markdown files with front matter carrying the id and the true title · the filesystem is not allowed to be the source of truth ### Data model (create this before Phase 1) - notes(id, title, body, created_at, updated_at, deleted_at, daily_date) · id is stable for the note's life and never derived from the title - aliases(note_id, alias, created_at) · every title a note has ever had, so an old link still resolves - links(source_id, target_id, target_text, resolved, ordinal) · resolved false means a link to a note that does not exist yet - tags(note_id, tag) · flat, because a hierarchy is a second feature pretending to be one - search_index(note_id, ...) · rebuilt on write, with the shape chosen after measuring on a real corpus - export_manifest(note_id, filename, title, exported_at, collision_suffix) · the mapping between ids and the names the filesystem allowed - The title is a label. Everything that refers to a note refers to its id, which is why renaming is free and export needs a manifest. ### Phase 1 · Ids, not titles Build: Give every note an id at creation. Store the title as an ordinary field. When a title changes, record the previous one in the aliases table. Resolve a written link by looking for a current title first, then an alias, and record which one matched. Trap: Keying notes by title, as the file-per-note tools do, means renaming one either breaks every link pointing at it or forces a rewrite of every file that mentions it, and the copies you already exported keep the old name forever. Give each note an id, keep the old titles as aliases, and let the rename be a single row update. Done when: Renaming a note leaves every existing link resolving to it, tested with links written before the rename. A link written using a title the note had two renames ago still resolves, via the alias table. Two notes cannot hold the same current title, and attempting it is refused with the conflicting note shown. Deleting a note leaves links to it unresolved and visible rather than silently removed. Do not build yet: No backlinks, search, capture or export yet. ### Phase 2 · Links and backlinks, computed once Build: Parse links out of the body on save into the links table, replacing that note's rows in one transaction. Derive backlinks by querying the table. An unresolved link renders differently and creating the missing note resolves every link that was waiting for it. Trap: Computing backlinks by scanning every note's body when a note is opened is fine at two hundred notes and unusable at five thousand, and because it degrades gradually you will not notice until the app you rely on daily has become slow. Write the links table on save and query it on read. Done when: Opening a note with two hundred backlinks renders them within a stated budget on a corpus of ten thousand notes, measured and asserted. Creating a note whose title matches existing unresolved links resolves all of them and their sources show the new backlink, tested. Editing a body to remove a link removes exactly that row and no others. The unresolved link count is visible somewhere, because it is the most useful to-do list in a notes app. Do not build yet: No search, capture or export yet. ### Phase 3 · Search that answers while you type Build: Choose a latency budget, generate a corpus of ten thousand realistic notes, and assert the budget in the test suite before tuning anything. Index on write. Rank by title match, then recency, then body match, and show the matching line rather than the first line. Trap: Building the index on read, or rebuilding it wholesale on every save, makes typing in a large note stutter, and the reflex fix of debouncing the save is how people lose the paragraph they just wrote. Index incrementally on write, off the interaction path, and never trade a save for a search. Done when: A query against ten thousand generated notes returns within the stated budget, asserted in the suite rather than observed once. Typing in a large note does not delay keystrokes while indexing, measured. Results show the matching line with the term in context, not the note's first line. A search for a term that appears only in a title outranks one where it appears once in a long body. Do not build yet: No capture shortcuts or export yet. ### Phase 4 · Capture in under a second Build: A global shortcut opens a small capture window that is already focused, writes into today's daily note by default, and closes on save. The window must be ready without waiting for the main application to start. Everything on this path is keyboard-driven. Trap: Making capture open the full application means a cold start, a window arranging itself, and a cursor that arrives second, which is long enough for the thought to go, so the feature quietly stops being used. Keep a warm capture window, focus the input before painting anything else, and measure the time from shortcut to typable cursor. Done when: The time from pressing the shortcut to a typable cursor is measured and under the stated budget, on a cold application. The capture window saves and closes with a single keystroke and the text appears in today's daily note. The shortcut works when the main window is closed. Every action in the capture path has a keyboard route and none requires a pointer. Do not build yet: No export or import yet. ### Phase 5 · Markdown files the filesystem will accept Build: Export one Markdown file per note with front matter carrying the id, the true title and the aliases. Derive the filename by slugifying the title against the strictest rules you must support: Microsoft documents the reserved characters as less than, greater than, colon, double quote, forward slash, backslash, vertical bar, question mark and asterisk, reserves the device names CON, PRN, AUX, NUL, COM1 to COM9 and LPT1 to LPT9 including when followed by an extension so that NUL.txt is still NUL, says not to end a name with a space or a period, and says not to assume case sensitivity (checked against their naming documentation). Record every mapping in a manifest, and import reads the manifest and the front matter rather than the filenames. Trap: Naming each exported file after its title works on your machine and breaks on a colleague's: a note called 'OS/2 vs NT' becomes a path with a directory in it, a note called 'NUL' cannot be created on Windows at all, and 'Meeting' and 'meeting' overwrite each other on a default macOS volume, so notes silently vanish from the export. Slugify against the strict rules, resolve collisions with a recorded suffix, and keep the true title in front matter where it cannot be mangled. Done when: Exporting a corpus containing a title with a slash, a title that is a reserved device name, a title ending in a period, and two titles differing only in case produces one file per note with no collisions and no losses, verified by counting files against notes. Every exported file's front matter contains the note id and the unmangled title. Re-importing an export reproduces the same ids, links and aliases, tested by a round trip. The manifest maps every id to its filename and records each collision suffix and why it was applied. Do not build yet: Nothing after this. ### Out of scope (and why) - Conflict-safe multi-device sync, which is a distributed systems project and the single largest thing you are not getting for free here. - Native mobile clients, which are their own build, their own store review and their own capture story. - Collaborative editing and sharing, which changes the data model from the first line rather than being added later. - Calendar integration, which is a provider-specific timezone problem well out of proportion to the feature. ### README must contain - That notes are identified by an id and titles are labels, which is why renaming never breaks a link - That every past title is kept as an alias so older links keep resolving - The search and capture latency budgets, and that both are asserted in the test suite on a ten thousand note corpus - The filename rules the export follows, why reserved names and reserved characters exist, and where the manifest lives - That an export round trips: ids, links and aliases survive a wipe and a re-import ===== ARCHITECTURE.md ===== # Architecture · Reflect ## Stack | Part | Choice | Why | | --- | --- | --- | | Shell | Tauri 2 with React and TypeScript | a notes app that cannot reach the filesystem cannot export properly | | Storage | SQLite, one file, with notes identified by an id and never by a title | renaming a note must not break anything that points at it | | Links | Parsed on save into a links table, backlinks derived from it | recomputing on read means a graph that gets slower as it gets useful | | Search | An index built on write with a stated latency budget asserted in tests | the whole promise is that it answers before you stop typing | | Export | Markdown files with front matter carrying the id and the true title | the filesystem is not allowed to be the source of truth | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Notes | Ids, titles and aliases. | Any store, provided identity never becomes the title. | | Links | Parse on save, backlinks by query. | Any parser, provided the links table is written in one transaction. | | Search | Incremental index and ranking. | Any index, provided the corpus budget test still passes. | | Capture | Global shortcut and warm window. | Any implementation that meets the latency budget from cold. | | Export | Strict filenames, front matter, manifest. | Any writer, provided the hostile-titles test and the round trip pass. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATA_DIR` · required · The SQLite file and the search index. Back this up. - `EXPORT_DIR` · required · Markdown files and the manifest. Overwritten on each export. - `CAPTURE_SHORTCUT` · required · The global shortcut. Conflicts with another app are the commonest setup failure. - `SEARCH_BUDGET_MS` · required · Asserted in the suite against the generated corpus. - `CAPTURE_BUDGET_MS` · required · Shortcut to typable cursor, measured from a cold application. - `EXPORT_FILENAME_MODE` · required · Strict applies the Windows rules everywhere, so an export is portable between machines. ## 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 · Reflect product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Tauri 2 with React and TypeScript, SQLite, one file, with notes identified by an id and never by a title, Parsed on save into a links table, backlinks derived from it, An index built on write with a stated latency budget asserted in tests, Markdown files with front matter carrying the id and the true title. - 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 - Keying notes by title, as the file-per-note tools do, means renaming one either breaks every link pointing at it or forces a rewrite of every file that mentions it, and the copies you already exported keep the old name forever. Give each note an id, keep the old titles as aliases, and let the rename be a single row update. - Computing backlinks by scanning every note's body when a note is opened is fine at two hundred notes and unusable at five thousand, and because it degrades gradually you will not notice until the app you rely on daily has become slow. Write the links table on save and query it on read. - Building the index on read, or rebuilding it wholesale on every save, makes typing in a large note stutter, and the reflex fix of debouncing the save is how people lose the paragraph they just wrote. Index incrementally on write, off the interaction path, and never trade a save for a search. - Making capture open the full application means a cold start, a window arranging itself, and a cursor that arrives second, which is long enough for the thought to go, so the feature quietly stops being used. Keep a warm capture window, focus the input before painting anything else, and measure the time from shortcut to typable cursor. - Naming each exported file after its title works on your machine and breaks on a colleague's: a note called 'OS/2 vs NT' becomes a path with a directory in it, a note called 'NUL' cannot be created on Windows at all, and 'Meeting' and 'meeting' overwrite each other on a default macOS volume, so notes silently vanish from the export. Slugify against the strict rules, resolve collisions with a recorded suffix, and keep the true title in front matter where it cannot be mangled. ===== MILESTONES.md ===== # Delivery milestones · Reflect Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Ids, not titles A note keeps its identity through every rename, and old links still resolve. ### Steps 1. Assign an id at creation Everything downstream depends on this one decision. Files: `src/notes/create.ts`, `src/db/schema.sql` 2. Keep every past title as an alias Renaming a note should not orphan the sentence that mentioned it. Files: `src/notes/rename.ts`, `tests/rename-links.test.ts` 3. Refuse duplicate current titles Ambiguous links are worse than rejected ones. Files: `src/notes/validate.ts` ### Done when - [ ] Links survive a rename - [ ] A link using a two-renames-ago title resolves - [ ] Duplicate current titles are refused - [ ] Links to a deleted note show as unresolved ### Watch out - Keying notes by title, as the file-per-note tools do, means renaming one either breaks every link pointing at it or forces a rewrite of every file that mentions it, and the copies you already exported keep the old name forever. Give each note an id, keep the old titles as aliases, and let the rename be a single row update. ## M2 · Links and backlinks, computed once Backlinks appear instantly and a link to a note that does not exist yet is a first-class thing. ### Steps 1. Rewrite a note's link rows on save One transaction, so a crash cannot leave half a graph. Files: `src/links/index.ts` 2. Resolve waiting links when a note appears The moment that makes the unresolved link useful rather than annoying. Files: `src/links/resolve.ts`, `tests/resolve-on-create.test.ts` 3. Measure backlink rendering on a real corpus Ten thousand notes, not the twelve you wrote by hand. Files: `tests/backlink-perf.test.ts` ### Done when - [ ] Two hundred backlinks render within the budget at ten thousand notes - [ ] Creating a note resolves links that were waiting - [ ] Removing a link removes exactly one row - [ ] Unresolved links are visible and countable ### Watch out - Computing backlinks by scanning every note's body when a note is opened is fine at two hundred notes and unusable at five thousand, and because it degrades gradually you will not notice until the app you rely on daily has become slow. Write the links table on save and query it on read. ## M3 · Search that answers while you type A query over ten thousand notes returns ranked results inside a budget you wrote down first. ### Steps 1. Write the budget down before tuning A number chosen after measurement is a description, not a target. Files: `tests/search-perf.test.ts`, `docs/budgets.md` 2. Generate a realistic corpus Ten thousand notes with real length distribution and link density. Files: `scripts/generate-corpus.ts` 3. Index off the typing path Never let indexing be a reason to delay a save. Files: `src/search/index.ts` ### Done when - [ ] Queries meet the budget at ten thousand notes, asserted - [ ] Typing is not delayed by indexing - [ ] Results show the matching line in context - [ ] Title matches outrank incidental body matches ### Watch out - Building the index on read, or rebuilding it wholesale on every save, makes typing in a large note stutter, and the reflex fix of debouncing the save is how people lose the paragraph they just wrote. Index incrementally on write, off the interaction path, and never trade a save for a search. ## M4 · Capture in under a second A thought can be written before it is lost, from anywhere, with no navigation. ### Steps 1. Keep a warm capture window The whole feature is the latency, and the latency is the warm window. Files: `src-tauri/src/capture.rs` 2. Measure shortcut to cursor The number that decides whether anyone uses this. Files: `tests/capture-latency.test.ts` 3. Default to the daily note One fewer decision between a thought and a record of it. Files: `src/capture/target.ts` ### Done when - [ ] Shortcut to typable cursor is within budget from cold - [ ] One keystroke saves and closes - [ ] The shortcut works with the main window closed - [ ] Every capture action has a keyboard route ### Watch out - Making capture open the full application means a cold start, a window arranging itself, and a cursor that arrives second, which is long enough for the thought to go, so the feature quietly stops being used. Keep a warm capture window, focus the input before painting anything else, and measure the time from shortcut to typable cursor. ## M5 · Markdown files the filesystem will accept (production only) Every note exports as a plain Markdown file, on any operating system, without losing its identity. ### Steps 1. Slugify against the strict rules Reserved characters, reserved device names, no trailing dot or space. Files: `src/export/filename.ts`, `tests/filename-rules.test.ts` 2. Put identity in front matter, not in the name The filename is a convenience; the id is the note. Files: `src/export/markdown.ts` 3. Round trip the whole corpus Export, wipe, import, compare ids, links and aliases. Files: `tests/export-roundtrip.test.ts` ### Done when - [ ] Hostile titles export with no collisions or losses - [ ] Front matter carries id, title and aliases - [ ] A round trip reproduces ids, links and aliases - [ ] The manifest records every collision suffix ### Watch out - Naming each exported file after its title works on your machine and breaks on a colleague's: a note called 'OS/2 vs NT' becomes a path with a directory in it, a note called 'NUL' cannot be created on Windows at all, and 'Meeting' and 'meeting' overwrite each other on a default macOS volume, so notes silently vanish from the export. Slugify against the strict rules, resolve collisions with a recorded suffix, and keep the true title in front matter where it cannot be mangled. ===== OPERATIONS.md ===== # Operations · Reflect ## Backup The data directory, and separately a periodic Markdown export, because a plain-text copy is readable by anything and does not depend on this application still building in three years. That second copy is the real insurance. ## Restore Restore and confirm the search index rebuilds and that a sample of links still resolve. Then restore instead from the Markdown export and confirm the round trip reproduces ids and links, because that is the path you will actually use one day. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a local tool this is the suite: search and capture budgets on the generated corpus, the hostile-titles export test, and the round trip. A budget test that starts failing is the app getting slower before you feel it. ## Incident checklist Notes missing after an export is the incident this design anticipates, and the manifest plus the file count assertion is what turns it from a mystery into a named collision. 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 - [ ] Links are confirmed to survive a rename and a two-rename-old alias - [ ] Backlink and search budgets are confirmed on a ten thousand note corpus - [ ] Capture latency is confirmed from a cold start - [ ] Hostile titles are confirmed to export without collision or loss - [ ] An export is confirmed to round trip ids, links and aliases ## Launch constraint Do not market omitted Reflect 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 SQLite file and the search index. Back this up. DATA_DIR=~/.local/share/notes # Required. Markdown files and the manifest. Overwritten on each export. EXPORT_DIR=~/Documents/notes-export # Required. The global shortcut. Conflicts with another app are the commonest setup failure. CAPTURE_SHORTCUT=CmdOrCtrl+Shift+N # Required. Asserted in the suite against the generated corpus. SEARCH_BUDGET_MS=80 # Required. Shortcut to typable cursor, measured from a cold application. CAPTURE_BUDGET_MS=400 # Required. Strict applies the Windows rules everywhere, so an export is portable between machines. EXPORT_FILENAME_MODE=strict
# Reflect · indie build A local notes app whose hard problems are links that survive a rename, search that answers while you type on ten thousand notes, and an export to plain files that does not mangle titles the operating system will not accept. 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 | | --- | --- | --- | | Shell | Tauri 2 with React and TypeScript | a notes app that cannot reach the filesystem cannot export properly | | Storage | SQLite, one file, with notes identified by an id and never by a title | renaming a note must not break anything that points at it | | Links | Parsed on save into a links table, backlinks derived from it | recomputing on read means a graph that gets slower as it gets useful | | Search | An index built on write with a stated latency budget asserted in tests | the whole promise is that it answers before you stop typing | | Export | Markdown files with front matter carrying the id and the true title | the filesystem is not allowed to be the source of truth | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Builds the front end and runs the corpus and performance tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **The Rust toolchain** · free - Why: Tauri 2 needs it, and the global shortcut and warm capture window live on that side. - Get it: rustup.rs, then the platform build dependencies Tauri lists. - Verify: cargo --version prints a version and a dev build opens a window - [ ] **A generated corpus of ten thousand notes** · free - Why: Every performance claim in this build is meaningless on the twelve notes you wrote by hand, and the corpus is what makes the budgets falsifiable. - Get it: Write the generator in phase three, with realistic note lengths, link density and title collisions. - Verify: The corpus loads and the search and backlink tests run against it - [ ] **Your latency budgets, written before tuning** · free - Why: Search response and shortcut-to-cursor are the two numbers this app lives or dies by, and choosing them after measuring is just describing what you built. - Get it: Pick two numbers, write them in the documentation with the reason, and assert them in tests. - Verify: Both numbers appear in docs and in failing tests before the work starts ## 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. - Native mobile clients. - Collaborative editing and sharing. - Calendar integration. - native mobile clients - conflict-safe multi-device sync - mature importers and exporters - collaborative editing and sharing - calendar-provider edge cases and timezone correctness If one of those is essential to you, that is the reason to keep paying for Reflect, and the README should say so rather than pretend.
# Build brief · Reflect 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-first networked notes app where every note can leave as a plain Markdown file with its links intact to replace Reflect. 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) - Shell: Tauri 2 with React and TypeScript · a notes app that cannot reach the filesystem cannot export properly - Storage: SQLite, one file, with notes identified by an id and never by a title · renaming a note must not break anything that points at it - Links: Parsed on save into a links table, backlinks derived from it · recomputing on read means a graph that gets slower as it gets useful - Search: An index built on write with a stated latency budget asserted in tests · the whole promise is that it answers before you stop typing - Export: Markdown files with front matter carrying the id and the true title · the filesystem is not allowed to be the source of truth ### Data model (create this before Phase 1) - notes(id, title, body, created_at, updated_at, deleted_at, daily_date) · id is stable for the note's life and never derived from the title - aliases(note_id, alias, created_at) · every title a note has ever had, so an old link still resolves - links(source_id, target_id, target_text, resolved, ordinal) · resolved false means a link to a note that does not exist yet - tags(note_id, tag) · flat, because a hierarchy is a second feature pretending to be one - search_index(note_id, ...) · rebuilt on write, with the shape chosen after measuring on a real corpus - export_manifest(note_id, filename, title, exported_at, collision_suffix) · the mapping between ids and the names the filesystem allowed - The title is a label. Everything that refers to a note refers to its id, which is why renaming is free and export needs a manifest. ### Phase 1 · Ids, not titles Build: Give every note an id at creation. Store the title as an ordinary field. When a title changes, record the previous one in the aliases table. Resolve a written link by looking for a current title first, then an alias, and record which one matched. Trap: Keying notes by title, as the file-per-note tools do, means renaming one either breaks every link pointing at it or forces a rewrite of every file that mentions it, and the copies you already exported keep the old name forever. Give each note an id, keep the old titles as aliases, and let the rename be a single row update. Done when: Renaming a note leaves every existing link resolving to it, tested with links written before the rename. A link written using a title the note had two renames ago still resolves, via the alias table. Two notes cannot hold the same current title, and attempting it is refused with the conflicting note shown. Deleting a note leaves links to it unresolved and visible rather than silently removed. Do not build yet: No backlinks, search, capture or export yet. ### Phase 2 · Links and backlinks, computed once Build: Parse links out of the body on save into the links table, replacing that note's rows in one transaction. Derive backlinks by querying the table. An unresolved link renders differently and creating the missing note resolves every link that was waiting for it. Trap: Computing backlinks by scanning every note's body when a note is opened is fine at two hundred notes and unusable at five thousand, and because it degrades gradually you will not notice until the app you rely on daily has become slow. Write the links table on save and query it on read. Done when: Opening a note with two hundred backlinks renders them within a stated budget on a corpus of ten thousand notes, measured and asserted. Creating a note whose title matches existing unresolved links resolves all of them and their sources show the new backlink, tested. Editing a body to remove a link removes exactly that row and no others. The unresolved link count is visible somewhere, because it is the most useful to-do list in a notes app. Do not build yet: No search, capture or export yet. ### Phase 3 · Search that answers while you type Build: Choose a latency budget, generate a corpus of ten thousand realistic notes, and assert the budget in the test suite before tuning anything. Index on write. Rank by title match, then recency, then body match, and show the matching line rather than the first line. Trap: Building the index on read, or rebuilding it wholesale on every save, makes typing in a large note stutter, and the reflex fix of debouncing the save is how people lose the paragraph they just wrote. Index incrementally on write, off the interaction path, and never trade a save for a search. Done when: A query against ten thousand generated notes returns within the stated budget, asserted in the suite rather than observed once. Typing in a large note does not delay keystrokes while indexing, measured. Results show the matching line with the term in context, not the note's first line. A search for a term that appears only in a title outranks one where it appears once in a long body. Do not build yet: No capture shortcuts or export yet. ### Phase 4 · Capture in under a second Build: A global shortcut opens a small capture window that is already focused, writes into today's daily note by default, and closes on save. The window must be ready without waiting for the main application to start. Everything on this path is keyboard-driven. Trap: Making capture open the full application means a cold start, a window arranging itself, and a cursor that arrives second, which is long enough for the thought to go, so the feature quietly stops being used. Keep a warm capture window, focus the input before painting anything else, and measure the time from shortcut to typable cursor. Done when: The time from pressing the shortcut to a typable cursor is measured and under the stated budget, on a cold application. The capture window saves and closes with a single keystroke and the text appears in today's daily note. The shortcut works when the main window is closed. Every action in the capture path has a keyboard route and none requires a pointer. Do not build yet: No export or import yet. ### Phase 5 · Markdown files the filesystem will accept Build: Export one Markdown file per note with front matter carrying the id, the true title and the aliases. Derive the filename by slugifying the title against the strictest rules you must support: Microsoft documents the reserved characters as less than, greater than, colon, double quote, forward slash, backslash, vertical bar, question mark and asterisk, reserves the device names CON, PRN, AUX, NUL, COM1 to COM9 and LPT1 to LPT9 including when followed by an extension so that NUL.txt is still NUL, says not to end a name with a space or a period, and says not to assume case sensitivity (checked against their naming documentation). Record every mapping in a manifest, and import reads the manifest and the front matter rather than the filenames. Trap: Naming each exported file after its title works on your machine and breaks on a colleague's: a note called 'OS/2 vs NT' becomes a path with a directory in it, a note called 'NUL' cannot be created on Windows at all, and 'Meeting' and 'meeting' overwrite each other on a default macOS volume, so notes silently vanish from the export. Slugify against the strict rules, resolve collisions with a recorded suffix, and keep the true title in front matter where it cannot be mangled. Done when: Exporting a corpus containing a title with a slash, a title that is a reserved device name, a title ending in a period, and two titles differing only in case produces one file per note with no collisions and no losses, verified by counting files against notes. Every exported file's front matter contains the note id and the unmangled title. Re-importing an export reproduces the same ids, links and aliases, tested by a round trip. The manifest maps every id to its filename and records each collision suffix and why it was applied. Do not build yet: Nothing after this. ### Out of scope (and why) - Conflict-safe multi-device sync, which is a distributed systems project and the single largest thing you are not getting for free here. - Native mobile clients, which are their own build, their own store review and their own capture story. - Collaborative editing and sharing, which changes the data model from the first line rather than being added later. - Calendar integration, which is a provider-specific timezone problem well out of proportion to the feature. ### README must contain - That notes are identified by an id and titles are labels, which is why renaming never breaks a link - That every past title is kept as an alias so older links keep resolving - The search and capture latency budgets, and that both are asserted in the test suite on a ten thousand note corpus - The filename rules the export follows, why reserved names and reserved characters exist, and where the manifest lives - That an export round trips: ids, links and aliases survive a wipe and a re-import
# Agent instructions · Reflect indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Tauri 2 with React and TypeScript, SQLite, one file, with notes identified by an id and never by a title, Parsed on save into a links table, backlinks derived from it, An index built on write with a stated latency budget asserted in tests, Markdown files with front matter carrying the id and the true title. 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 - Keying notes by title, as the file-per-note tools do, means renaming one either breaks every link pointing at it or forces a rewrite of every file that mentions it, and the copies you already exported keep the old name forever. Give each note an id, keep the old titles as aliases, and let the rename be a single row update. - Computing backlinks by scanning every note's body when a note is opened is fine at two hundred notes and unusable at five thousand, and because it degrades gradually you will not notice until the app you rely on daily has become slow. Write the links table on save and query it on read. - Building the index on read, or rebuilding it wholesale on every save, makes typing in a large note stutter, and the reflex fix of debouncing the save is how people lose the paragraph they just wrote. Index incrementally on write, off the interaction path, and never trade a save for a search. - Making capture open the full application means a cold start, a window arranging itself, and a cursor that arrives second, which is long enough for the thought to go, so the feature quietly stops being used. Keep a warm capture window, focus the input before painting anything else, and measure the time from shortcut to typable cursor.
# Build plan · Reflect A local notes app whose hard problems are links that survive a rename, search that answers while you type on ten thousand notes, and an export to plain files that does not mangle titles the operating system will not accept. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Ids, not titles A note keeps its identity through every rename, and old links still resolve. ### Steps 1. Assign an id at creation Everything downstream depends on this one decision. Files: `src/notes/create.ts`, `src/db/schema.sql` 2. Keep every past title as an alias Renaming a note should not orphan the sentence that mentioned it. Files: `src/notes/rename.ts`, `tests/rename-links.test.ts` 3. Refuse duplicate current titles Ambiguous links are worse than rejected ones. Files: `src/notes/validate.ts` ### Done when - [ ] Links survive a rename - [ ] A link using a two-renames-ago title resolves - [ ] Duplicate current titles are refused - [ ] Links to a deleted note show as unresolved ### Watch out - Keying notes by title, as the file-per-note tools do, means renaming one either breaks every link pointing at it or forces a rewrite of every file that mentions it, and the copies you already exported keep the old name forever. Give each note an id, keep the old titles as aliases, and let the rename be a single row update. ## Phase 2 · Links and backlinks, computed once Backlinks appear instantly and a link to a note that does not exist yet is a first-class thing. ### Steps 1. Rewrite a note's link rows on save One transaction, so a crash cannot leave half a graph. Files: `src/links/index.ts` 2. Resolve waiting links when a note appears The moment that makes the unresolved link useful rather than annoying. Files: `src/links/resolve.ts`, `tests/resolve-on-create.test.ts` 3. Measure backlink rendering on a real corpus Ten thousand notes, not the twelve you wrote by hand. Files: `tests/backlink-perf.test.ts` ### Done when - [ ] Two hundred backlinks render within the budget at ten thousand notes - [ ] Creating a note resolves links that were waiting - [ ] Removing a link removes exactly one row - [ ] Unresolved links are visible and countable ### Watch out - Computing backlinks by scanning every note's body when a note is opened is fine at two hundred notes and unusable at five thousand, and because it degrades gradually you will not notice until the app you rely on daily has become slow. Write the links table on save and query it on read. ## Phase 3 · Search that answers while you type A query over ten thousand notes returns ranked results inside a budget you wrote down first. ### Steps 1. Write the budget down before tuning A number chosen after measurement is a description, not a target. Files: `tests/search-perf.test.ts`, `docs/budgets.md` 2. Generate a realistic corpus Ten thousand notes with real length distribution and link density. Files: `scripts/generate-corpus.ts` 3. Index off the typing path Never let indexing be a reason to delay a save. Files: `src/search/index.ts` ### Done when - [ ] Queries meet the budget at ten thousand notes, asserted - [ ] Typing is not delayed by indexing - [ ] Results show the matching line in context - [ ] Title matches outrank incidental body matches ### Watch out - Building the index on read, or rebuilding it wholesale on every save, makes typing in a large note stutter, and the reflex fix of debouncing the save is how people lose the paragraph they just wrote. Index incrementally on write, off the interaction path, and never trade a save for a search. ## Phase 4 · Capture in under a second A thought can be written before it is lost, from anywhere, with no navigation. ### Steps 1. Keep a warm capture window The whole feature is the latency, and the latency is the warm window. Files: `src-tauri/src/capture.rs` 2. Measure shortcut to cursor The number that decides whether anyone uses this. Files: `tests/capture-latency.test.ts` 3. Default to the daily note One fewer decision between a thought and a record of it. Files: `src/capture/target.ts` ### Done when - [ ] Shortcut to typable cursor is within budget from cold - [ ] One keystroke saves and closes - [ ] The shortcut works with the main window closed - [ ] Every capture action has a keyboard route ### Watch out - Making capture open the full application means a cold start, a window arranging itself, and a cursor that arrives second, which is long enough for the thought to go, so the feature quietly stops being used. Keep a warm capture window, focus the input before painting anything else, and measure the time from shortcut to typable cursor. ## Not in this build - Multi-device sync. - Native mobile clients. - Collaborative editing and sharing. - Calendar integration. ## After v1, if you want it - A graph view, which needs nothing new because the links table already exists - Transclusion of one note inside another, which is a render-time resolution of an id you already store - A second export format such as a single archive file, reusing the manifest
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The SQLite file and the search index. Back this up. DATA_DIR=~/.local/share/notes # Required. Markdown files and the manifest. Overwritten on each export. EXPORT_DIR=~/Documents/notes-export # Required. The global shortcut. Conflicts with another app are the commonest setup failure. CAPTURE_SHORTCUT=CmdOrCtrl+Shift+N # Required. Asserted in the suite against the generated corpus. SEARCH_BUDGET_MS=80 # Required. Shortcut to typable cursor, measured from a cold application. CAPTURE_BUDGET_MS=400 # Required. Strict applies the Windows rules everywhere, so an export is portable between machines. EXPORT_FILENAME_MODE=strict
# Reflect · product brief ## Problem Reflect's solo core is compact: build a local-first networked notes app with fast capture, full-text search, backlinks, tags, and Markdown export. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on polish, sync, importers. ## Product outcome A notes app that stays fast at ten thousand notes, never breaks a link when you rename something, captures a thought before you lose it, and exports to files that open on any machine. ## 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. - Native mobile clients. - Collaborative editing and sharing. - Calendar integration. - native mobile clients - conflict-safe multi-device sync - mature importers and exporters - collaborative editing and sharing - calendar-provider edge cases and timezone correctness ## Success criteria - Links are confirmed to survive a rename and a two-rename-old alias - Backlink and search budgets are confirmed on a ten thousand note corpus - Capture latency is confirmed from a cold start - Hostile titles are confirmed to export without collision or loss - An export is confirmed to round trip ids, links and aliases
# Build brief · Reflect 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-first networked notes app where every note can leave as a plain Markdown file with its links intact to replace Reflect. 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) - Shell: Tauri 2 with React and TypeScript · a notes app that cannot reach the filesystem cannot export properly - Storage: SQLite, one file, with notes identified by an id and never by a title · renaming a note must not break anything that points at it - Links: Parsed on save into a links table, backlinks derived from it · recomputing on read means a graph that gets slower as it gets useful - Search: An index built on write with a stated latency budget asserted in tests · the whole promise is that it answers before you stop typing - Export: Markdown files with front matter carrying the id and the true title · the filesystem is not allowed to be the source of truth ### Data model (create this before Phase 1) - notes(id, title, body, created_at, updated_at, deleted_at, daily_date) · id is stable for the note's life and never derived from the title - aliases(note_id, alias, created_at) · every title a note has ever had, so an old link still resolves - links(source_id, target_id, target_text, resolved, ordinal) · resolved false means a link to a note that does not exist yet - tags(note_id, tag) · flat, because a hierarchy is a second feature pretending to be one - search_index(note_id, ...) · rebuilt on write, with the shape chosen after measuring on a real corpus - export_manifest(note_id, filename, title, exported_at, collision_suffix) · the mapping between ids and the names the filesystem allowed - The title is a label. Everything that refers to a note refers to its id, which is why renaming is free and export needs a manifest. ### Phase 1 · Ids, not titles Build: Give every note an id at creation. Store the title as an ordinary field. When a title changes, record the previous one in the aliases table. Resolve a written link by looking for a current title first, then an alias, and record which one matched. Trap: Keying notes by title, as the file-per-note tools do, means renaming one either breaks every link pointing at it or forces a rewrite of every file that mentions it, and the copies you already exported keep the old name forever. Give each note an id, keep the old titles as aliases, and let the rename be a single row update. Done when: Renaming a note leaves every existing link resolving to it, tested with links written before the rename. A link written using a title the note had two renames ago still resolves, via the alias table. Two notes cannot hold the same current title, and attempting it is refused with the conflicting note shown. Deleting a note leaves links to it unresolved and visible rather than silently removed. Do not build yet: No backlinks, search, capture or export yet. ### Phase 2 · Links and backlinks, computed once Build: Parse links out of the body on save into the links table, replacing that note's rows in one transaction. Derive backlinks by querying the table. An unresolved link renders differently and creating the missing note resolves every link that was waiting for it. Trap: Computing backlinks by scanning every note's body when a note is opened is fine at two hundred notes and unusable at five thousand, and because it degrades gradually you will not notice until the app you rely on daily has become slow. Write the links table on save and query it on read. Done when: Opening a note with two hundred backlinks renders them within a stated budget on a corpus of ten thousand notes, measured and asserted. Creating a note whose title matches existing unresolved links resolves all of them and their sources show the new backlink, tested. Editing a body to remove a link removes exactly that row and no others. The unresolved link count is visible somewhere, because it is the most useful to-do list in a notes app. Do not build yet: No search, capture or export yet. ### Phase 3 · Search that answers while you type Build: Choose a latency budget, generate a corpus of ten thousand realistic notes, and assert the budget in the test suite before tuning anything. Index on write. Rank by title match, then recency, then body match, and show the matching line rather than the first line. Trap: Building the index on read, or rebuilding it wholesale on every save, makes typing in a large note stutter, and the reflex fix of debouncing the save is how people lose the paragraph they just wrote. Index incrementally on write, off the interaction path, and never trade a save for a search. Done when: A query against ten thousand generated notes returns within the stated budget, asserted in the suite rather than observed once. Typing in a large note does not delay keystrokes while indexing, measured. Results show the matching line with the term in context, not the note's first line. A search for a term that appears only in a title outranks one where it appears once in a long body. Do not build yet: No capture shortcuts or export yet. ### Phase 4 · Capture in under a second Build: A global shortcut opens a small capture window that is already focused, writes into today's daily note by default, and closes on save. The window must be ready without waiting for the main application to start. Everything on this path is keyboard-driven. Trap: Making capture open the full application means a cold start, a window arranging itself, and a cursor that arrives second, which is long enough for the thought to go, so the feature quietly stops being used. Keep a warm capture window, focus the input before painting anything else, and measure the time from shortcut to typable cursor. Done when: The time from pressing the shortcut to a typable cursor is measured and under the stated budget, on a cold application. The capture window saves and closes with a single keystroke and the text appears in today's daily note. The shortcut works when the main window is closed. Every action in the capture path has a keyboard route and none requires a pointer. Do not build yet: No export or import yet. ### Phase 5 · Markdown files the filesystem will accept Build: Export one Markdown file per note with front matter carrying the id, the true title and the aliases. Derive the filename by slugifying the title against the strictest rules you must support: Microsoft documents the reserved characters as less than, greater than, colon, double quote, forward slash, backslash, vertical bar, question mark and asterisk, reserves the device names CON, PRN, AUX, NUL, COM1 to COM9 and LPT1 to LPT9 including when followed by an extension so that NUL.txt is still NUL, says not to end a name with a space or a period, and says not to assume case sensitivity (checked against their naming documentation). Record every mapping in a manifest, and import reads the manifest and the front matter rather than the filenames. Trap: Naming each exported file after its title works on your machine and breaks on a colleague's: a note called 'OS/2 vs NT' becomes a path with a directory in it, a note called 'NUL' cannot be created on Windows at all, and 'Meeting' and 'meeting' overwrite each other on a default macOS volume, so notes silently vanish from the export. Slugify against the strict rules, resolve collisions with a recorded suffix, and keep the true title in front matter where it cannot be mangled. Done when: Exporting a corpus containing a title with a slash, a title that is a reserved device name, a title ending in a period, and two titles differing only in case produces one file per note with no collisions and no losses, verified by counting files against notes. Every exported file's front matter contains the note id and the unmangled title. Re-importing an export reproduces the same ids, links and aliases, tested by a round trip. The manifest maps every id to its filename and records each collision suffix and why it was applied. Do not build yet: Nothing after this. ### Out of scope (and why) - Conflict-safe multi-device sync, which is a distributed systems project and the single largest thing you are not getting for free here. - Native mobile clients, which are their own build, their own store review and their own capture story. - Collaborative editing and sharing, which changes the data model from the first line rather than being added later. - Calendar integration, which is a provider-specific timezone problem well out of proportion to the feature. ### README must contain - That notes are identified by an id and titles are labels, which is why renaming never breaks a link - That every past title is kept as an alias so older links keep resolving - The search and capture latency budgets, and that both are asserted in the test suite on a ten thousand note corpus - The filename rules the export follows, why reserved names and reserved characters exist, and where the manifest lives - That an export round trips: ids, links and aliases survive a wipe and a re-import
# Architecture · Reflect ## Stack | Part | Choice | Why | | --- | --- | --- | | Shell | Tauri 2 with React and TypeScript | a notes app that cannot reach the filesystem cannot export properly | | Storage | SQLite, one file, with notes identified by an id and never by a title | renaming a note must not break anything that points at it | | Links | Parsed on save into a links table, backlinks derived from it | recomputing on read means a graph that gets slower as it gets useful | | Search | An index built on write with a stated latency budget asserted in tests | the whole promise is that it answers before you stop typing | | Export | Markdown files with front matter carrying the id and the true title | the filesystem is not allowed to be the source of truth | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Notes | Ids, titles and aliases. | Any store, provided identity never becomes the title. | | Links | Parse on save, backlinks by query. | Any parser, provided the links table is written in one transaction. | | Search | Incremental index and ranking. | Any index, provided the corpus budget test still passes. | | Capture | Global shortcut and warm window. | Any implementation that meets the latency budget from cold. | | Export | Strict filenames, front matter, manifest. | Any writer, provided the hostile-titles test and the round trip pass. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATA_DIR` · required · The SQLite file and the search index. Back this up. - `EXPORT_DIR` · required · Markdown files and the manifest. Overwritten on each export. - `CAPTURE_SHORTCUT` · required · The global shortcut. Conflicts with another app are the commonest setup failure. - `SEARCH_BUDGET_MS` · required · Asserted in the suite against the generated corpus. - `CAPTURE_BUDGET_MS` · required · Shortcut to typable cursor, measured from a cold application. - `EXPORT_FILENAME_MODE` · required · Strict applies the Windows rules everywhere, so an export is portable between machines. ## 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 · Reflect product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Tauri 2 with React and TypeScript, SQLite, one file, with notes identified by an id and never by a title, Parsed on save into a links table, backlinks derived from it, An index built on write with a stated latency budget asserted in tests, Markdown files with front matter carrying the id and the true title. - 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 - Keying notes by title, as the file-per-note tools do, means renaming one either breaks every link pointing at it or forces a rewrite of every file that mentions it, and the copies you already exported keep the old name forever. Give each note an id, keep the old titles as aliases, and let the rename be a single row update. - Computing backlinks by scanning every note's body when a note is opened is fine at two hundred notes and unusable at five thousand, and because it degrades gradually you will not notice until the app you rely on daily has become slow. Write the links table on save and query it on read. - Building the index on read, or rebuilding it wholesale on every save, makes typing in a large note stutter, and the reflex fix of debouncing the save is how people lose the paragraph they just wrote. Index incrementally on write, off the interaction path, and never trade a save for a search. - Making capture open the full application means a cold start, a window arranging itself, and a cursor that arrives second, which is long enough for the thought to go, so the feature quietly stops being used. Keep a warm capture window, focus the input before painting anything else, and measure the time from shortcut to typable cursor. - Naming each exported file after its title works on your machine and breaks on a colleague's: a note called 'OS/2 vs NT' becomes a path with a directory in it, a note called 'NUL' cannot be created on Windows at all, and 'Meeting' and 'meeting' overwrite each other on a default macOS volume, so notes silently vanish from the export. Slugify against the strict rules, resolve collisions with a recorded suffix, and keep the true title in front matter where it cannot be mangled.
# Delivery milestones · Reflect Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Ids, not titles A note keeps its identity through every rename, and old links still resolve. ### Steps 1. Assign an id at creation Everything downstream depends on this one decision. Files: `src/notes/create.ts`, `src/db/schema.sql` 2. Keep every past title as an alias Renaming a note should not orphan the sentence that mentioned it. Files: `src/notes/rename.ts`, `tests/rename-links.test.ts` 3. Refuse duplicate current titles Ambiguous links are worse than rejected ones. Files: `src/notes/validate.ts` ### Done when - [ ] Links survive a rename - [ ] A link using a two-renames-ago title resolves - [ ] Duplicate current titles are refused - [ ] Links to a deleted note show as unresolved ### Watch out - Keying notes by title, as the file-per-note tools do, means renaming one either breaks every link pointing at it or forces a rewrite of every file that mentions it, and the copies you already exported keep the old name forever. Give each note an id, keep the old titles as aliases, and let the rename be a single row update. ## M2 · Links and backlinks, computed once Backlinks appear instantly and a link to a note that does not exist yet is a first-class thing. ### Steps 1. Rewrite a note's link rows on save One transaction, so a crash cannot leave half a graph. Files: `src/links/index.ts` 2. Resolve waiting links when a note appears The moment that makes the unresolved link useful rather than annoying. Files: `src/links/resolve.ts`, `tests/resolve-on-create.test.ts` 3. Measure backlink rendering on a real corpus Ten thousand notes, not the twelve you wrote by hand. Files: `tests/backlink-perf.test.ts` ### Done when - [ ] Two hundred backlinks render within the budget at ten thousand notes - [ ] Creating a note resolves links that were waiting - [ ] Removing a link removes exactly one row - [ ] Unresolved links are visible and countable ### Watch out - Computing backlinks by scanning every note's body when a note is opened is fine at two hundred notes and unusable at five thousand, and because it degrades gradually you will not notice until the app you rely on daily has become slow. Write the links table on save and query it on read. ## M3 · Search that answers while you type A query over ten thousand notes returns ranked results inside a budget you wrote down first. ### Steps 1. Write the budget down before tuning A number chosen after measurement is a description, not a target. Files: `tests/search-perf.test.ts`, `docs/budgets.md` 2. Generate a realistic corpus Ten thousand notes with real length distribution and link density. Files: `scripts/generate-corpus.ts` 3. Index off the typing path Never let indexing be a reason to delay a save. Files: `src/search/index.ts` ### Done when - [ ] Queries meet the budget at ten thousand notes, asserted - [ ] Typing is not delayed by indexing - [ ] Results show the matching line in context - [ ] Title matches outrank incidental body matches ### Watch out - Building the index on read, or rebuilding it wholesale on every save, makes typing in a large note stutter, and the reflex fix of debouncing the save is how people lose the paragraph they just wrote. Index incrementally on write, off the interaction path, and never trade a save for a search. ## M4 · Capture in under a second A thought can be written before it is lost, from anywhere, with no navigation. ### Steps 1. Keep a warm capture window The whole feature is the latency, and the latency is the warm window. Files: `src-tauri/src/capture.rs` 2. Measure shortcut to cursor The number that decides whether anyone uses this. Files: `tests/capture-latency.test.ts` 3. Default to the daily note One fewer decision between a thought and a record of it. Files: `src/capture/target.ts` ### Done when - [ ] Shortcut to typable cursor is within budget from cold - [ ] One keystroke saves and closes - [ ] The shortcut works with the main window closed - [ ] Every capture action has a keyboard route ### Watch out - Making capture open the full application means a cold start, a window arranging itself, and a cursor that arrives second, which is long enough for the thought to go, so the feature quietly stops being used. Keep a warm capture window, focus the input before painting anything else, and measure the time from shortcut to typable cursor. ## M5 · Markdown files the filesystem will accept (production only) Every note exports as a plain Markdown file, on any operating system, without losing its identity. ### Steps 1. Slugify against the strict rules Reserved characters, reserved device names, no trailing dot or space. Files: `src/export/filename.ts`, `tests/filename-rules.test.ts` 2. Put identity in front matter, not in the name The filename is a convenience; the id is the note. Files: `src/export/markdown.ts` 3. Round trip the whole corpus Export, wipe, import, compare ids, links and aliases. Files: `tests/export-roundtrip.test.ts` ### Done when - [ ] Hostile titles export with no collisions or losses - [ ] Front matter carries id, title and aliases - [ ] A round trip reproduces ids, links and aliases - [ ] The manifest records every collision suffix ### Watch out - Naming each exported file after its title works on your machine and breaks on a colleague's: a note called 'OS/2 vs NT' becomes a path with a directory in it, a note called 'NUL' cannot be created on Windows at all, and 'Meeting' and 'meeting' overwrite each other on a default macOS volume, so notes silently vanish from the export. Slugify against the strict rules, resolve collisions with a recorded suffix, and keep the true title in front matter where it cannot be mangled.
# Operations · Reflect ## Backup The data directory, and separately a periodic Markdown export, because a plain-text copy is readable by anything and does not depend on this application still building in three years. That second copy is the real insurance. ## Restore Restore and confirm the search index rebuilds and that a sample of links still resolve. Then restore instead from the Markdown export and confirm the round trip reproduces ids and links, because that is the path you will actually use one day. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a local tool this is the suite: search and capture budgets on the generated corpus, the hostile-titles export test, and the round trip. A budget test that starts failing is the app getting slower before you feel it. ## Incident checklist Notes missing after an export is the incident this design anticipates, and the manifest plus the file count assertion is what turns it from a mystery into a named collision. 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 - [ ] Links are confirmed to survive a rename and a two-rename-old alias - [ ] Backlink and search budgets are confirmed on a ten thousand note corpus - [ ] Capture latency is confirmed from a cold start - [ ] Hostile titles are confirmed to export without collision or loss - [ ] An export is confirmed to round trip ids, links and aliases ## Launch constraint Do not market omitted Reflect 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 SQLite file and the search index. Back this up. DATA_DIR=~/.local/share/notes # Required. Markdown files and the manifest. Overwritten on each export. EXPORT_DIR=~/Documents/notes-export # Required. The global shortcut. Conflicts with another app are the commonest setup failure. CAPTURE_SHORTCUT=CmdOrCtrl+Shift+N # Required. Asserted in the suite against the generated corpus. SEARCH_BUDGET_MS=80 # Required. Shortcut to typable cursor, measured from a cold application. CAPTURE_BUDGET_MS=400 # Required. Strict applies the Windows rules everywhere, so an export is portable between machines. EXPORT_FILENAME_MODE=strict
$ choose a build depth, inspect the files, then open the complete pack in your agent
Reflect: 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
xcalendar-provider edge cases and timezone correctness
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Reflect pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| reflect | — | $10/user | One plan with networked notes, web clipper, Kindle sync, end-to-end encryption, iOS app and AI; no numeric note/storage caps published |
free tierno permanent free tier; 14-day free trial
billingannual only on the public page ($120/year = $10/month); auto-renews
hidden costsNo monthly checkout price or numeric usage/storage limits are published. Paid fees are generally non-refundable.
verified 2026-08-13 · source ↗
Vibecode Reflect
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Reflect replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Reflect cost?
Reflect'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 Reflect?
Honestly: native mobile clients; conflict-safe multi-device sync; mature importers and exporters; collaborative editing and sharing; calendar-provider edge cases and timezone correctness. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Reflect?
Yes: Logseq (Daily notes, backlinks and block references; calendar context is native, AI is optional.) Obsidian (Fast local notes and backlinks, with calendar and AI supplied by one-click plugins.) The prompt is for when you want it exactly your way.