litura-app 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,16 @@ number carries breaking changes.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.3.1 — 2026-09-07
9
+
10
+ - The update badge now copies `npx litura-app` instead of opening a changelog, so the next update command is ready to paste.
11
+
12
+ ## 0.3.0 — 2026-09-07
13
+
14
+ - Review remarks stay beside the passage they describe without shifting the draft when opened or clicked.
15
+ - Added document actions and local history, replace-in-place previews, light/dark appearance settings, and automatic settings saves.
16
+ - Refined the editor header, chat composer, settings controls, and release safety checks.
17
+
8
18
  ## 0.2.0 — 2026-09-06
9
19
 
10
20
  - Settings can ask npm about new versions once a day, off by default. When a newer version is published the header shows a badge linking to this file; the badge links rather than installs, so updating stays a command you run. Left off, Litura opens no connection of its own.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Litura is a local, AI-assisted text editor for making prose sharper and less generic without taking control away from its author. The name comes from Latin: a correction, erasure, or visible revision in a manuscript.
4
4
 
5
- ![Litura editor highlighting generic writing patterns](docs/screenshot.png)
5
+ ![Litura editor in dark mode](docs/screenshot.png)
6
6
 
7
7
  ## What it does
8
8
 
package/SPEC.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Litura is a standalone local writing editor. It helps an author find generic prose, discuss a draft, and try alternative wording while keeping every document change under explicit user control.
6
6
 
7
- The product does not claim to determine whether text was written by AI. Its local score and model review identify named writing patterns only.
7
+ The product does not claim to determine whether text was written by AI. Its review identifies named writing patterns only.
8
8
 
9
9
  ### Product principles
10
10
 
@@ -19,16 +19,17 @@ The product does not claim to determine whether text was written by AI. Its loca
19
19
 
20
20
  ```text
21
21
  +---------------------------------------------------------------+
22
- | Litura local score review model settings |
22
+ | Litura draft.md v saved Slop Score 65 settings |
23
23
  +---------------------------------------------------------------+
24
24
  | |
25
- | WORKING DOCUMENT |
25
+ | WORKING DOCUMENT | finding card |
26
+ | | |
27
+ | CodeMirror editor | finding card |
28
+ | (never moves for a card) | |
29
+ | highlighted review findings | [< 2 of 3 >] card |
26
30
  | |
27
- | CodeMirror editor |
28
- | |
29
- | highlighted review findings |
30
- | |
31
- | chat messages / finding / rewrite cards |
31
+ | chat messages and replies |
32
+ | [Review draft] reviewing... |
32
33
  | +-----------------------------------------+ |
33
34
  | | attached-passage chip | |
34
35
  | | Ask anything... | |
@@ -40,12 +41,30 @@ The current product has one working-document pane. There is no separate context
40
41
 
41
42
  ### Header
42
43
 
44
+ One row, and nothing in it spends a model call. Everything about the file is behind its name, the save status rides beside it, and the local score sits at the far end. The header is the frame the draft is read in.
45
+
43
46
  - `Litura` wordmark.
44
- - Local style score when the document is predominantly Latin script.
45
- - `Review` action. After a review it shows the number of active findings; clicking it then moves the selection to the next finding and scrolls it into view, cycling from the end back to the first. Shift-click runs a new full review.
46
- - Selected model name.
47
+ - Document menu, labelled with the draft's file name: `Open a file…`, `Export a copy`, `History`.
48
+ - Save status: `Saved`, `Saving…`, `Unsaved changes`, a save error, or a paused autosave after a conflict.
49
+ - Local slop score, labelled `Slop Score`, when the draft is predominantly Latin script.
47
50
  - Settings button.
48
51
 
52
+ The selected model is named in settings, not in the header: it is chosen rarely and read never.
53
+
54
+ Actions that spend a model call are not here: they live with the assistant, above the composer. There is no word count and no assistant-visibility toggle.
55
+
56
+ ### Rail
57
+
58
+ The rail floats in the margin the centred text column already leaves empty, so the draft sits exactly where it sat before any card existed. It appears when that margin can hold a 240-pixel card and a gutter — measured from the text column's own padding, not assumed at a breakpoint — which is a window of about 1160 pixels or wider.
59
+
60
+ - A remark about a place in the draft lives there: finding cards, and the rewrite strip while options are being tried on. Without a rail the cards stack in the panel and the strip returns to the action row.
61
+ - A card names the problem, why it is one, and the editing direction after an arrow. Hovering it lights the passage it belongs to; the open card gets a firmer border, no color, to stand out among its neighbours — what is attached is already named in the composer chip. The dismiss control appears on hover and on keyboard focus.
62
+ - Each card is positioned against the passage it anchors to. Cards that would overlap are pushed down in document order; a card whose passage has scrolled out of view is hidden with its mark.
63
+ - In a narrower window there is no rail and the same nodes stack in the panel instead. Crossing that width moves them; nothing is rebuilt or lost.
64
+ - Only the cards take clicks. The empty margin around them still belongs to the editor.
65
+
66
+ The panel keeps what is about the draft as a whole: typed messages, replies, `Review draft`, review status, and the composer.
67
+
49
68
  ### Editor
50
69
 
51
70
  - Plain-text CodeMirror 6 editor with line wrapping and history.
@@ -53,6 +72,7 @@ The current product has one working-document pane. There is no separate context
53
72
  - Warm paper canvas and header; chat blocks are white cards on it, without shadows.
54
73
  - System light/dark theme.
55
74
  - Draft saved to browser local storage after every edit.
75
+ - The placeholder of an empty draft carries the only instructions in the product: how to open a file, how to rewrite a passage, and what `Review draft` sends where.
56
76
  - The editor reserves the measured height of the floating panel, so the caret,
57
77
  the line being typed, and any continuation panel scroll above it instead of
58
78
  disappearing behind the cards.
@@ -62,15 +82,20 @@ The current product has one working-document pane. There is no separate context
62
82
  - Fixed near the bottom center of the window.
63
83
  - Grows upward as messages, findings, and alternatives appear.
64
84
  - A backdrop fades the draft out behind the panel so cards read as floating above the document rather than as part of it.
65
- - A send button sits at the right of the field and is disabled while the field is empty.
85
+ - A send button sits at the right of the field and is disabled while the field is empty. While the model is working on something the writer asked for, it is that work: the arrow gives way to a ring turning around a stop square, and pressing it stops the request. There is no separate stop control — the status and the way out are the button they just pressed. Background work — a continuation, a paragraph checked while typing — never appears there: the writer did not press send for it, and finding the arrow replaced by a spinner on every pause would make the composer feel taken away.
86
+ - A row of actions sits between the stream and the field: `Review draft` and one line of review status truncated to the leftover width. The rewrite strip joins that row only when the rail is not in the layout.
87
+ - There is no findings list. The marks in the draft are the navigation: clicking one opens its card, and `F8` and `Shift+F8` step through them in document order.
66
88
  - An attached passage is named by a small chip, not quoted: the passage itself stays highlighted in the draft.
89
+ - One field, no mode control. What the message does is decided by what is attached: a passage the writer selected is a rewrite target, a finding is something to ask about, and nothing attached is a conversation about the draft. The placeholder says which of the three is active.
67
90
  - `Enter` sends; `Shift+Enter` inserts a newline.
68
91
  - `Escape` cancels an active request or detaches the current selection.
69
- - `Clear` removes the in-memory conversation and visible cards.
92
+ - A single control above the stream closes what the assistant is showing and opens it again: `×` over an open conversation, `Conversation` over a closed one. One press puts away both the turns and the finding cards standing in the rail two `×` in two places for one idea is two presses to get a clean screen. The composer is not part of it: it is how the writer talks to the draft at all, so it is always there.
93
+ - Closing is a view, not an edit. Nothing is discarded: the turns stay in memory and in storage, the findings and their marks stay in the draft, and clicking a mark builds its card again without reopening the conversation. Anything new arriving opens the stream by itself, so a reply never lands somewhere the writer cannot see. There is no control that discards the conversation; replacing the document does that, and the server only ever reads the last 20 turns.
94
+ - A failed model call is one short sentence naming the cause — a rejected key, a rate limit, an overloaded provider — and one button doing something about it. The sentence never carries the remedy as a second clause: `Try again` is the remedy. Where retrying cannot help there is no retry, and a key problem offers `Open settings` instead. The provider's payload goes to the tooltip and the console, where it is there for a bug report and nowhere else.
70
95
 
71
96
  ## 3. Writing workflows
72
97
 
73
- ### 3.1 Local style score
98
+ ### 3.1 Local slop score
74
99
 
75
100
  The browser computes a score from 0 to 100 using:
76
101
 
@@ -79,29 +104,30 @@ The browser computes a score from 0 to 100 using:
79
104
  - moving lexical diversity;
80
105
  - repeated three-word sequences.
81
106
 
82
- For passages shorter than 40 words or three sentences, only the lexical component is used. The score is hidden for predominantly non-Latin text because the current word lists are English-specific.
107
+ For passages shorter than 40 words or three sentences, only the lexical component is used. The score is hidden for predominantly non-Latin text because the word lists are English-specific. It is a heuristic, never sent to the model, and not an authorship probability.
83
108
 
84
- The score is a heuristic used for feedback and request filtering. It is never sent to the model and is not an authorship probability.
85
-
86
- Clicking the score adds a card naming the matched tell words and phrases as they appear in the draft, plus a line for each structural axis that reads badly. A number the writer cannot trace back to their own text is only something to argue with.
109
+ Clicking it adds a card naming the matched tells as they appear in the draft, a line for each structural axis that reads badly, and what the number does not measure. A number the writer cannot trace back to their own text is only something to argue with.
87
110
 
88
111
  ### 3.2 Automatic review
89
112
 
90
- After 1.5 seconds of inactivity, the client considers completed sentences that:
113
+ On by default. 1.5 seconds after the last edit the client takes up to three blank-line-delimited paragraphs that:
91
114
 
92
115
  - are at least 25 characters long;
93
- - are not currently being edited;
94
- - have not already been checked in the current session.
116
+ - end in sentence punctuation, unless more text follows them;
117
+ - do not contain the caret;
118
+ - have not already been checked with the same neighbours and model.
119
+
120
+ There is no local prefilter: a paragraph is judged by the model or not at all, in whatever language it is written.
95
121
 
96
- For Latin-script text, a sentence is sent only when its local score is at least 20. Non-Latin sentences bypass the English-only prefilter and are reviewed by the model.
122
+ The full document is included as context, but the server instructs the model to return findings only for the submitted target paragraphs. Automatic review runs one request at a time, and the `Review draft` control shows that a background check is in flight.
97
123
 
98
- The full document is included as context, but the server instructs the model to return findings only for the submitted target sentences. Automatic review runs one request at a time, and the `Review` control shows that a background check is in flight.
124
+ The text already in the file when it is opened counts as checked. Opening a draft spends nothing; auditing what is already written is what `Review draft` is for, and a paragraph the writer touches is checked as soon as they finish it.
99
125
 
100
- Automatic review can be switched off in settings, leaving the `Review` button as the only action that spends a model call. The setting is remembered.
126
+ Automatic review and continuation suggestions are separate settings, both on by default. With both off, only actions the writer starts spend a model call. The settings are remembered.
101
127
 
102
128
  ### 3.3 Full-document review
103
129
 
104
- The `Review` button clears current findings and audits the entire non-empty document. It runs a global pass for levels 1–4 and a local pass for levels 5–7 plus generic prose in parallel. It merges contained duplicate quotes of the same code and suppresses a level-6 finding whose quote contains or is contained by a level-3 finding's quote, then returns at most eight findings with:
130
+ The `Review draft` action clears current findings and audits the entire non-empty document. It runs a global pass for levels 1–4 and a local pass for levels 5–7 plus generic prose in parallel. It merges contained duplicate quotes of the same code and suppresses a level-6 finding whose quote contains or is contained by a level-3 finding's quote, then returns at most eight findings with:
105
131
 
106
132
  - `code`: internal diagnostic level (`level-1` through `level-7`, or `generic-prose`); not shown in the interface;
107
133
  - `quote`: exact contiguous text from the draft;
@@ -111,11 +137,11 @@ The `Review` button clears current findings and audits the entire non-empty docu
111
137
 
112
138
  The prompt caps these at four words for `pattern` and twelve each for `reason` and `fix`: the card is read at a glance beside the draft, and a paragraph of explanation there is not read at all.
113
139
 
114
- Each returned quote is anchored to a non-overlapping occurrence in the current document. Findings appear as wavy underlines and move with edits outside their ranges. Editing inside a range removes its underline.
140
+ Each returned quote is anchored to a non-overlapping occurrence in the current document. Findings appear as a quiet tinted band with a hairline under it — a remark to weigh, not a spell-checker's error to clear — and move with edits outside their ranges. Editing inside a range removes its mark. The attached passage uses the same tint one step stronger, so the two states never read alike.
115
141
 
116
- A finding card carries a dismiss control. Dismissing removes the underline, the card, any alternatives requested for it, and cancels an in-flight request, so the counter only reports findings the author has not rejected.
142
+ A finding card carries a dismiss control. Dismissing removes the mark, the card, any alternatives requested for it, and cancels an in-flight request, so the counter only reports findings the author has not rejected.
117
143
 
118
- Structural findings use the same underlines and finding cards as prose findings. They cover
144
+ Structural findings use the same marks and finding cards as prose findings. They cover
119
145
  problems such as a broken opening promise, a buried point, an abrupt
120
146
  old-to-new transition, or a dropped key term. The review treats constant-topic,
121
147
  linking, super-theme, and preview-and-develop progressions as alternatives rather than a
@@ -125,25 +151,43 @@ single mandatory paragraph template.
125
151
 
126
152
  A passage can be attached to the composer in three ways:
127
153
 
128
- - click a review underline;
154
+ - click a review mark;
129
155
  - select text and open the context menu;
130
156
  - press `Cmd/Ctrl+K` with a selection.
131
157
 
132
- An attached passage is marked in the draft with a tinted highlight that survives the editor losing focus, and moves with edits like a review underline. The composer shows only the finding's pattern name, or `Selected text` for an ordinary selection.
158
+ An attached passage is marked in the draft with a tinted highlight that survives the editor losing focus, and moves with edits like a review mark. A finding already carries its own mark, so clicking one keeps that mark's look rather than laying the selection tint over it — the tracking is the same either way, only a plain selection needs the tint to show anything at all. For the same reason the composer chip is for selections only, reading `Selected text`: a finding is named on its own card beside the line, and the placeholder says what the next message will do with it. A chip on top of that read as the sentence having been pulled into the composer, which is not what happened.
133
159
 
134
- Clicking an underline places the caret where it was clicked and leaves the keyboard in the editor, so an underlined sentence stays as editable as any other text. `Cmd/Ctrl+K` and the context menu move focus to the composer instead, because both are explicit requests to instruct.
160
+ Clicking a mark places the caret where it was clicked and leaves the keyboard in the editor, so a marked sentence stays as editable as any other text. `Cmd/Ctrl+K` and the context menu move focus to the composer instead, because both are explicit requests to instruct.
135
161
 
136
162
  Editing inside the attached passage ends the attachment and cancels an in-flight rewrite: the author has taken the sentence over, and alternatives generated for the old wording no longer apply.
137
163
 
138
- Clicking a generic prose finding attaches its quote and shows its card. It makes no model request: the card carries an `Offer rewrites` control, and reading the remark and fixing the sentence by hand is a complete outcome. Clicking the same underline again returns to the card already in the stream rather than repeating the remark.
164
+ Clicking any finding attaches exactly the passage its mark covers and shows its card. It makes no model request: the card carries a single `Options` control, and reading the remark and fixing the sentence by hand is a complete outcome. Clicking the same mark again returns to the card already in the stream rather than repeating the remark.
139
165
 
140
- A structural finding names a problem with a paragraph, not with the sentence it quotes, so clicking one attaches the whole blank-line-delimited paragraph around the quote and its `Offer rewrites` control asks for alternatives that rewrite that paragraph sentences may be reordered and rejoined, facts and voice may not change. Typing in the composer with a structural finding attached still opens a discussion instead, which is the path for a change that has to move material between paragraphs.
166
+ There is one replacement scope, and it is the quoted passage. The tint in the draft and the range a rewrite would replace are the same span, so a finding never silently rewrites more than it marked, and the card asks for nothing before it can be used. A structural finding names a problem with the paragraph around its quote; its rewrite still replaces the quote, and the whole document goes to the model as context. A change that has to move material between paragraphs is a conversation: typing in the composer with a finding attached opens one.
141
167
 
142
168
  For an ordinary selection, the next composer message becomes the rewrite instruction.
143
169
 
144
- A group of alternatives carries a `Try again` control that discards the three and re-requests them with the same instruction and the same attached passage. It disappears once one alternative has been applied.
170
+ The server returns exactly three strings, and they are tried on rather than listed. One at a time, the option is substituted into the draft where the passage sits, so it is read with the sentences around it — which is what decides whether it works. It arrives on a plain white sheet rather than the accent tint a mark or an attached passage carries: inside a preview, colour belongs to the edit, and a wash underneath drowns it. Nothing is ranked or reordered by a local metric.
171
+
172
+ A finding card holds one thing at a time: the remark, then the request in flight, then the strip. Clicking `Options` replaces the remark with `Looking for options…`; when the answer lands the same slot holds the strip instead. Cancelling — `Escape`, editing, `Try again` finding nothing — puts the remark back exactly as it was.
145
173
 
146
- The server returns exactly three strings. The client optionally ranks them by the resulting whole-document local score and displays that score delta for Latin-script drafts. Clicking a card replaces only the attached range. The remaining cards are then disabled.
174
+ Only the remark is worth a box. For the two states after it the card keeps its place in the rail and drops its walls: a bordered panel around a status line, or around a row of stepper controls, is more furniture than a passing moment deserves. The strip carries no border, background or padding of its own — the controls are the interface, and nothing needs to hold them. Without a card — an ordinary selection, or `/idea` — the strip has no block to sit in and stands on its own: in the rail beside the passage, or above the composer in place of `Review draft` when there is no rail.
175
+
176
+ The substitution itself is a decoration, not an edit: the document is untouched while the writer is choosing, so nothing is autosaved, no finding is re-anchored, and undo stays clean.
177
+
178
+ - `‹ N of 3 ›` steps through the options and the writer's own wording as one ring — `Original`, then 1, 2, 3, and round again — also with `←` and `→`;
179
+ - an option is shown as a word-level edit script against the writer's own wording. What the edit *is* stays on the line for as long as the option is up: cut words stay struck through, added words stay where they landed. What is temporary is the colour. It walks through the changed runs in reading order, ninety milliseconds apart, holds for about a second and a half, and leaves the same way — so the passage announces what changed and then settles into something readable as a sentence rather than as a coloured diff. Nothing about the highlight animates size; the line's reserved height is measured against this, and a highlight that moved the text would make that measurement a lie. A cut longer than 120 characters is not struck through: a rewritten paragraph in strikethrough is a wall, not a diff;
180
+ - every option is a different length, so the paragraph would rewrap and step the rest of the draft up and down as the writer flips through. The tallest of the stops — the writer's own wording included — is measured once against a copy of the real line when the options arrive, and reserved as a minimum height on that line. Shorter options leave a little slack rather than dragging the page up. A rewrite spanning whole paragraphs is not reserved: it already replaces blocks, where there is nothing to hold still;
181
+ - stepping back to `Original` runs the same close-up in reverse — the option's words are the ones that go, the writer's own come back untinted — and only then does the sheet leave, so nothing about the passage ever cuts;
182
+ - `Try again` discards the three and re-requests them with the same instruction and the same passage.
183
+
184
+ There is no confirm button. Whatever stop the writer leaves the ring on is what the draft keeps, because they have been reading that stop in place the whole time and `Original` is one of the stops. Leaving happens by pressing `Enter`, clicking into the draft, or turning to another finding; the replacement covers the range the answer was generated for, and only while that range still holds its original text. `Escape` returns the ring to `Original` and closes, so cancelling is still cancelling. Focus moves to the strip when the options arrive: at that moment the writer is choosing, not typing.
185
+
186
+ An edit nobody pressed a button for has to leave a way back in sight. After a replacement a single `Undo` stands for eight seconds in the same card the options sat in, beside the sentence that changed; there is no word for what happened, because the sentence says it. It stands on its own, with no panel around one button, and the countdown is in the button itself: a bar across its foot drains over those eight seconds, so the control is also the clock, and the writer sees the offer expiring rather than guessing at it. `Undo` puts back exactly what was replaced and only while it is still there to put back — it is not the editor's undo stack, which by then may belong to something the writer typed afterwards. The History snapshot taken before every replacement is the longer way back.
187
+
188
+ Nothing commits on a timer. A document that changes while its author is looking out of the window is the one thing the product principles rule out.
189
+
190
+ A finding card is hidden while its own options are on screen — it has said its piece, and the strip owns the decision. Leaving on `Original` or pressing `Escape` brings it back; keeping an option removes it, because the passage it remarked on is gone.
147
191
 
148
192
  ### 3.5 Draft chat
149
193
 
@@ -164,7 +208,7 @@ After 900 milliseconds without typing, a suggestion may be requested when:
164
208
  - the caret is at the end of a paragraph or before a blank line;
165
209
  - the current line is not an `/idea` command.
166
210
 
167
- The model returns a 5-15 word continuation. Known local tell words veto the result. A valid continuation appears in a block below the current line without entering the document.
211
+ A failed continuation says so in the status line rather than going quiet: a feature that fails in silence is indistinguishable from a broken one. The model returns a 5-15 word continuation. Known local tell words veto the result. A valid continuation appears in a block below the current line without entering the document.
168
212
 
169
213
  - `Tab`: insert the suggestion at its original cursor position.
170
214
  - `Escape`: dismiss it.
@@ -172,53 +216,63 @@ The model returns a 5-15 word continuation. Known local tell words veto the resu
172
216
 
173
217
  ### 3.7 `/idea` expansion
174
218
 
175
- Typing `/idea <instruction>` on a line and pressing `Enter`:
219
+ Typing `/idea <instruction>` on a line and pressing `Enter` streams the expansion into the assistant panel, then offers it as a preview over the command line — the same try-on strip as a rewrite, with one option and no `Try again`. The command line is replaced only if the writer keeps it.
176
220
 
177
- 1. removes the command text;
178
- 2. temporarily makes the editor read-only;
179
- 3. streams generated text into the command's position;
180
- 4. restores editing and saves the document.
181
-
182
- If the request fails, the original command is restored.
221
+ If the request fails or is stopped, whatever arrived stays in the panel as text and the command line is untouched.
183
222
 
184
223
  ### 3.8 Model and access settings
185
224
 
186
225
  The settings dialog:
187
226
 
188
227
  - discovers Pi credentials and supported provider environment variables;
228
+ - lists the models each authenticated provider offers now, not the snapshot bundled with the Pi dependency. That snapshot ages, and a model added after it was published cannot be selected at all: Pi refuses a model its catalog does not know. Asking a provider what it offers is a call to the provider the writer already pointed the app at, so it needs no permission the app does not already have; `LITURA_OFFLINE_MODELS` stops it, and a failed refresh falls back to the list Pi already has;
229
+ - offers provider and model as searchable comboboxes: a trigger naming the current choice, and a popover holding a search box and the matching rows, each naming one model. Typing still matches a model's id as well as its name — the writer who knows the id finds the row, and the row still reads as a name. Six hundred models do not fit a `select`, and a native `datalist` cannot stand in — its popup never reaches the top layer above a modal dialog, and a field already holding an answer filters the writer's typing against it. The popover API still supplies the top layer, light dismiss and `Escape`; `↑` `↓` walk the list and `Enter` takes the highlighted row;
189
230
  - lists authenticated providers and their available models;
190
231
  - exposes only thinking levels supported by the selected model;
191
232
  - saves the active provider/model/thinking selection to local storage;
192
233
  - adds and removes Pi API-key credentials;
193
234
  - does not offer removal for credentials supplied by environment variables;
194
- - switches automatic review on or off.
235
+ - switches automatic review and continuation suggestions on or off, both on by default;
236
+ - switches the npm update check on or off, on by default.
237
+
238
+ The switches take effect immediately; the provider, model, and reasoning selection is applied by `Save`.
195
239
 
196
240
  When no model is selected, the review, rewrite, and chat actions re-check Pi status once, then report the missing setup in the composer and open this dialog instead of failing silently. Automatic review and continuation suggestions stay silent and make no request.
197
241
 
198
242
  ## 4. State and privacy
199
243
 
244
+ Document-specific browser keys use the prefix `litura:<id>:`, where `id` is the SHA-256 hash of the canonical draft path returned by the server. In the table below, document keys are relative to that prefix. Model and background-action settings remain shared within the browser origin.
245
+
200
246
  | State | Location | Lifetime |
201
247
  |---|---|---|
202
- | Working document | `localStorage["wa-working"]` | Until browser storage is cleared |
248
+ | Working document | `wa-working` and `wa-working:<tabId>` in local storage; tab ID in session storage | Until browser storage is cleared |
203
249
  | Working document mirror | `DRAFT_FILE` (default `<cwd>/draft.md`) | Until the file is deleted |
204
250
  | Agent selection | `localStorage["wa-agent"]` | Until browser storage is cleared |
205
- | Chat history | `localStorage["wa-chat"]`, last 20 turns | Until `Clear` or browser storage is cleared |
206
- | Findings | `localStorage["wa-findings"]` | Until a new review, `Clear`, or browser storage is cleared |
207
- | Automatic review setting | `localStorage["wa-autoreview"]` | Until browser storage is cleared |
208
- | Update-check setting | `localStorage["wa-updatecheck"]`, off unless set to `on` | Until browser storage is cleared |
251
+ | Chat history | `wa-chat`, last 20 turns | Until document replacement or browser storage is cleared |
252
+ | Findings | `wa-findings`, with the reviewed document text | Replaced by a successful full review; cleared on document replacement |
253
+ | Dismissed findings | `dismissed`, last 100, tied to exact document text | Until document replacement or browser storage is cleared |
254
+ | Disk history | `<draft>.litura-history`; latest 20 returned to the UI | No automatic deletion |
255
+ | Browser checkpoints | `snapshots`, last 10; other tabs' working copies also appear in History | Checkpoints rotate; working copies remain until browser storage is cleared |
256
+ | Automatic review setting | `localStorage["wa-autoreview"]`, on unless set to `off` | Until browser storage is cleared |
257
+ | Continuation-suggestion setting | `localStorage["wa-autosuggest"]`, on unless set to `off` | Until browser storage is cleared |
258
+ | Update-check setting | `localStorage["wa-updatecheck"]`, on unless set to `off` | Until browser storage is cleared |
209
259
  | Last seen published version | `localStorage["wa-update"]` as `{ latest, at }`, reused for 24 hours | Until browser storage is cleared |
210
- | Checked sentences | Browser memory | Until reload or a full review reset |
260
+ | Checked paragraphs | Browser memory, keyed by text, neighbours and model | Until reload or a full review reset |
211
261
  | API credentials | Pi credential storage or environment | Managed by Pi |
212
262
 
213
- The document is written to a local Markdown file 800 milliseconds after the last edit. The browser copy is authoritative: the file is read back into the editor only when `localStorage["wa-working"]` is empty, or when the writer accepts the prompt described below.
263
+ Each edit updates the browser working copy and schedules a disk save after 800 milliseconds. Saves run one at a time. Each request includes the last known file revision; a stale revision returns `409` without overwriting the file. The server keeps the previous disk text, writes a temporary file, flushes it, rechecks the revision, and renames the temporary file over the draft. A lock coordinates saves between Litura processes; external editors do not participate in that lock.
264
+
265
+ At startup, the editor waits for the server to identify and read the draft. It restores this tab's working copy when available, otherwise the document's shared browser copy, otherwise the disk text. An empty browser copy is distinct from a missing one. Legacy unscoped browser text is offered for export, not silently imported into another workspace.
266
+
267
+ If browser and disk differ at startup, or the disk revision changes when the window regains focus or visibility, Litura pauses autosave. The conflict dialog shows both copies and offers `Use disk copy`, `Keep browser copy`, or `Export browser copy`. Closing the dialog does not resume saving. Choosing a copy resumes revision-checked saving; another external change can cause a new conflict. Nothing is merged automatically.
214
268
 
215
- Findings are stored as their quotes and are re-anchored against the document at load, so a finding whose text has since changed is dropped rather than misplaced. The conversation is restored as messages only; finding and alternative cards are not.
269
+ Save errors remain visible and pause autosave until the writer retries. A failed request does not advance the recorded disk revision. The browser warns before leaving while a save is in flight or the working text differs from the last confirmed disk copy.
216
270
 
217
- If the file and the browser copy differ at startup, or when the window regains focus Litura says so and offers to load the file. Until the writer accepts, the browser copy stands and the next save overwrites the file. Nothing is merged.
271
+ Findings are restored only when their stored document text exactly matches the loaded draft, then their quotes are re-anchored. Chat restores messages, not finding cards or rewrite previews. Replacing the document clears findings, dismissed findings and chat, and keeps a browser checkpoint of the previous text. Keeping an AI replacement also creates a checkpoint. Rewrite previews alone change neither stored copy.
218
272
 
219
- `Cmd/Ctrl+S` downloads the draft as `draft.md`. Dropping a text file on the editor replaces the draft, after a confirmation when the current draft is not empty.
273
+ `Cmd/Ctrl+S` requests an immediate disk save, respecting any paused conflict or error. `Export a copy` downloads the current text using the workspace draft's filename. `Open a file…` and drag-and-drop accept `.md`, `.markdown` and `.txt` files up to 1 MiB and ask before importing. Import replaces the current workspace draft's contents; it does not switch the server to the imported file's path. History can restore a previous version after confirmation while keeping a checkpoint of the current text.
220
274
 
221
- Every model-backed action sends the current full document to the selected provider. Rewrite and review requests additionally send the relevant selection or target passages. The local score does not make a network request.
275
+ Every model-backed action sends the current full document to the selected provider. Rewrite and review requests additionally send the relevant selection or target passages.
222
276
 
223
277
  ## 5. Technical architecture
224
278
 
@@ -230,7 +284,7 @@ Every model-backed action sends the current full document to the selected provid
230
284
  - **Streaming:** Server-Sent Events for `/idea` and `/chat`.
231
285
  - **Non-streaming:** JSON for status, credentials, review, rewrite, and suggestions.
232
286
 
233
- `npm start` bundles the frontend before starting the server. If that build fails, the server logs the error and serves the existing bundle. `public/app.js` is generated and should not be edited directly.
287
+ `npm start` bundles the frontend before starting the server in a source checkout. If the build fails, startup stops with an error. The published package has no `src/` directory and serves its bundled frontend without rebuilding. `public/app.js` is generated and should not be edited directly.
234
288
 
235
289
  ### File structure
236
290
 
@@ -265,8 +319,9 @@ All bodies and non-streaming responses are JSON unless noted.
265
319
  |---|---|---|
266
320
  | `GET` | `/` | Application HTML |
267
321
  | `GET` | `/style.css`, `/app.js`, `/fonts/*` | Static assets |
268
- | `GET` | `/draft` | Current contents of the draft file as `{ text, path }` |
269
- | `PUT` | `/draft` | Overwrite the draft file with `{ text }` |
322
+ | `GET` | `/draft` | `{ text, path, id, exists, revision }`; a missing file has empty text and revision `missing` |
323
+ | `PUT` | `/draft` | Save `{ text, revision }`; returns `{ saved: true, text, path, id, exists, revision }`, or `409` with `{ error, current }` when the revision differs |
324
+ | `GET` | `/draft/history` | `{ snapshots }`, latest 20 disk backups with `text`, `at` and `revision` |
270
325
  | `GET` | `/api/version` | `{ name, current }`; with `?check=1` also `latest` from the npm registry, or `error` if it is unreachable |
271
326
  | `GET` | `/api/agent/status` | Providers, models, auth status, and default selection |
272
327
  | `POST` | `/api/agent/credentials` | Save a provider API key through Pi |
@@ -338,6 +393,7 @@ Each review pass validates diagnostic codes against its assigned levels and chec
338
393
  | `STYLE_FILE` | Writing style guide path | `<cwd>/style.md`, else the bundled `style.md` |
339
394
  | `DRAFT_FILE` | Draft mirror path | `<cwd>/draft.md` |
340
395
  | `LITURA_NO_OPEN` | Set to skip opening the browser at startup | unset |
396
+ | `LITURA_OFFLINE_MODELS` | Set to stop Litura refreshing provider model lists | unset |
341
397
 
342
398
  ## 9. Distribution and updates
343
399
 
@@ -345,7 +401,7 @@ Litura is published to npm as `litura-app`; the command it installs is `litura`.
345
401
 
346
402
  `litura --version` prints the running version. `litura --check-update` prints the published version next to it, and reports an unpublished package rather than comparing against nothing. Neither flag ranks the two versions: a local build legitimately runs ahead of the registry, and ordering semver correctly would be a dependency.
347
403
 
348
- In the browser, `Ask npm about new versions` in settings is off by default and stored in `wa-updatecheck`. Off, the browser calls `/api/version` without `?check=1` and the server contacts nothing. On, it asks at most once every 24 hours, caches the answer in `wa-update`, and shows a header badge linking to the changelog when the published version differs from the running one. Switching it off hides the badge rather than leaving a stale one.
404
+ In the browser, `Ask npm about new versions` in settings is on by default and stored in `wa-updatecheck`. Off, the browser calls `/api/version` without `?check=1` and the server contacts nothing. On, it asks at most once every 24 hours, caches the answer in `wa-update`, and shows a header badge linking to the changelog when the published version differs from the running one. Switching it off hides the badge rather than leaving a stale one.
349
405
 
350
406
  ### Releasing
351
407
 
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+
5
+ const hash = text => createHash('sha256').update(text).digest('hex');
6
+ export const MAX_DOCUMENT_BYTES = 1024 * 1024;
7
+
8
+ // A lock coordinates Litura processes. External editors do not participate;
9
+ // recheck immediately before rename and retain the previous disk copy.
10
+ export function documentStore(filename) {
11
+ const resolved = path.resolve(filename);
12
+ const file = fs.existsSync(resolved) ? fs.realpathSync(resolved) : path.join(fs.realpathSync(path.dirname(resolved)), path.basename(resolved));
13
+ const historyDir = `${file}.litura-history`;
14
+ function read() {
15
+ let text;
16
+ try { text = fs.readFileSync(file, 'utf8'); }
17
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
18
+ if (text !== undefined && Buffer.byteLength(text) > MAX_DOCUMENT_BYTES) {
19
+ throw new Error('Draft exceeds the 1 MB editing limit. Open a smaller document.');
20
+ }
21
+ return { text: text ?? '', exists: text !== undefined, revision: text === undefined ? 'missing' : hash(text), path: file, id: hash(file) };
22
+ }
23
+ function history() {
24
+ let files;
25
+ try { files = fs.readdirSync(historyDir); }
26
+ catch (error) { if (error.code === 'ENOENT') return []; throw error; }
27
+ return files.filter(name => /^\d+-[a-f0-9-]+\.json$/.test(name)).sort().reverse().slice(0, 20)
28
+ .map(name => JSON.parse(fs.readFileSync(path.join(historyDir, name), 'utf8')));
29
+ }
30
+ function write(text, revision) {
31
+ if (typeof text !== 'string' || typeof revision !== 'string') throw Object.assign(new Error('text and revision are required strings'), { status: 400 });
32
+ if (Buffer.byteLength(text) > MAX_DOCUMENT_BYTES) throw Object.assign(new Error('Draft exceeds 1 MB'), { status: 413 });
33
+ const lock = `${file}.litura-lock`;
34
+ let fd;
35
+ try { fd = fs.openSync(lock, 'wx', 0o600); }
36
+ catch (error) {
37
+ if (error.code === 'EEXIST') throw Object.assign(new Error(`Another save is active. If Litura crashed, close its processes and remove ${lock}.`), { status: 423 });
38
+ throw error;
39
+ }
40
+ const temp = `${file}.${randomUUID()}.tmp`;
41
+ try {
42
+ const before = read();
43
+ if (before.revision !== revision) throw Object.assign(new Error('The file changed. Choose which copy to keep.'), { status: 409, current: before });
44
+ if (before.exists && before.text === text) return before;
45
+ if (before.exists) {
46
+ fs.mkdirSync(historyDir, { recursive: true, mode: 0o700 });
47
+ fs.writeFileSync(path.join(historyDir, `${Date.now()}-${randomUUID()}.json`), JSON.stringify({ text: before.text, at: new Date().toISOString(), revision: before.revision }), { mode: 0o600 });
48
+ }
49
+ const mode = before.exists ? fs.statSync(file).mode & 0o777 : 0o600;
50
+ const output = fs.openSync(temp, 'wx', mode);
51
+ try { fs.writeFileSync(output, text, 'utf8'); fs.fsyncSync(output); }
52
+ finally { fs.closeSync(output); }
53
+ if (read().revision !== revision) throw Object.assign(new Error('The file changed during save.'), { status: 409, current: read() });
54
+ fs.renameSync(temp, file);
55
+ return read();
56
+ } finally {
57
+ if (fs.existsSync(temp)) fs.unlinkSync(temp);
58
+ fs.closeSync(fd);
59
+ fs.unlinkSync(lock);
60
+ }
61
+ }
62
+ return { read, write, history };
63
+ }
package/editing.js ADDED
@@ -0,0 +1,60 @@
1
+ // Each result owns its source document and span. Never retarget an old answer
2
+ // to whatever happens to be selected now. Conservative invalidation is explicit.
3
+ export function replacementTarget(document, target) {
4
+ return target && document === target.document && Number.isInteger(target.from)
5
+ && target.from >= 0 && target.to > target.from
6
+ && document.slice(target.from, target.to) === target.text;
7
+ }
8
+
9
+ export function newerVersion(latest, current) {
10
+ const parse = value => /^(\d+)\.(\d+)\.(\d+)$/.exec(value ?? '')?.slice(1).map(Number);
11
+ const a = parse(latest), b = parse(current);
12
+ if (!a || !b) return false; // Stable-release notifications only.
13
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
14
+ return false;
15
+ }
16
+
17
+ // Word-level edit script: which words a rewrite keeps, drops and adds, in
18
+ // reading order, so the change can be shown one word at a time instead of as
19
+ // one opaque block. Spaces the rewrite did not touch stay untouched.
20
+ //
21
+ // ponytail: plain LCS table, O(words²) — a sentence or a paragraph, not a
22
+ // book. Past the guard it falls back to a single changed run, which is what
23
+ // the coarse version always did.
24
+ export function wordDiff(before, after) {
25
+ const a = before.match(/\s+|\S+/g) ?? [], b = after.match(/\s+|\S+/g) ?? [];
26
+ if (a.length * b.length > 40_000) return coarseDiff(a, b);
27
+ const lcs = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1));
28
+ for (let i = a.length - 1; i >= 0; i--) {
29
+ for (let j = b.length - 1; j >= 0; j--) {
30
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
31
+ }
32
+ }
33
+ const ops = [];
34
+ const push = (type, text) => {
35
+ const last = ops[ops.length - 1];
36
+ if (last && last.type === type) last.text += text; // one run, one step
37
+ else ops.push({ type, text });
38
+ };
39
+ let i = 0, j = 0;
40
+ while (i < a.length && j < b.length) {
41
+ if (a[i] === b[j]) push('keep', a[i++]), j++;
42
+ else if (lcs[i + 1][j] >= lcs[i][j + 1]) push('del', a[i++]);
43
+ else push('ins', b[j++]);
44
+ }
45
+ while (i < a.length) push('del', a[i++]);
46
+ while (j < b.length) push('ins', b[j++]);
47
+ return ops;
48
+ }
49
+
50
+ function coarseDiff(a, b) {
51
+ let start = 0, end = 0;
52
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
53
+ while (end < a.length - start && end < b.length - start && a[a.length - end - 1] === b[b.length - end - 1]) end++;
54
+ return [
55
+ { type: 'keep', text: a.slice(0, start).join('') },
56
+ { type: 'del', text: a.slice(start, a.length - end).join('') },
57
+ { type: 'ins', text: b.slice(start, b.length - end).join('') },
58
+ { type: 'keep', text: end ? a.slice(-end).join('') : '' },
59
+ ].filter(op => op.text);
60
+ }