litura-app 0.2.0 → 0.3.0
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 +6 -0
- package/README.md +1 -1
- package/SPEC.md +115 -59
- package/document-store.js +63 -0
- package/editing.js +60 -0
- package/index.js +71 -28
- package/package.json +5 -2
- package/pi.js +30 -5
- package/public/app.js +2617 -516
- package/public/index.html +105 -19
- package/public/style.css +608 -163
- package/review-model.js +12 -5
- package/review-prompt.js +2 -2
- package/review.js +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ number carries breaking changes.
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.3.0 — 2026-09-07
|
|
9
|
+
|
|
10
|
+
- Review remarks stay beside the passage they describe without shifting the draft when opened or clicked.
|
|
11
|
+
- Added document actions and local history, replace-in-place previews, light/dark appearance settings, and automatic settings saves.
|
|
12
|
+
- Refined the editor header, chat composer, settings controls, and release safety checks.
|
|
13
|
+
|
|
8
14
|
## 0.2.0 — 2026-09-06
|
|
9
15
|
|
|
10
16
|
- 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
|
-

|
|
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
|
|
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
|
|
22
|
+
| Litura draft.md v saved Slop Score 65 settings |
|
|
23
23
|
+---------------------------------------------------------------+
|
|
24
24
|
| |
|
|
25
|
-
|
|
|
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
|
-
|
|
|
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
|
-
-
|
|
45
|
-
- `
|
|
46
|
-
-
|
|
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
|
-
- `
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
94
|
-
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 | `
|
|
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 | `
|
|
206
|
-
| Findings | `
|
|
207
|
-
|
|
|
208
|
-
|
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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` |
|
|
269
|
-
| `PUT` | `/draft` |
|
|
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
|
|
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
|
+
}
|