The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Lex, write and revise long-form documents with comments, version history, and AI actions. The hard boundary is collaborative editor polish, sync, and embedded model access, plus workflow, data, and model tuning.
You are building a lean indie version of Lex. 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 ===== # Lex · indie build A document editor whose AI reads what you wrote and responds as a reader would, in margin comments you can accept, argue with or ignore, with the constraint that it never edits your text and never writes a sentence you then keep. Estimated effort: **multi-day**. 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 | | --- | --- | --- | | Editor | ProseMirror over a document model with stable positions | comments anchor to ranges and those anchors must survive editing | | Runtime | TypeScript with a local application and SQLite | drafts are the most private thing most people write | | Comments | Generated on demand, anchored to ranges, never applied automatically | the whole premise is that the writing stays yours | | Versions | Every session's text kept, diffable | seeing how a draft changed is most of the value of keeping drafts | | Models | Local by default, hosted as an explicit per-document choice | an unfinished draft going to a service should be a decision | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the editor and the local application. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A local model runner** · free, needs memory and patience - Why: The default policy is local, and a draft is the most private writing most people do. - Get it: Ollama or llama.cpp with a model that follows structured output instructions reliably. - Verify: It returns valid structured output for a comment request ten times in a row - [ ] **A real draft of yours, at least two thousand words** · free - Why: Comment quality, coverage and anchor drift are only observable on real writing with real weaknesses. - Get it: Something you are actually working on and would revise. - Verify: It is long enough that comment coverage across sections is testable - [ ] **Your editor's position model** · free, a couple of hours of reading - Why: Everything about comments depends on positions that survive editing, and this is the hardest decision to change later. - Get it: Read how your editor framework represents positions and how they map through changes before writing anchor code. - Verify: A written note explains how a position maps through an insertion above it ## 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: - Collaborative editing. - Publishing and distribution. - Any generation of body text. - Style enforcement against a house guide. - collaborative editor polish, sync, and embedded model access - proprietary ranking data - brand-trained models - team workflows - large template libraries If one of those is essential to you, that is the reason to keep paying for Lex, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Lex 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 writing editor where the AI comments in the margin instead of writing for you to replace Lex. 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) - Editor: ProseMirror over a document model with stable positions · comments anchor to ranges and those anchors must survive editing - Runtime: TypeScript with a local application and SQLite · drafts are the most private thing most people write - Comments: Generated on demand, anchored to ranges, never applied automatically · the whole premise is that the writing stays yours - Versions: Every session's text kept, diffable · seeing how a draft changed is most of the value of keeping drafts - Models: Local by default, hosted as an explicit per-document choice · an unfinished draft going to a service should be a decision ### Data model (create this before Phase 1) - documents(id, title, created_at, updated_at, model_policy) · model_policy is local or a named hosted model, per document - versions(id, document_id, body, word_count, at) · a snapshot per writing session, not per keystroke - comments(id, document_id, version_id, from_pos, to_pos, kind, body, state, at) · kind is question, observation, structure or reader_reaction - comment_state · open, addressed, dismissed. Dismissed is kept, because dismissing the same note three times is a signal - anchors(comment_id, quoted_text, prefix, suffix) · so a comment can be relocated after the text moves rather than becoming orphaned - There is deliberately no table for AI-generated body text, because the tool does not produce any. ### Phase 1 · An editor that keeps your words Build: A document editor with a stable position model, autosave, and a version snapshot per writing session. Verify recovery from a hard kill mid-sentence. Trap: Snapshotting per keystroke fills the database with thousands of near-identical versions and makes the history unusable for the thing it is for, which is seeing how the draft changed. Snapshot per session, defined by a gap in activity, and keep the current text saved continuously and separately. Done when: An hour of writing survives a hard kill, tested by killing the process mid-sentence. Versions accumulate per session rather than per keystroke, verified by writing in two sessions and seeing two versions. A diff between versions shows what changed. Positions survive a reload. Do not build yet: No comments yet. ### Phase 2 · Comments in the margin, anchored to the text Build: Generate comments against a selection or the whole draft, anchored to specific ranges, displayed in the margin. Comments are questions and observations, never rewrites. Prohibit replacement text in the output structurally. Trap: A model asked to comment will offer a rewritten sentence unprompted, and the moment a rewrite is one click from being applied the tool has become a writing tool rather than a reading one. Make the output structure hold no replacement text field at all, so there is nothing to apply. Done when: Comments generate anchored to specific ranges and display beside them. A comment reads as a question or observation rather than a correction, checked across ten real comments. The output structure cannot carry replacement text, verified by inspecting the schema. Comments on a long draft cover the whole document rather than only the opening. Do not build yet: No anchor drift handling yet. ### Phase 3 · Comments that survive editing Build: Store a quoted excerpt with surrounding context per comment. After an edit, relocate by position where possible and by quote match where the position moved. Mark a comment orphaned when its text is gone rather than pointing it somewhere wrong. Trap: Comments anchored only to character offsets drift onto unrelated text after an edit above them, and a note about a weak argument appearing next to a paragraph it does not describe is worse than no note at all. Keep a quoted excerpt and relocate by content, and orphan the comment when the text no longer exists. Done when: Editing above a comment leaves it on the correct text. Rewriting the commented paragraph orphans the comment rather than moving it elsewhere. An orphaned comment shows the text it referred to. Deleting the passage entirely removes or orphans its comments rather than leaving them pointing at nothing. Do not build yet: No history or model choice yet. ### Phase 4 · Argue with a note, and keep the argument Build: Let a comment be replied to, addressed or dismissed, keeping all three states. Show dismissal patterns: the same note dismissed repeatedly is either a persistent habit you have chosen or a suggestion the tool should stop making. Trap: Deleting dismissed comments throws away the most useful signal in the system. A note you dismiss every time is either a deliberate stylistic choice, which the tool should learn to stop raising, or a blind spot, which is worth knowing. Keep dismissals and surface the pattern. Done when: A comment can be replied to, addressed or dismissed and the state persists. Dismissed comments are retained and countable by kind. A repeated dismissal pattern is surfaced. Marking a comment addressed does not change your text, because nothing in the tool does. Do not build yet: No model policy or export yet. ### Phase 5 · Where the draft goes, per document Build: Set a model policy per document: local only, or a named hosted model. Enforce it at the call site so a local-only document cannot be sent. Show the policy in the editor and record which model produced each comment. Trap: A global setting means one careless change sends every draft, including the ones you had decided were local only. Put the policy on the document, enforce it where the request is made rather than in the interface, and test that a local-only document raises rather than sends. Done when: A local-only document cannot be sent, verified by attempting it and observing a raised error rather than a request. The policy is visible while writing. Each comment records the model that produced it. Changing a document's policy warns that previous comments were produced under a different one. Do not build yet: Export and search come last. ### Phase 6 · Getting the writing out Build: Export to Markdown and to a document format, with comments as annotations or omitted. Search across documents and versions. Confirm the exported text is exactly what you wrote with no generated content anywhere in it. Trap: An export that silently applies a suggestion, or includes a phrase from a comment, breaks the one promise the tool makes. Compare the export byte for byte against the stored document text and treat any difference as a defect. Done when: Export produces text byte-identical to the stored document, asserted in a test. Comments export as annotations or are omitted by choice. Search across documents and versions finds a phrase you wrote months ago. Deleting a document removes its versions, comments and anchors together. Do not build yet: Nothing after this. ### Out of scope (and why) - Collaborative editing, which is a different and much harder problem than a single writer's drafts. - Publishing and distribution, which is the part of the hosted product that turns an editor into a business. - Any generation of body text, which is the entire point of building this rather than using a general assistant. - Style enforcement against a house guide, which is a different tool for a different stage of writing. ### README must contain - That the tool never writes body text and the comment structure has no field for it - That comments anchor to quoted text and orphan rather than drift - That dismissed comments are kept because the pattern is the signal - That the model policy is per document and enforced at the call site - That export is asserted byte-identical to what you wrote ===== AGENTS.md ===== # Agent instructions · Lex indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: ProseMirror over a document model with stable positions, TypeScript with a local application and SQLite, Generated on demand, anchored to ranges, never applied automatically, Every session's text kept, diffable, Local by default, hosted as an explicit per-document choice. 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 - Snapshotting per keystroke fills the database with thousands of near-identical versions and makes the history unusable for the thing it is for, which is seeing how the draft changed. Snapshot per session, defined by a gap in activity, and keep the current text saved continuously and separately. - A model asked to comment will offer a rewritten sentence unprompted, and the moment a rewrite is one click from being applied the tool has become a writing tool rather than a reading one. Make the output structure hold no replacement text field at all, so there is nothing to apply. - Comments anchored only to character offsets drift onto unrelated text after an edit above them, and a note about a weak argument appearing next to a paragraph it does not describe is worse than no note at all. Keep a quoted excerpt and relocate by content, and orphan the comment when the text no longer exists. - Deleting dismissed comments throws away the most useful signal in the system. A note you dismiss every time is either a deliberate stylistic choice, which the tool should learn to stop raising, or a blind spot, which is worth knowing. Keep dismissals and surface the pattern. - A global setting means one careless change sends every draft, including the ones you had decided were local only. Put the policy on the document, enforce it where the request is made rather than in the interface, and test that a local-only document raises rather than sends. ===== BUILD_PLAN.md ===== # Build plan · Lex A document editor whose AI reads what you wrote and responds as a reader would, in margin comments you can accept, argue with or ignore, with the constraint that it never edits your text and never writes a sentence you then keep. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · An editor that keeps your words You can write for an hour and lose nothing. ### Steps 1. Build on a stable position model Comments anchor to ranges. A model without stable positions makes the next phase impossible. Files: `src/editor.ts` 2. Autosave continuously, snapshot per session Two different mechanisms. Conflating them ruins the history. Files: `src/versions.ts` 3. Test recovery by killing the process Mid-sentence, not at a clean point. Files: `tests/recovery.test.ts` ### Done when - [ ] An hour of writing survives a hard kill - [ ] Versions accumulate per session, not per keystroke - [ ] A diff between versions shows the changes - [ ] Positions survive a reload ### Watch out - Snapshotting per keystroke fills the database with thousands of near-identical versions and makes the history unusable for the thing it is for, which is seeing how the draft changed. Snapshot per session, defined by a gap in activity, and keep the current text saved continuously and separately. ## Phase 2 · Comments in the margin, anchored to the text The AI responds to a passage as a reader, and the note stays on that passage. ### Steps 1. Structure the output as anchored notes Range, kind, body. No replacement field, because the absence is the design. Files: `src/comment.ts` 2. Prompt for a reader's response What confused them, what they wanted next, what the paragraph is doing. Files: `prompts/comment.md` 3. Cover the whole draft Models attend to the opening. Chunk so later sections get read too. Files: `src/chunk.ts` ### Done when - [ ] Comments anchor to ranges and display beside them - [ ] Ten comments read as questions rather than corrections - [ ] The output schema holds no replacement text - [ ] A long draft gets comments throughout ### Watch out - A model asked to comment will offer a rewritten sentence unprompted, and the moment a rewrite is one click from being applied the tool has become a writing tool rather than a reading one. Make the output structure hold no replacement text field at all, so there is nothing to apply. ## Phase 3 · Comments that survive editing You revise a paragraph and its comments follow the text or say they cannot. ### Steps 1. Store quote plus context per comment Prefix and suffix, so relocation by content is possible. Files: `src/anchor.ts` 2. Relocate after edits Position first, quote match second, orphan third. A pure function, unit tested. Files: `src/anchor.ts`, `src/anchor.test.ts` 3. Show orphans honestly With the text they referred to. Silently dropping them loses feedback you paid for. Files: `src/margin.ts` ### Done when - [ ] Editing above leaves comments correctly placed - [ ] Rewriting a passage orphans its comment - [ ] An orphan shows its original text - [ ] Deletion does not leave dangling anchors ### Watch out - Comments anchored only to character offsets drift onto unrelated text after an edit above them, and a note about a weak argument appearing next to a paragraph it does not describe is worse than no note at all. Keep a quoted excerpt and relocate by content, and orphan the comment when the text no longer exists. ## Phase 4 · Argue with a note, and keep the argument You can disagree with a comment and that disagreement persists. ### Steps 1. Three states, all kept Addressed, dismissed, replied. Deletion loses the signal. Files: `src/state.ts` 2. Count dismissals by kind The pattern is the interesting output, not any single dismissal. Files: `src/patterns.ts` 3. Never touch the text Addressing a comment is a state change. The revision is yours to make. Files: `src/state.ts` ### Done when - [ ] Three comment states persist - [ ] Dismissed comments are retained and countable - [ ] A repeated dismissal pattern is surfaced - [ ] No state change ever edits the document ### Watch out - Deleting dismissed comments throws away the most useful signal in the system. A note you dismiss every time is either a deliberate stylistic choice, which the tool should learn to stop raising, or a blind spot, which is worth knowing. Keep dismissals and surface the pattern. ## Phase 5 · Where the draft goes, per document You choose per document whether it is ever sent anywhere. ### Steps 1. Policy on the document Not a global setting. The unit of decision is the draft. Files: `src/policy.ts` 2. Enforce at the call site Raise rather than send. Interface enforcement is not enforcement. Files: `src/model.ts`, `src/model.test.ts` 3. Record the model per comment So you know which reader you were listening to. Files: `src/comment.ts` ### Done when - [ ] A local-only document raises rather than sends - [ ] The policy is visible while writing - [ ] Each comment records its model - [ ] Changing policy warns about prior comments ### Watch out - A global setting means one careless change sends every draft, including the ones you had decided were local only. Put the policy on the document, enforce it where the request is made rather than in the interface, and test that a local-only document raises rather than sends. ## Not in this build - Collaborative editing. - Publishing and distribution. - Any generation of body text. - Style enforcement against a house guide. ## After v1, if you want it - Comment kinds you can turn off, once dismissal patterns show which you never want - A reading pass that reports structure rather than commenting inline - Version comparison across drafts, which is where a long project's history becomes useful ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Documents, versions and comments. Back this up like the drafts it holds. DB_PATH=~/.local/share/lex/lex.db # Required. What a new document gets. Local, so sending is a decision rather than a default. DEFAULT_MODEL_POLICY=local # Required. The local model for comments. Must follow structured output reliably. LOCAL_MODEL=llama3.1:8b # Required. The activity gap that ends a writing session and snapshots a version. SESSION_GAP_MINUTES=30 # Optional · secret. Only for documents explicitly set to a hosted model. Absent means local only, which is a fine way to run. HOSTED_API_KEY=sk-...
You are building a lean indie version of Lex. 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 ===== # Lex · indie build A document editor whose AI reads what you wrote and responds as a reader would, in margin comments you can accept, argue with or ignore, with the constraint that it never edits your text and never writes a sentence you then keep. Estimated effort: **multi-day**. 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 | | --- | --- | --- | | Editor | ProseMirror over a document model with stable positions | comments anchor to ranges and those anchors must survive editing | | Runtime | TypeScript with a local application and SQLite | drafts are the most private thing most people write | | Comments | Generated on demand, anchored to ranges, never applied automatically | the whole premise is that the writing stays yours | | Versions | Every session's text kept, diffable | seeing how a draft changed is most of the value of keeping drafts | | Models | Local by default, hosted as an explicit per-document choice | an unfinished draft going to a service should be a decision | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the editor and the local application. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A local model runner** · free, needs memory and patience - Why: The default policy is local, and a draft is the most private writing most people do. - Get it: Ollama or llama.cpp with a model that follows structured output instructions reliably. - Verify: It returns valid structured output for a comment request ten times in a row - [ ] **A real draft of yours, at least two thousand words** · free - Why: Comment quality, coverage and anchor drift are only observable on real writing with real weaknesses. - Get it: Something you are actually working on and would revise. - Verify: It is long enough that comment coverage across sections is testable - [ ] **Your editor's position model** · free, a couple of hours of reading - Why: Everything about comments depends on positions that survive editing, and this is the hardest decision to change later. - Get it: Read how your editor framework represents positions and how they map through changes before writing anchor code. - Verify: A written note explains how a position maps through an insertion above it ## 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: - Collaborative editing. - Publishing and distribution. - Any generation of body text. - Style enforcement against a house guide. - collaborative editor polish, sync, and embedded model access - proprietary ranking data - brand-trained models - team workflows - large template libraries If one of those is essential to you, that is the reason to keep paying for Lex, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Lex 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 writing editor where the AI comments in the margin instead of writing for you to replace Lex. 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) - Editor: ProseMirror over a document model with stable positions · comments anchor to ranges and those anchors must survive editing - Runtime: TypeScript with a local application and SQLite · drafts are the most private thing most people write - Comments: Generated on demand, anchored to ranges, never applied automatically · the whole premise is that the writing stays yours - Versions: Every session's text kept, diffable · seeing how a draft changed is most of the value of keeping drafts - Models: Local by default, hosted as an explicit per-document choice · an unfinished draft going to a service should be a decision ### Data model (create this before Phase 1) - documents(id, title, created_at, updated_at, model_policy) · model_policy is local or a named hosted model, per document - versions(id, document_id, body, word_count, at) · a snapshot per writing session, not per keystroke - comments(id, document_id, version_id, from_pos, to_pos, kind, body, state, at) · kind is question, observation, structure or reader_reaction - comment_state · open, addressed, dismissed. Dismissed is kept, because dismissing the same note three times is a signal - anchors(comment_id, quoted_text, prefix, suffix) · so a comment can be relocated after the text moves rather than becoming orphaned - There is deliberately no table for AI-generated body text, because the tool does not produce any. ### Phase 1 · An editor that keeps your words Build: A document editor with a stable position model, autosave, and a version snapshot per writing session. Verify recovery from a hard kill mid-sentence. Trap: Snapshotting per keystroke fills the database with thousands of near-identical versions and makes the history unusable for the thing it is for, which is seeing how the draft changed. Snapshot per session, defined by a gap in activity, and keep the current text saved continuously and separately. Done when: An hour of writing survives a hard kill, tested by killing the process mid-sentence. Versions accumulate per session rather than per keystroke, verified by writing in two sessions and seeing two versions. A diff between versions shows what changed. Positions survive a reload. Do not build yet: No comments yet. ### Phase 2 · Comments in the margin, anchored to the text Build: Generate comments against a selection or the whole draft, anchored to specific ranges, displayed in the margin. Comments are questions and observations, never rewrites. Prohibit replacement text in the output structurally. Trap: A model asked to comment will offer a rewritten sentence unprompted, and the moment a rewrite is one click from being applied the tool has become a writing tool rather than a reading one. Make the output structure hold no replacement text field at all, so there is nothing to apply. Done when: Comments generate anchored to specific ranges and display beside them. A comment reads as a question or observation rather than a correction, checked across ten real comments. The output structure cannot carry replacement text, verified by inspecting the schema. Comments on a long draft cover the whole document rather than only the opening. Do not build yet: No anchor drift handling yet. ### Phase 3 · Comments that survive editing Build: Store a quoted excerpt with surrounding context per comment. After an edit, relocate by position where possible and by quote match where the position moved. Mark a comment orphaned when its text is gone rather than pointing it somewhere wrong. Trap: Comments anchored only to character offsets drift onto unrelated text after an edit above them, and a note about a weak argument appearing next to a paragraph it does not describe is worse than no note at all. Keep a quoted excerpt and relocate by content, and orphan the comment when the text no longer exists. Done when: Editing above a comment leaves it on the correct text. Rewriting the commented paragraph orphans the comment rather than moving it elsewhere. An orphaned comment shows the text it referred to. Deleting the passage entirely removes or orphans its comments rather than leaving them pointing at nothing. Do not build yet: No history or model choice yet. ### Phase 4 · Argue with a note, and keep the argument Build: Let a comment be replied to, addressed or dismissed, keeping all three states. Show dismissal patterns: the same note dismissed repeatedly is either a persistent habit you have chosen or a suggestion the tool should stop making. Trap: Deleting dismissed comments throws away the most useful signal in the system. A note you dismiss every time is either a deliberate stylistic choice, which the tool should learn to stop raising, or a blind spot, which is worth knowing. Keep dismissals and surface the pattern. Done when: A comment can be replied to, addressed or dismissed and the state persists. Dismissed comments are retained and countable by kind. A repeated dismissal pattern is surfaced. Marking a comment addressed does not change your text, because nothing in the tool does. Do not build yet: No model policy or export yet. ### Phase 5 · Where the draft goes, per document Build: Set a model policy per document: local only, or a named hosted model. Enforce it at the call site so a local-only document cannot be sent. Show the policy in the editor and record which model produced each comment. Trap: A global setting means one careless change sends every draft, including the ones you had decided were local only. Put the policy on the document, enforce it where the request is made rather than in the interface, and test that a local-only document raises rather than sends. Done when: A local-only document cannot be sent, verified by attempting it and observing a raised error rather than a request. The policy is visible while writing. Each comment records the model that produced it. Changing a document's policy warns that previous comments were produced under a different one. Do not build yet: Export and search come last. ### Phase 6 · Getting the writing out Build: Export to Markdown and to a document format, with comments as annotations or omitted. Search across documents and versions. Confirm the exported text is exactly what you wrote with no generated content anywhere in it. Trap: An export that silently applies a suggestion, or includes a phrase from a comment, breaks the one promise the tool makes. Compare the export byte for byte against the stored document text and treat any difference as a defect. Done when: Export produces text byte-identical to the stored document, asserted in a test. Comments export as annotations or are omitted by choice. Search across documents and versions finds a phrase you wrote months ago. Deleting a document removes its versions, comments and anchors together. Do not build yet: Nothing after this. ### Out of scope (and why) - Collaborative editing, which is a different and much harder problem than a single writer's drafts. - Publishing and distribution, which is the part of the hosted product that turns an editor into a business. - Any generation of body text, which is the entire point of building this rather than using a general assistant. - Style enforcement against a house guide, which is a different tool for a different stage of writing. ### README must contain - That the tool never writes body text and the comment structure has no field for it - That comments anchor to quoted text and orphan rather than drift - That dismissed comments are kept because the pattern is the signal - That the model policy is per document and enforced at the call site - That export is asserted byte-identical to what you wrote ===== AGENTS.md ===== # Agent instructions · Lex indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: ProseMirror over a document model with stable positions, TypeScript with a local application and SQLite, Generated on demand, anchored to ranges, never applied automatically, Every session's text kept, diffable, Local by default, hosted as an explicit per-document choice. 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 - Snapshotting per keystroke fills the database with thousands of near-identical versions and makes the history unusable for the thing it is for, which is seeing how the draft changed. Snapshot per session, defined by a gap in activity, and keep the current text saved continuously and separately. - A model asked to comment will offer a rewritten sentence unprompted, and the moment a rewrite is one click from being applied the tool has become a writing tool rather than a reading one. Make the output structure hold no replacement text field at all, so there is nothing to apply. - Comments anchored only to character offsets drift onto unrelated text after an edit above them, and a note about a weak argument appearing next to a paragraph it does not describe is worse than no note at all. Keep a quoted excerpt and relocate by content, and orphan the comment when the text no longer exists. - Deleting dismissed comments throws away the most useful signal in the system. A note you dismiss every time is either a deliberate stylistic choice, which the tool should learn to stop raising, or a blind spot, which is worth knowing. Keep dismissals and surface the pattern. - A global setting means one careless change sends every draft, including the ones you had decided were local only. Put the policy on the document, enforce it where the request is made rather than in the interface, and test that a local-only document raises rather than sends. ===== BUILD_PLAN.md ===== # Build plan · Lex A document editor whose AI reads what you wrote and responds as a reader would, in margin comments you can accept, argue with or ignore, with the constraint that it never edits your text and never writes a sentence you then keep. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · An editor that keeps your words You can write for an hour and lose nothing. ### Steps 1. Build on a stable position model Comments anchor to ranges. A model without stable positions makes the next phase impossible. Files: `src/editor.ts` 2. Autosave continuously, snapshot per session Two different mechanisms. Conflating them ruins the history. Files: `src/versions.ts` 3. Test recovery by killing the process Mid-sentence, not at a clean point. Files: `tests/recovery.test.ts` ### Done when - [ ] An hour of writing survives a hard kill - [ ] Versions accumulate per session, not per keystroke - [ ] A diff between versions shows the changes - [ ] Positions survive a reload ### Watch out - Snapshotting per keystroke fills the database with thousands of near-identical versions and makes the history unusable for the thing it is for, which is seeing how the draft changed. Snapshot per session, defined by a gap in activity, and keep the current text saved continuously and separately. ## Phase 2 · Comments in the margin, anchored to the text The AI responds to a passage as a reader, and the note stays on that passage. ### Steps 1. Structure the output as anchored notes Range, kind, body. No replacement field, because the absence is the design. Files: `src/comment.ts` 2. Prompt for a reader's response What confused them, what they wanted next, what the paragraph is doing. Files: `prompts/comment.md` 3. Cover the whole draft Models attend to the opening. Chunk so later sections get read too. Files: `src/chunk.ts` ### Done when - [ ] Comments anchor to ranges and display beside them - [ ] Ten comments read as questions rather than corrections - [ ] The output schema holds no replacement text - [ ] A long draft gets comments throughout ### Watch out - A model asked to comment will offer a rewritten sentence unprompted, and the moment a rewrite is one click from being applied the tool has become a writing tool rather than a reading one. Make the output structure hold no replacement text field at all, so there is nothing to apply. ## Phase 3 · Comments that survive editing You revise a paragraph and its comments follow the text or say they cannot. ### Steps 1. Store quote plus context per comment Prefix and suffix, so relocation by content is possible. Files: `src/anchor.ts` 2. Relocate after edits Position first, quote match second, orphan third. A pure function, unit tested. Files: `src/anchor.ts`, `src/anchor.test.ts` 3. Show orphans honestly With the text they referred to. Silently dropping them loses feedback you paid for. Files: `src/margin.ts` ### Done when - [ ] Editing above leaves comments correctly placed - [ ] Rewriting a passage orphans its comment - [ ] An orphan shows its original text - [ ] Deletion does not leave dangling anchors ### Watch out - Comments anchored only to character offsets drift onto unrelated text after an edit above them, and a note about a weak argument appearing next to a paragraph it does not describe is worse than no note at all. Keep a quoted excerpt and relocate by content, and orphan the comment when the text no longer exists. ## Phase 4 · Argue with a note, and keep the argument You can disagree with a comment and that disagreement persists. ### Steps 1. Three states, all kept Addressed, dismissed, replied. Deletion loses the signal. Files: `src/state.ts` 2. Count dismissals by kind The pattern is the interesting output, not any single dismissal. Files: `src/patterns.ts` 3. Never touch the text Addressing a comment is a state change. The revision is yours to make. Files: `src/state.ts` ### Done when - [ ] Three comment states persist - [ ] Dismissed comments are retained and countable - [ ] A repeated dismissal pattern is surfaced - [ ] No state change ever edits the document ### Watch out - Deleting dismissed comments throws away the most useful signal in the system. A note you dismiss every time is either a deliberate stylistic choice, which the tool should learn to stop raising, or a blind spot, which is worth knowing. Keep dismissals and surface the pattern. ## Phase 5 · Where the draft goes, per document You choose per document whether it is ever sent anywhere. ### Steps 1. Policy on the document Not a global setting. The unit of decision is the draft. Files: `src/policy.ts` 2. Enforce at the call site Raise rather than send. Interface enforcement is not enforcement. Files: `src/model.ts`, `src/model.test.ts` 3. Record the model per comment So you know which reader you were listening to. Files: `src/comment.ts` ### Done when - [ ] A local-only document raises rather than sends - [ ] The policy is visible while writing - [ ] Each comment records its model - [ ] Changing policy warns about prior comments ### Watch out - A global setting means one careless change sends every draft, including the ones you had decided were local only. Put the policy on the document, enforce it where the request is made rather than in the interface, and test that a local-only document raises rather than sends. ## Not in this build - Collaborative editing. - Publishing and distribution. - Any generation of body text. - Style enforcement against a house guide. ## After v1, if you want it - Comment kinds you can turn off, once dismissal patterns show which you never want - A reading pass that reports structure rather than commenting inline - Version comparison across drafts, which is where a long project's history becomes useful ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Documents, versions and comments. Back this up like the drafts it holds. DB_PATH=~/.local/share/lex/lex.db # Required. What a new document gets. Local, so sending is a decision rather than a default. DEFAULT_MODEL_POLICY=local # Required. The local model for comments. Must follow structured output reliably. LOCAL_MODEL=llama3.1:8b # Required. The activity gap that ends a writing session and snapshots a version. SESSION_GAP_MINUTES=30 # Optional · secret. Only for documents explicitly set to a hosted model. Absent means local only, which is a fine way to run. HOSTED_API_KEY=sk-...
You are building a production product version of Lex. 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 ===== # Lex · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Lex, write and revise long-form documents with comments, version history, and AI actions. The hard boundary is collaborative editor polish, sync, and embedded model access, plus workflow, data, and model tuning. ## Product outcome An editor that reads your draft and tells you what a reader noticed, in notes you can argue with, and never writes a word of it for you. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - OpenAI API key - Node.js 22 - local or self-hosted deployment - user-supplied sources ## Explicit non-goals for v1 - Collaborative editing. - Publishing and distribution. - Any generation of body text. - Style enforcement against a house guide. - collaborative editor polish, sync, and embedded model access - proprietary ranking data - brand-trained models - team workflows - large template libraries ## Success criteria - The comment schema is confirmed to hold no replacement text - Rewriting a passage is confirmed to orphan rather than drift its comment - A local-only document is confirmed to raise rather than send - Export is confirmed byte-identical to the stored text - An hour of writing is confirmed to survive a hard kill ===== BRIEF.md ===== # Build brief · Lex 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 writing editor where the AI comments in the margin instead of writing for you to replace Lex. 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) - Editor: ProseMirror over a document model with stable positions · comments anchor to ranges and those anchors must survive editing - Runtime: TypeScript with a local application and SQLite · drafts are the most private thing most people write - Comments: Generated on demand, anchored to ranges, never applied automatically · the whole premise is that the writing stays yours - Versions: Every session's text kept, diffable · seeing how a draft changed is most of the value of keeping drafts - Models: Local by default, hosted as an explicit per-document choice · an unfinished draft going to a service should be a decision ### Data model (create this before Phase 1) - documents(id, title, created_at, updated_at, model_policy) · model_policy is local or a named hosted model, per document - versions(id, document_id, body, word_count, at) · a snapshot per writing session, not per keystroke - comments(id, document_id, version_id, from_pos, to_pos, kind, body, state, at) · kind is question, observation, structure or reader_reaction - comment_state · open, addressed, dismissed. Dismissed is kept, because dismissing the same note three times is a signal - anchors(comment_id, quoted_text, prefix, suffix) · so a comment can be relocated after the text moves rather than becoming orphaned - There is deliberately no table for AI-generated body text, because the tool does not produce any. ### Phase 1 · An editor that keeps your words Build: A document editor with a stable position model, autosave, and a version snapshot per writing session. Verify recovery from a hard kill mid-sentence. Trap: Snapshotting per keystroke fills the database with thousands of near-identical versions and makes the history unusable for the thing it is for, which is seeing how the draft changed. Snapshot per session, defined by a gap in activity, and keep the current text saved continuously and separately. Done when: An hour of writing survives a hard kill, tested by killing the process mid-sentence. Versions accumulate per session rather than per keystroke, verified by writing in two sessions and seeing two versions. A diff between versions shows what changed. Positions survive a reload. Do not build yet: No comments yet. ### Phase 2 · Comments in the margin, anchored to the text Build: Generate comments against a selection or the whole draft, anchored to specific ranges, displayed in the margin. Comments are questions and observations, never rewrites. Prohibit replacement text in the output structurally. Trap: A model asked to comment will offer a rewritten sentence unprompted, and the moment a rewrite is one click from being applied the tool has become a writing tool rather than a reading one. Make the output structure hold no replacement text field at all, so there is nothing to apply. Done when: Comments generate anchored to specific ranges and display beside them. A comment reads as a question or observation rather than a correction, checked across ten real comments. The output structure cannot carry replacement text, verified by inspecting the schema. Comments on a long draft cover the whole document rather than only the opening. Do not build yet: No anchor drift handling yet. ### Phase 3 · Comments that survive editing Build: Store a quoted excerpt with surrounding context per comment. After an edit, relocate by position where possible and by quote match where the position moved. Mark a comment orphaned when its text is gone rather than pointing it somewhere wrong. Trap: Comments anchored only to character offsets drift onto unrelated text after an edit above them, and a note about a weak argument appearing next to a paragraph it does not describe is worse than no note at all. Keep a quoted excerpt and relocate by content, and orphan the comment when the text no longer exists. Done when: Editing above a comment leaves it on the correct text. Rewriting the commented paragraph orphans the comment rather than moving it elsewhere. An orphaned comment shows the text it referred to. Deleting the passage entirely removes or orphans its comments rather than leaving them pointing at nothing. Do not build yet: No history or model choice yet. ### Phase 4 · Argue with a note, and keep the argument Build: Let a comment be replied to, addressed or dismissed, keeping all three states. Show dismissal patterns: the same note dismissed repeatedly is either a persistent habit you have chosen or a suggestion the tool should stop making. Trap: Deleting dismissed comments throws away the most useful signal in the system. A note you dismiss every time is either a deliberate stylistic choice, which the tool should learn to stop raising, or a blind spot, which is worth knowing. Keep dismissals and surface the pattern. Done when: A comment can be replied to, addressed or dismissed and the state persists. Dismissed comments are retained and countable by kind. A repeated dismissal pattern is surfaced. Marking a comment addressed does not change your text, because nothing in the tool does. Do not build yet: No model policy or export yet. ### Phase 5 · Where the draft goes, per document Build: Set a model policy per document: local only, or a named hosted model. Enforce it at the call site so a local-only document cannot be sent. Show the policy in the editor and record which model produced each comment. Trap: A global setting means one careless change sends every draft, including the ones you had decided were local only. Put the policy on the document, enforce it where the request is made rather than in the interface, and test that a local-only document raises rather than sends. Done when: A local-only document cannot be sent, verified by attempting it and observing a raised error rather than a request. The policy is visible while writing. Each comment records the model that produced it. Changing a document's policy warns that previous comments were produced under a different one. Do not build yet: Export and search come last. ### Phase 6 · Getting the writing out Build: Export to Markdown and to a document format, with comments as annotations or omitted. Search across documents and versions. Confirm the exported text is exactly what you wrote with no generated content anywhere in it. Trap: An export that silently applies a suggestion, or includes a phrase from a comment, breaks the one promise the tool makes. Compare the export byte for byte against the stored document text and treat any difference as a defect. Done when: Export produces text byte-identical to the stored document, asserted in a test. Comments export as annotations or are omitted by choice. Search across documents and versions finds a phrase you wrote months ago. Deleting a document removes its versions, comments and anchors together. Do not build yet: Nothing after this. ### Out of scope (and why) - Collaborative editing, which is a different and much harder problem than a single writer's drafts. - Publishing and distribution, which is the part of the hosted product that turns an editor into a business. - Any generation of body text, which is the entire point of building this rather than using a general assistant. - Style enforcement against a house guide, which is a different tool for a different stage of writing. ### README must contain - That the tool never writes body text and the comment structure has no field for it - That comments anchor to quoted text and orphan rather than drift - That dismissed comments are kept because the pattern is the signal - That the model policy is per document and enforced at the call site - That export is asserted byte-identical to what you wrote ===== ARCHITECTURE.md ===== # Architecture · Lex ## Stack | Part | Choice | Why | | --- | --- | --- | | Editor | ProseMirror over a document model with stable positions | comments anchor to ranges and those anchors must survive editing | | Runtime | TypeScript with a local application and SQLite | drafts are the most private thing most people write | | Comments | Generated on demand, anchored to ranges, never applied automatically | the whole premise is that the writing stays yours | | Versions | Every session's text kept, diffable | seeing how a draft changed is most of the value of keeping drafts | | Models | Local by default, hosted as an explicit per-document choice | an unfinished draft going to a service should be a decision | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Editor | The document and its stable positions. | Hard to swap. The position model is the foundation the anchors sit on. | | Comments | Generation with no replacement text. | Any model, provided the output structure stays free of a body-text field. | | Anchors | Relocation and orphaning. | Any implementation. Orphaning rather than drifting is the requirement. | | Policy | Per-document model choice, enforced at the call site. | Not swappable for a global setting, which is what makes it hold. | | Export | Text out, asserted unchanged. | Any format. The byte-identity assertion is the part that stays. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DB_PATH` · required · Documents, versions and comments. Back this up like the drafts it holds. - `DEFAULT_MODEL_POLICY` · required · What a new document gets. Local, so sending is a decision rather than a default. - `LOCAL_MODEL` · required · The local model for comments. Must follow structured output reliably. - `SESSION_GAP_MINUTES` · required · The activity gap that ends a writing session and snapshots a version. - `HOSTED_API_KEY` · optional, secret · Only for documents explicitly set to a hosted model. Absent means local only, which is a fine way to run. ## 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 · Lex product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: ProseMirror over a document model with stable positions, TypeScript with a local application and SQLite, Generated on demand, anchored to ranges, never applied automatically, Every session's text kept, diffable, Local by default, hosted as an explicit per-document choice. - 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 - Snapshotting per keystroke fills the database with thousands of near-identical versions and makes the history unusable for the thing it is for, which is seeing how the draft changed. Snapshot per session, defined by a gap in activity, and keep the current text saved continuously and separately. - A model asked to comment will offer a rewritten sentence unprompted, and the moment a rewrite is one click from being applied the tool has become a writing tool rather than a reading one. Make the output structure hold no replacement text field at all, so there is nothing to apply. - Comments anchored only to character offsets drift onto unrelated text after an edit above them, and a note about a weak argument appearing next to a paragraph it does not describe is worse than no note at all. Keep a quoted excerpt and relocate by content, and orphan the comment when the text no longer exists. - Deleting dismissed comments throws away the most useful signal in the system. A note you dismiss every time is either a deliberate stylistic choice, which the tool should learn to stop raising, or a blind spot, which is worth knowing. Keep dismissals and surface the pattern. - A global setting means one careless change sends every draft, including the ones you had decided were local only. Put the policy on the document, enforce it where the request is made rather than in the interface, and test that a local-only document raises rather than sends. - An export that silently applies a suggestion, or includes a phrase from a comment, breaks the one promise the tool makes. Compare the export byte for byte against the stored document text and treat any difference as a defect. ===== MILESTONES.md ===== # Delivery milestones · Lex Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · An editor that keeps your words You can write for an hour and lose nothing. ### Steps 1. Build on a stable position model Comments anchor to ranges. A model without stable positions makes the next phase impossible. Files: `src/editor.ts` 2. Autosave continuously, snapshot per session Two different mechanisms. Conflating them ruins the history. Files: `src/versions.ts` 3. Test recovery by killing the process Mid-sentence, not at a clean point. Files: `tests/recovery.test.ts` ### Done when - [ ] An hour of writing survives a hard kill - [ ] Versions accumulate per session, not per keystroke - [ ] A diff between versions shows the changes - [ ] Positions survive a reload ### Watch out - Snapshotting per keystroke fills the database with thousands of near-identical versions and makes the history unusable for the thing it is for, which is seeing how the draft changed. Snapshot per session, defined by a gap in activity, and keep the current text saved continuously and separately. ## M2 · Comments in the margin, anchored to the text The AI responds to a passage as a reader, and the note stays on that passage. ### Steps 1. Structure the output as anchored notes Range, kind, body. No replacement field, because the absence is the design. Files: `src/comment.ts` 2. Prompt for a reader's response What confused them, what they wanted next, what the paragraph is doing. Files: `prompts/comment.md` 3. Cover the whole draft Models attend to the opening. Chunk so later sections get read too. Files: `src/chunk.ts` ### Done when - [ ] Comments anchor to ranges and display beside them - [ ] Ten comments read as questions rather than corrections - [ ] The output schema holds no replacement text - [ ] A long draft gets comments throughout ### Watch out - A model asked to comment will offer a rewritten sentence unprompted, and the moment a rewrite is one click from being applied the tool has become a writing tool rather than a reading one. Make the output structure hold no replacement text field at all, so there is nothing to apply. ## M3 · Comments that survive editing You revise a paragraph and its comments follow the text or say they cannot. ### Steps 1. Store quote plus context per comment Prefix and suffix, so relocation by content is possible. Files: `src/anchor.ts` 2. Relocate after edits Position first, quote match second, orphan third. A pure function, unit tested. Files: `src/anchor.ts`, `src/anchor.test.ts` 3. Show orphans honestly With the text they referred to. Silently dropping them loses feedback you paid for. Files: `src/margin.ts` ### Done when - [ ] Editing above leaves comments correctly placed - [ ] Rewriting a passage orphans its comment - [ ] An orphan shows its original text - [ ] Deletion does not leave dangling anchors ### Watch out - Comments anchored only to character offsets drift onto unrelated text after an edit above them, and a note about a weak argument appearing next to a paragraph it does not describe is worse than no note at all. Keep a quoted excerpt and relocate by content, and orphan the comment when the text no longer exists. ## M4 · Argue with a note, and keep the argument You can disagree with a comment and that disagreement persists. ### Steps 1. Three states, all kept Addressed, dismissed, replied. Deletion loses the signal. Files: `src/state.ts` 2. Count dismissals by kind The pattern is the interesting output, not any single dismissal. Files: `src/patterns.ts` 3. Never touch the text Addressing a comment is a state change. The revision is yours to make. Files: `src/state.ts` ### Done when - [ ] Three comment states persist - [ ] Dismissed comments are retained and countable - [ ] A repeated dismissal pattern is surfaced - [ ] No state change ever edits the document ### Watch out - Deleting dismissed comments throws away the most useful signal in the system. A note you dismiss every time is either a deliberate stylistic choice, which the tool should learn to stop raising, or a blind spot, which is worth knowing. Keep dismissals and surface the pattern. ## M5 · Where the draft goes, per document You choose per document whether it is ever sent anywhere. ### Steps 1. Policy on the document Not a global setting. The unit of decision is the draft. Files: `src/policy.ts` 2. Enforce at the call site Raise rather than send. Interface enforcement is not enforcement. Files: `src/model.ts`, `src/model.test.ts` 3. Record the model per comment So you know which reader you were listening to. Files: `src/comment.ts` ### Done when - [ ] A local-only document raises rather than sends - [ ] The policy is visible while writing - [ ] Each comment records its model - [ ] Changing policy warns about prior comments ### Watch out - A global setting means one careless change sends every draft, including the ones you had decided were local only. Put the policy on the document, enforce it where the request is made rather than in the interface, and test that a local-only document raises rather than sends. ## M6 · Getting the writing out (production only) The draft leaves in a format you can use, and comments come too if you want. ### Steps 1. Assert export equals stored text Byte for byte, in a test. This is the promise made checkable. Files: `src/export.ts`, `src/export.test.ts` 2. Comments as optional annotations Some formats support them. Omission is the default. Files: `src/export.ts` 3. Index documents and versions Finding the paragraph you cut three drafts ago is why versions are kept. Files: `src/search.ts` ### Done when - [ ] Export is byte-identical to the stored text - [ ] Comments export as annotations or are omitted - [ ] Search finds an old phrase across versions - [ ] Deleting a document removes everything attached ### Watch out - An export that silently applies a suggestion, or includes a phrase from a comment, breaks the one promise the tool makes. Compare the export byte for byte against the stored document text and treat any difference as a defect. ===== OPERATIONS.md ===== # Operations · Lex ## Backup Documents and versions are the irreplaceable data. Comments regenerate, drafts do not. Back up the database and test a restore by opening a document and reading its history. ## Restore Restore and confirm versions, anchors and policies survived. A restored local-only document that comes back as hosted is a serious defect. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a personal tool, the thing worth watching is orphan rate. Comments orphaning constantly means the anchoring is too brittle, and comments never orphaning means it is drifting instead. ## Incident checklist If a comment appears next to text it does not describe, the anchor relocated wrongly and that is the highest-priority defect, because misplaced feedback is worse than none. If any generated text reaches the document, stop and fix the structure, since that is the one promise. 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 - [ ] The comment schema is confirmed to hold no replacement text - [ ] Rewriting a passage is confirmed to orphan rather than drift its comment - [ ] A local-only document is confirmed to raise rather than send - [ ] Export is confirmed byte-identical to the stored text - [ ] An hour of writing is confirmed to survive a hard kill ## Launch constraint Do not market omitted Lex 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. Documents, versions and comments. Back this up like the drafts it holds. DB_PATH=~/.local/share/lex/lex.db # Required. What a new document gets. Local, so sending is a decision rather than a default. DEFAULT_MODEL_POLICY=local # Required. The local model for comments. Must follow structured output reliably. LOCAL_MODEL=llama3.1:8b # Required. The activity gap that ends a writing session and snapshots a version. SESSION_GAP_MINUTES=30 # Optional · secret. Only for documents explicitly set to a hosted model. Absent means local only, which is a fine way to run. HOSTED_API_KEY=sk-...
# Lex · indie build A document editor whose AI reads what you wrote and responds as a reader would, in margin comments you can accept, argue with or ignore, with the constraint that it never edits your text and never writes a sentence you then keep. Estimated effort: **multi-day**. 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 | | --- | --- | --- | | Editor | ProseMirror over a document model with stable positions | comments anchor to ranges and those anchors must survive editing | | Runtime | TypeScript with a local application and SQLite | drafts are the most private thing most people write | | Comments | Generated on demand, anchored to ranges, never applied automatically | the whole premise is that the writing stays yours | | Versions | Every session's text kept, diffable | seeing how a draft changed is most of the value of keeping drafts | | Models | Local by default, hosted as an explicit per-document choice | an unfinished draft going to a service should be a decision | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the editor and the local application. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A local model runner** · free, needs memory and patience - Why: The default policy is local, and a draft is the most private writing most people do. - Get it: Ollama or llama.cpp with a model that follows structured output instructions reliably. - Verify: It returns valid structured output for a comment request ten times in a row - [ ] **A real draft of yours, at least two thousand words** · free - Why: Comment quality, coverage and anchor drift are only observable on real writing with real weaknesses. - Get it: Something you are actually working on and would revise. - Verify: It is long enough that comment coverage across sections is testable - [ ] **Your editor's position model** · free, a couple of hours of reading - Why: Everything about comments depends on positions that survive editing, and this is the hardest decision to change later. - Get it: Read how your editor framework represents positions and how they map through changes before writing anchor code. - Verify: A written note explains how a position maps through an insertion above it ## 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: - Collaborative editing. - Publishing and distribution. - Any generation of body text. - Style enforcement against a house guide. - collaborative editor polish, sync, and embedded model access - proprietary ranking data - brand-trained models - team workflows - large template libraries If one of those is essential to you, that is the reason to keep paying for Lex, and the README should say so rather than pretend.
# Build brief · Lex 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 writing editor where the AI comments in the margin instead of writing for you to replace Lex. 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) - Editor: ProseMirror over a document model with stable positions · comments anchor to ranges and those anchors must survive editing - Runtime: TypeScript with a local application and SQLite · drafts are the most private thing most people write - Comments: Generated on demand, anchored to ranges, never applied automatically · the whole premise is that the writing stays yours - Versions: Every session's text kept, diffable · seeing how a draft changed is most of the value of keeping drafts - Models: Local by default, hosted as an explicit per-document choice · an unfinished draft going to a service should be a decision ### Data model (create this before Phase 1) - documents(id, title, created_at, updated_at, model_policy) · model_policy is local or a named hosted model, per document - versions(id, document_id, body, word_count, at) · a snapshot per writing session, not per keystroke - comments(id, document_id, version_id, from_pos, to_pos, kind, body, state, at) · kind is question, observation, structure or reader_reaction - comment_state · open, addressed, dismissed. Dismissed is kept, because dismissing the same note three times is a signal - anchors(comment_id, quoted_text, prefix, suffix) · so a comment can be relocated after the text moves rather than becoming orphaned - There is deliberately no table for AI-generated body text, because the tool does not produce any. ### Phase 1 · An editor that keeps your words Build: A document editor with a stable position model, autosave, and a version snapshot per writing session. Verify recovery from a hard kill mid-sentence. Trap: Snapshotting per keystroke fills the database with thousands of near-identical versions and makes the history unusable for the thing it is for, which is seeing how the draft changed. Snapshot per session, defined by a gap in activity, and keep the current text saved continuously and separately. Done when: An hour of writing survives a hard kill, tested by killing the process mid-sentence. Versions accumulate per session rather than per keystroke, verified by writing in two sessions and seeing two versions. A diff between versions shows what changed. Positions survive a reload. Do not build yet: No comments yet. ### Phase 2 · Comments in the margin, anchored to the text Build: Generate comments against a selection or the whole draft, anchored to specific ranges, displayed in the margin. Comments are questions and observations, never rewrites. Prohibit replacement text in the output structurally. Trap: A model asked to comment will offer a rewritten sentence unprompted, and the moment a rewrite is one click from being applied the tool has become a writing tool rather than a reading one. Make the output structure hold no replacement text field at all, so there is nothing to apply. Done when: Comments generate anchored to specific ranges and display beside them. A comment reads as a question or observation rather than a correction, checked across ten real comments. The output structure cannot carry replacement text, verified by inspecting the schema. Comments on a long draft cover the whole document rather than only the opening. Do not build yet: No anchor drift handling yet. ### Phase 3 · Comments that survive editing Build: Store a quoted excerpt with surrounding context per comment. After an edit, relocate by position where possible and by quote match where the position moved. Mark a comment orphaned when its text is gone rather than pointing it somewhere wrong. Trap: Comments anchored only to character offsets drift onto unrelated text after an edit above them, and a note about a weak argument appearing next to a paragraph it does not describe is worse than no note at all. Keep a quoted excerpt and relocate by content, and orphan the comment when the text no longer exists. Done when: Editing above a comment leaves it on the correct text. Rewriting the commented paragraph orphans the comment rather than moving it elsewhere. An orphaned comment shows the text it referred to. Deleting the passage entirely removes or orphans its comments rather than leaving them pointing at nothing. Do not build yet: No history or model choice yet. ### Phase 4 · Argue with a note, and keep the argument Build: Let a comment be replied to, addressed or dismissed, keeping all three states. Show dismissal patterns: the same note dismissed repeatedly is either a persistent habit you have chosen or a suggestion the tool should stop making. Trap: Deleting dismissed comments throws away the most useful signal in the system. A note you dismiss every time is either a deliberate stylistic choice, which the tool should learn to stop raising, or a blind spot, which is worth knowing. Keep dismissals and surface the pattern. Done when: A comment can be replied to, addressed or dismissed and the state persists. Dismissed comments are retained and countable by kind. A repeated dismissal pattern is surfaced. Marking a comment addressed does not change your text, because nothing in the tool does. Do not build yet: No model policy or export yet. ### Phase 5 · Where the draft goes, per document Build: Set a model policy per document: local only, or a named hosted model. Enforce it at the call site so a local-only document cannot be sent. Show the policy in the editor and record which model produced each comment. Trap: A global setting means one careless change sends every draft, including the ones you had decided were local only. Put the policy on the document, enforce it where the request is made rather than in the interface, and test that a local-only document raises rather than sends. Done when: A local-only document cannot be sent, verified by attempting it and observing a raised error rather than a request. The policy is visible while writing. Each comment records the model that produced it. Changing a document's policy warns that previous comments were produced under a different one. Do not build yet: Export and search come last. ### Phase 6 · Getting the writing out Build: Export to Markdown and to a document format, with comments as annotations or omitted. Search across documents and versions. Confirm the exported text is exactly what you wrote with no generated content anywhere in it. Trap: An export that silently applies a suggestion, or includes a phrase from a comment, breaks the one promise the tool makes. Compare the export byte for byte against the stored document text and treat any difference as a defect. Done when: Export produces text byte-identical to the stored document, asserted in a test. Comments export as annotations or are omitted by choice. Search across documents and versions finds a phrase you wrote months ago. Deleting a document removes its versions, comments and anchors together. Do not build yet: Nothing after this. ### Out of scope (and why) - Collaborative editing, which is a different and much harder problem than a single writer's drafts. - Publishing and distribution, which is the part of the hosted product that turns an editor into a business. - Any generation of body text, which is the entire point of building this rather than using a general assistant. - Style enforcement against a house guide, which is a different tool for a different stage of writing. ### README must contain - That the tool never writes body text and the comment structure has no field for it - That comments anchor to quoted text and orphan rather than drift - That dismissed comments are kept because the pattern is the signal - That the model policy is per document and enforced at the call site - That export is asserted byte-identical to what you wrote
# Agent instructions · Lex indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: ProseMirror over a document model with stable positions, TypeScript with a local application and SQLite, Generated on demand, anchored to ranges, never applied automatically, Every session's text kept, diffable, Local by default, hosted as an explicit per-document choice. 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 - Snapshotting per keystroke fills the database with thousands of near-identical versions and makes the history unusable for the thing it is for, which is seeing how the draft changed. Snapshot per session, defined by a gap in activity, and keep the current text saved continuously and separately. - A model asked to comment will offer a rewritten sentence unprompted, and the moment a rewrite is one click from being applied the tool has become a writing tool rather than a reading one. Make the output structure hold no replacement text field at all, so there is nothing to apply. - Comments anchored only to character offsets drift onto unrelated text after an edit above them, and a note about a weak argument appearing next to a paragraph it does not describe is worse than no note at all. Keep a quoted excerpt and relocate by content, and orphan the comment when the text no longer exists. - Deleting dismissed comments throws away the most useful signal in the system. A note you dismiss every time is either a deliberate stylistic choice, which the tool should learn to stop raising, or a blind spot, which is worth knowing. Keep dismissals and surface the pattern. - A global setting means one careless change sends every draft, including the ones you had decided were local only. Put the policy on the document, enforce it where the request is made rather than in the interface, and test that a local-only document raises rather than sends.
# Build plan · Lex A document editor whose AI reads what you wrote and responds as a reader would, in margin comments you can accept, argue with or ignore, with the constraint that it never edits your text and never writes a sentence you then keep. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · An editor that keeps your words You can write for an hour and lose nothing. ### Steps 1. Build on a stable position model Comments anchor to ranges. A model without stable positions makes the next phase impossible. Files: `src/editor.ts` 2. Autosave continuously, snapshot per session Two different mechanisms. Conflating them ruins the history. Files: `src/versions.ts` 3. Test recovery by killing the process Mid-sentence, not at a clean point. Files: `tests/recovery.test.ts` ### Done when - [ ] An hour of writing survives a hard kill - [ ] Versions accumulate per session, not per keystroke - [ ] A diff between versions shows the changes - [ ] Positions survive a reload ### Watch out - Snapshotting per keystroke fills the database with thousands of near-identical versions and makes the history unusable for the thing it is for, which is seeing how the draft changed. Snapshot per session, defined by a gap in activity, and keep the current text saved continuously and separately. ## Phase 2 · Comments in the margin, anchored to the text The AI responds to a passage as a reader, and the note stays on that passage. ### Steps 1. Structure the output as anchored notes Range, kind, body. No replacement field, because the absence is the design. Files: `src/comment.ts` 2. Prompt for a reader's response What confused them, what they wanted next, what the paragraph is doing. Files: `prompts/comment.md` 3. Cover the whole draft Models attend to the opening. Chunk so later sections get read too. Files: `src/chunk.ts` ### Done when - [ ] Comments anchor to ranges and display beside them - [ ] Ten comments read as questions rather than corrections - [ ] The output schema holds no replacement text - [ ] A long draft gets comments throughout ### Watch out - A model asked to comment will offer a rewritten sentence unprompted, and the moment a rewrite is one click from being applied the tool has become a writing tool rather than a reading one. Make the output structure hold no replacement text field at all, so there is nothing to apply. ## Phase 3 · Comments that survive editing You revise a paragraph and its comments follow the text or say they cannot. ### Steps 1. Store quote plus context per comment Prefix and suffix, so relocation by content is possible. Files: `src/anchor.ts` 2. Relocate after edits Position first, quote match second, orphan third. A pure function, unit tested. Files: `src/anchor.ts`, `src/anchor.test.ts` 3. Show orphans honestly With the text they referred to. Silently dropping them loses feedback you paid for. Files: `src/margin.ts` ### Done when - [ ] Editing above leaves comments correctly placed - [ ] Rewriting a passage orphans its comment - [ ] An orphan shows its original text - [ ] Deletion does not leave dangling anchors ### Watch out - Comments anchored only to character offsets drift onto unrelated text after an edit above them, and a note about a weak argument appearing next to a paragraph it does not describe is worse than no note at all. Keep a quoted excerpt and relocate by content, and orphan the comment when the text no longer exists. ## Phase 4 · Argue with a note, and keep the argument You can disagree with a comment and that disagreement persists. ### Steps 1. Three states, all kept Addressed, dismissed, replied. Deletion loses the signal. Files: `src/state.ts` 2. Count dismissals by kind The pattern is the interesting output, not any single dismissal. Files: `src/patterns.ts` 3. Never touch the text Addressing a comment is a state change. The revision is yours to make. Files: `src/state.ts` ### Done when - [ ] Three comment states persist - [ ] Dismissed comments are retained and countable - [ ] A repeated dismissal pattern is surfaced - [ ] No state change ever edits the document ### Watch out - Deleting dismissed comments throws away the most useful signal in the system. A note you dismiss every time is either a deliberate stylistic choice, which the tool should learn to stop raising, or a blind spot, which is worth knowing. Keep dismissals and surface the pattern. ## Phase 5 · Where the draft goes, per document You choose per document whether it is ever sent anywhere. ### Steps 1. Policy on the document Not a global setting. The unit of decision is the draft. Files: `src/policy.ts` 2. Enforce at the call site Raise rather than send. Interface enforcement is not enforcement. Files: `src/model.ts`, `src/model.test.ts` 3. Record the model per comment So you know which reader you were listening to. Files: `src/comment.ts` ### Done when - [ ] A local-only document raises rather than sends - [ ] The policy is visible while writing - [ ] Each comment records its model - [ ] Changing policy warns about prior comments ### Watch out - A global setting means one careless change sends every draft, including the ones you had decided were local only. Put the policy on the document, enforce it where the request is made rather than in the interface, and test that a local-only document raises rather than sends. ## Not in this build - Collaborative editing. - Publishing and distribution. - Any generation of body text. - Style enforcement against a house guide. ## After v1, if you want it - Comment kinds you can turn off, once dismissal patterns show which you never want - A reading pass that reports structure rather than commenting inline - Version comparison across drafts, which is where a long project's history becomes useful
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Documents, versions and comments. Back this up like the drafts it holds. DB_PATH=~/.local/share/lex/lex.db # Required. What a new document gets. Local, so sending is a decision rather than a default. DEFAULT_MODEL_POLICY=local # Required. The local model for comments. Must follow structured output reliably. LOCAL_MODEL=llama3.1:8b # Required. The activity gap that ends a writing session and snapshots a version. SESSION_GAP_MINUTES=30 # Optional · secret. Only for documents explicitly set to a hosted model. Absent means local only, which is a fine way to run. HOSTED_API_KEY=sk-...
# Lex · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Lex, write and revise long-form documents with comments, version history, and AI actions. The hard boundary is collaborative editor polish, sync, and embedded model access, plus workflow, data, and model tuning. ## Product outcome An editor that reads your draft and tells you what a reader noticed, in notes you can argue with, and never writes a word of it for you. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - OpenAI API key - Node.js 22 - local or self-hosted deployment - user-supplied sources ## Explicit non-goals for v1 - Collaborative editing. - Publishing and distribution. - Any generation of body text. - Style enforcement against a house guide. - collaborative editor polish, sync, and embedded model access - proprietary ranking data - brand-trained models - team workflows - large template libraries ## Success criteria - The comment schema is confirmed to hold no replacement text - Rewriting a passage is confirmed to orphan rather than drift its comment - A local-only document is confirmed to raise rather than send - Export is confirmed byte-identical to the stored text - An hour of writing is confirmed to survive a hard kill
# Build brief · Lex 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 writing editor where the AI comments in the margin instead of writing for you to replace Lex. 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) - Editor: ProseMirror over a document model with stable positions · comments anchor to ranges and those anchors must survive editing - Runtime: TypeScript with a local application and SQLite · drafts are the most private thing most people write - Comments: Generated on demand, anchored to ranges, never applied automatically · the whole premise is that the writing stays yours - Versions: Every session's text kept, diffable · seeing how a draft changed is most of the value of keeping drafts - Models: Local by default, hosted as an explicit per-document choice · an unfinished draft going to a service should be a decision ### Data model (create this before Phase 1) - documents(id, title, created_at, updated_at, model_policy) · model_policy is local or a named hosted model, per document - versions(id, document_id, body, word_count, at) · a snapshot per writing session, not per keystroke - comments(id, document_id, version_id, from_pos, to_pos, kind, body, state, at) · kind is question, observation, structure or reader_reaction - comment_state · open, addressed, dismissed. Dismissed is kept, because dismissing the same note three times is a signal - anchors(comment_id, quoted_text, prefix, suffix) · so a comment can be relocated after the text moves rather than becoming orphaned - There is deliberately no table for AI-generated body text, because the tool does not produce any. ### Phase 1 · An editor that keeps your words Build: A document editor with a stable position model, autosave, and a version snapshot per writing session. Verify recovery from a hard kill mid-sentence. Trap: Snapshotting per keystroke fills the database with thousands of near-identical versions and makes the history unusable for the thing it is for, which is seeing how the draft changed. Snapshot per session, defined by a gap in activity, and keep the current text saved continuously and separately. Done when: An hour of writing survives a hard kill, tested by killing the process mid-sentence. Versions accumulate per session rather than per keystroke, verified by writing in two sessions and seeing two versions. A diff between versions shows what changed. Positions survive a reload. Do not build yet: No comments yet. ### Phase 2 · Comments in the margin, anchored to the text Build: Generate comments against a selection or the whole draft, anchored to specific ranges, displayed in the margin. Comments are questions and observations, never rewrites. Prohibit replacement text in the output structurally. Trap: A model asked to comment will offer a rewritten sentence unprompted, and the moment a rewrite is one click from being applied the tool has become a writing tool rather than a reading one. Make the output structure hold no replacement text field at all, so there is nothing to apply. Done when: Comments generate anchored to specific ranges and display beside them. A comment reads as a question or observation rather than a correction, checked across ten real comments. The output structure cannot carry replacement text, verified by inspecting the schema. Comments on a long draft cover the whole document rather than only the opening. Do not build yet: No anchor drift handling yet. ### Phase 3 · Comments that survive editing Build: Store a quoted excerpt with surrounding context per comment. After an edit, relocate by position where possible and by quote match where the position moved. Mark a comment orphaned when its text is gone rather than pointing it somewhere wrong. Trap: Comments anchored only to character offsets drift onto unrelated text after an edit above them, and a note about a weak argument appearing next to a paragraph it does not describe is worse than no note at all. Keep a quoted excerpt and relocate by content, and orphan the comment when the text no longer exists. Done when: Editing above a comment leaves it on the correct text. Rewriting the commented paragraph orphans the comment rather than moving it elsewhere. An orphaned comment shows the text it referred to. Deleting the passage entirely removes or orphans its comments rather than leaving them pointing at nothing. Do not build yet: No history or model choice yet. ### Phase 4 · Argue with a note, and keep the argument Build: Let a comment be replied to, addressed or dismissed, keeping all three states. Show dismissal patterns: the same note dismissed repeatedly is either a persistent habit you have chosen or a suggestion the tool should stop making. Trap: Deleting dismissed comments throws away the most useful signal in the system. A note you dismiss every time is either a deliberate stylistic choice, which the tool should learn to stop raising, or a blind spot, which is worth knowing. Keep dismissals and surface the pattern. Done when: A comment can be replied to, addressed or dismissed and the state persists. Dismissed comments are retained and countable by kind. A repeated dismissal pattern is surfaced. Marking a comment addressed does not change your text, because nothing in the tool does. Do not build yet: No model policy or export yet. ### Phase 5 · Where the draft goes, per document Build: Set a model policy per document: local only, or a named hosted model. Enforce it at the call site so a local-only document cannot be sent. Show the policy in the editor and record which model produced each comment. Trap: A global setting means one careless change sends every draft, including the ones you had decided were local only. Put the policy on the document, enforce it where the request is made rather than in the interface, and test that a local-only document raises rather than sends. Done when: A local-only document cannot be sent, verified by attempting it and observing a raised error rather than a request. The policy is visible while writing. Each comment records the model that produced it. Changing a document's policy warns that previous comments were produced under a different one. Do not build yet: Export and search come last. ### Phase 6 · Getting the writing out Build: Export to Markdown and to a document format, with comments as annotations or omitted. Search across documents and versions. Confirm the exported text is exactly what you wrote with no generated content anywhere in it. Trap: An export that silently applies a suggestion, or includes a phrase from a comment, breaks the one promise the tool makes. Compare the export byte for byte against the stored document text and treat any difference as a defect. Done when: Export produces text byte-identical to the stored document, asserted in a test. Comments export as annotations or are omitted by choice. Search across documents and versions finds a phrase you wrote months ago. Deleting a document removes its versions, comments and anchors together. Do not build yet: Nothing after this. ### Out of scope (and why) - Collaborative editing, which is a different and much harder problem than a single writer's drafts. - Publishing and distribution, which is the part of the hosted product that turns an editor into a business. - Any generation of body text, which is the entire point of building this rather than using a general assistant. - Style enforcement against a house guide, which is a different tool for a different stage of writing. ### README must contain - That the tool never writes body text and the comment structure has no field for it - That comments anchor to quoted text and orphan rather than drift - That dismissed comments are kept because the pattern is the signal - That the model policy is per document and enforced at the call site - That export is asserted byte-identical to what you wrote
# Architecture · Lex ## Stack | Part | Choice | Why | | --- | --- | --- | | Editor | ProseMirror over a document model with stable positions | comments anchor to ranges and those anchors must survive editing | | Runtime | TypeScript with a local application and SQLite | drafts are the most private thing most people write | | Comments | Generated on demand, anchored to ranges, never applied automatically | the whole premise is that the writing stays yours | | Versions | Every session's text kept, diffable | seeing how a draft changed is most of the value of keeping drafts | | Models | Local by default, hosted as an explicit per-document choice | an unfinished draft going to a service should be a decision | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Editor | The document and its stable positions. | Hard to swap. The position model is the foundation the anchors sit on. | | Comments | Generation with no replacement text. | Any model, provided the output structure stays free of a body-text field. | | Anchors | Relocation and orphaning. | Any implementation. Orphaning rather than drifting is the requirement. | | Policy | Per-document model choice, enforced at the call site. | Not swappable for a global setting, which is what makes it hold. | | Export | Text out, asserted unchanged. | Any format. The byte-identity assertion is the part that stays. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DB_PATH` · required · Documents, versions and comments. Back this up like the drafts it holds. - `DEFAULT_MODEL_POLICY` · required · What a new document gets. Local, so sending is a decision rather than a default. - `LOCAL_MODEL` · required · The local model for comments. Must follow structured output reliably. - `SESSION_GAP_MINUTES` · required · The activity gap that ends a writing session and snapshots a version. - `HOSTED_API_KEY` · optional, secret · Only for documents explicitly set to a hosted model. Absent means local only, which is a fine way to run. ## 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 · Lex product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: ProseMirror over a document model with stable positions, TypeScript with a local application and SQLite, Generated on demand, anchored to ranges, never applied automatically, Every session's text kept, diffable, Local by default, hosted as an explicit per-document choice. - 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 - Snapshotting per keystroke fills the database with thousands of near-identical versions and makes the history unusable for the thing it is for, which is seeing how the draft changed. Snapshot per session, defined by a gap in activity, and keep the current text saved continuously and separately. - A model asked to comment will offer a rewritten sentence unprompted, and the moment a rewrite is one click from being applied the tool has become a writing tool rather than a reading one. Make the output structure hold no replacement text field at all, so there is nothing to apply. - Comments anchored only to character offsets drift onto unrelated text after an edit above them, and a note about a weak argument appearing next to a paragraph it does not describe is worse than no note at all. Keep a quoted excerpt and relocate by content, and orphan the comment when the text no longer exists. - Deleting dismissed comments throws away the most useful signal in the system. A note you dismiss every time is either a deliberate stylistic choice, which the tool should learn to stop raising, or a blind spot, which is worth knowing. Keep dismissals and surface the pattern. - A global setting means one careless change sends every draft, including the ones you had decided were local only. Put the policy on the document, enforce it where the request is made rather than in the interface, and test that a local-only document raises rather than sends. - An export that silently applies a suggestion, or includes a phrase from a comment, breaks the one promise the tool makes. Compare the export byte for byte against the stored document text and treat any difference as a defect.
# Delivery milestones · Lex Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · An editor that keeps your words You can write for an hour and lose nothing. ### Steps 1. Build on a stable position model Comments anchor to ranges. A model without stable positions makes the next phase impossible. Files: `src/editor.ts` 2. Autosave continuously, snapshot per session Two different mechanisms. Conflating them ruins the history. Files: `src/versions.ts` 3. Test recovery by killing the process Mid-sentence, not at a clean point. Files: `tests/recovery.test.ts` ### Done when - [ ] An hour of writing survives a hard kill - [ ] Versions accumulate per session, not per keystroke - [ ] A diff between versions shows the changes - [ ] Positions survive a reload ### Watch out - Snapshotting per keystroke fills the database with thousands of near-identical versions and makes the history unusable for the thing it is for, which is seeing how the draft changed. Snapshot per session, defined by a gap in activity, and keep the current text saved continuously and separately. ## M2 · Comments in the margin, anchored to the text The AI responds to a passage as a reader, and the note stays on that passage. ### Steps 1. Structure the output as anchored notes Range, kind, body. No replacement field, because the absence is the design. Files: `src/comment.ts` 2. Prompt for a reader's response What confused them, what they wanted next, what the paragraph is doing. Files: `prompts/comment.md` 3. Cover the whole draft Models attend to the opening. Chunk so later sections get read too. Files: `src/chunk.ts` ### Done when - [ ] Comments anchor to ranges and display beside them - [ ] Ten comments read as questions rather than corrections - [ ] The output schema holds no replacement text - [ ] A long draft gets comments throughout ### Watch out - A model asked to comment will offer a rewritten sentence unprompted, and the moment a rewrite is one click from being applied the tool has become a writing tool rather than a reading one. Make the output structure hold no replacement text field at all, so there is nothing to apply. ## M3 · Comments that survive editing You revise a paragraph and its comments follow the text or say they cannot. ### Steps 1. Store quote plus context per comment Prefix and suffix, so relocation by content is possible. Files: `src/anchor.ts` 2. Relocate after edits Position first, quote match second, orphan third. A pure function, unit tested. Files: `src/anchor.ts`, `src/anchor.test.ts` 3. Show orphans honestly With the text they referred to. Silently dropping them loses feedback you paid for. Files: `src/margin.ts` ### Done when - [ ] Editing above leaves comments correctly placed - [ ] Rewriting a passage orphans its comment - [ ] An orphan shows its original text - [ ] Deletion does not leave dangling anchors ### Watch out - Comments anchored only to character offsets drift onto unrelated text after an edit above them, and a note about a weak argument appearing next to a paragraph it does not describe is worse than no note at all. Keep a quoted excerpt and relocate by content, and orphan the comment when the text no longer exists. ## M4 · Argue with a note, and keep the argument You can disagree with a comment and that disagreement persists. ### Steps 1. Three states, all kept Addressed, dismissed, replied. Deletion loses the signal. Files: `src/state.ts` 2. Count dismissals by kind The pattern is the interesting output, not any single dismissal. Files: `src/patterns.ts` 3. Never touch the text Addressing a comment is a state change. The revision is yours to make. Files: `src/state.ts` ### Done when - [ ] Three comment states persist - [ ] Dismissed comments are retained and countable - [ ] A repeated dismissal pattern is surfaced - [ ] No state change ever edits the document ### Watch out - Deleting dismissed comments throws away the most useful signal in the system. A note you dismiss every time is either a deliberate stylistic choice, which the tool should learn to stop raising, or a blind spot, which is worth knowing. Keep dismissals and surface the pattern. ## M5 · Where the draft goes, per document You choose per document whether it is ever sent anywhere. ### Steps 1. Policy on the document Not a global setting. The unit of decision is the draft. Files: `src/policy.ts` 2. Enforce at the call site Raise rather than send. Interface enforcement is not enforcement. Files: `src/model.ts`, `src/model.test.ts` 3. Record the model per comment So you know which reader you were listening to. Files: `src/comment.ts` ### Done when - [ ] A local-only document raises rather than sends - [ ] The policy is visible while writing - [ ] Each comment records its model - [ ] Changing policy warns about prior comments ### Watch out - A global setting means one careless change sends every draft, including the ones you had decided were local only. Put the policy on the document, enforce it where the request is made rather than in the interface, and test that a local-only document raises rather than sends. ## M6 · Getting the writing out (production only) The draft leaves in a format you can use, and comments come too if you want. ### Steps 1. Assert export equals stored text Byte for byte, in a test. This is the promise made checkable. Files: `src/export.ts`, `src/export.test.ts` 2. Comments as optional annotations Some formats support them. Omission is the default. Files: `src/export.ts` 3. Index documents and versions Finding the paragraph you cut three drafts ago is why versions are kept. Files: `src/search.ts` ### Done when - [ ] Export is byte-identical to the stored text - [ ] Comments export as annotations or are omitted - [ ] Search finds an old phrase across versions - [ ] Deleting a document removes everything attached ### Watch out - An export that silently applies a suggestion, or includes a phrase from a comment, breaks the one promise the tool makes. Compare the export byte for byte against the stored document text and treat any difference as a defect.
# Operations · Lex ## Backup Documents and versions are the irreplaceable data. Comments regenerate, drafts do not. Back up the database and test a restore by opening a document and reading its history. ## Restore Restore and confirm versions, anchors and policies survived. A restored local-only document that comes back as hosted is a serious defect. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a personal tool, the thing worth watching is orphan rate. Comments orphaning constantly means the anchoring is too brittle, and comments never orphaning means it is drifting instead. ## Incident checklist If a comment appears next to text it does not describe, the anchor relocated wrongly and that is the highest-priority defect, because misplaced feedback is worse than none. If any generated text reaches the document, stop and fix the structure, since that is the one promise. 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 - [ ] The comment schema is confirmed to hold no replacement text - [ ] Rewriting a passage is confirmed to orphan rather than drift its comment - [ ] A local-only document is confirmed to raise rather than send - [ ] Export is confirmed byte-identical to the stored text - [ ] An hour of writing is confirmed to survive a hard kill ## Launch constraint Do not market omitted Lex 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. Documents, versions and comments. Back this up like the drafts it holds. DB_PATH=~/.local/share/lex/lex.db # Required. What a new document gets. Local, so sending is a decision rather than a default. DEFAULT_MODEL_POLICY=local # Required. The local model for comments. Must follow structured output reliably. LOCAL_MODEL=llama3.1:8b # Required. The activity gap that ends a writing session and snapshots a version. SESSION_GAP_MINUTES=30 # Optional · secret. Only for documents explicitly set to a hosted model. Absent means local only, which is a fine way to run. HOSTED_API_KEY=sk-...
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Lex because the subscription bundles a refined workflow, proprietary signals, templates, and predictable output quality for a team. The recurring cost buys prompt maintenance, retrieval quality, source handling, provider changes, and editorial QA, not just the visible interface.
xcollaborative editor polish, sync, and embedded model access
xproprietary ranking data
xbrand-trained models
xteam workflows
xlarge template libraries
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Vibecode Lex
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Lex replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Lex cost?
Lex costs about $12/month (Pro, checked 2026-07-31), which is $144 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Lex?
Honestly: collaborative editor polish, sync, and embedded model access; proprietary ranking data; brand-trained models; team workflows; large template libraries. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Lex?
Yes: AppFlowy (A local-first collaborative document workspace with comments, history, and free Ollama-backed AI; Notion-shaped, but it can replace Lex.) The prompt is for when you want it exactly your way.