litura-app 0.4.2 → 0.5.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/SPEC.md CHANGED
@@ -18,33 +18,33 @@ The product does not claim to determine whether text was written by AI. Its revi
18
18
  ## 2. Interface
19
19
 
20
20
  ```text
21
- +---------------------------------------------------------------+
22
- | [logo] Style 3 settings |
23
- +---------------------------------------------------------------+
24
- | |
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 |
30
- | |
31
- | chat messages and replies |
32
- | [Review draft] reviewing... |
33
- | +-----------------------------------------+ |
34
- | | attached-passage chip | |
35
- | | Ask anything... | |
36
- | +-----------------------------------------+ |
37
- +---------------------------------------------------------------+
21
+ +--------+------------------------------------------------------+
22
+ | Files | [sidebar] Style 3 settings |
23
+ | list +------------------------------------------------------+
24
+ | New | |
25
+ | Open… | WORKING DOCUMENT | finding card |
26
+ | Save… | | |
27
+ | History| CodeMirror editor | finding card |
28
+ | | (never moves for a card) | |
29
+ | | highlighted review findings | [< 2 of 3 >] card |
30
+ | | |
31
+ | | chat messages and replies |
32
+ | | [Review draft] reviewing... |
33
+ | | +-----------------------------------------+ |
34
+ | | | attached-passage chip | |
35
+ | | | Ask anything... | |
36
+ | | +-----------------------------------------+ |
37
+ +--------+------------------------------------------------------+
38
38
  ```
39
39
 
40
- The current product has one working-document pane. There is no separate context pane, file browser, or document list.
40
+ The current product has one working-document pane and a file sidebar. There is no separate context pane.
41
41
 
42
42
  ### Header
43
43
 
44
- One row, and nothing in it spends a model call. Everything about the file is behind the logo, and the local score sits at the far end. The header is the frame the draft is read in — it carries no file name and no status line of its own.
44
+ One row, and nothing in it spends a model call. Everything about the file lives in the sidebar behind the sidebar toggle, and the local score sits at the far end. The header is the frame the draft is read in — it carries no file name and no status line of its own.
45
45
 
46
- - Logo button opening the document menu: `Open a file…`, `Export a copy`, `History`.
47
- - Save trouble — a save error, or a paused autosave after a conflict — is reported in the chat stream, where the writer is already reading. Routine saves are silent.
46
+ - Sidebar toggle opening the file sidebar: the list of Markdown and text files next to the draft, a filter, and `New`, `Open a file…`, `Save as new…`, `History`. Opening a listed file replaces the workspace draft at once — the click is the intent, and the previous text is kept in History.
47
+ - Save trouble — a save error, or disk writes waiting on an unresolved divergence — is named where the writer is already reading. Routine saves are silent.
48
48
  - Local style signals, labelled `Style` with a count of marked passages, when the draft is predominantly Latin script. Hidden otherwise.
49
49
  - Settings button.
50
50
 
@@ -163,6 +163,8 @@ Editing inside the attached passage ends the attachment and cancels an in-flight
163
163
 
164
164
  Clicking any finding shows its card and offers a bubble with `Add to context` and `Suggest rewrites`. 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 its card rather than repeating the remark.
165
165
 
166
+ The bubble is placed inside the writing pane and below the header, never over the sidebar or the fixed header band: a toolbar a chrome layer covers is a toolbar that looks present and swallows the click meant for it. It is anchored to the words it was raised on: a scroll, the panel opening under it or a window resize carries it along with them, and it hides while they are scrolled out of the pane. Only an edit, which may remove those words, takes it away.
167
+
166
168
  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.
167
169
 
168
170
  For an ordinary selection, the next composer message becomes the rewrite instruction.
@@ -189,7 +191,7 @@ The conversation survives a reload. A message sent with a finding attached carri
189
191
 
190
192
  ### 3.6 Continuation suggestions
191
193
 
192
- After 900 milliseconds without typing, a suggestion may be requested when:
194
+ After 200 milliseconds without typing, a suggestion may be requested when:
193
195
 
194
196
  - the document contains at least 15 non-whitespace characters;
195
197
  - the selection is collapsed;
@@ -199,7 +201,7 @@ After 900 milliseconds without typing, a suggestion may be requested when:
199
201
 
200
202
  Only typing (including composition) starts this timer. Paste, deletion, Undo, Redo, loading a document and accepting an AI edit cancel pending suggestions without requesting another one.
201
203
 
202
- A failed continuation says so in the status line; the next successful request clears its error. The model may return an empty continuation when the thought is finished or continuing would invent facts or the author's intent. Non-empty output is a single line of at most 15 words; one word or punctuation alone is allowed. The provider returns `NONE`, `TEXT: …` for new words or punctuation, or `WORD: …` for missing letters, so prose quotation marks do not need JSON escaping. The HTTP response still uses `{ suggestion: string }`. Invalid output is reported as an error and never inserted. Spacing is applied at the insertion boundary; repeated tails are removed only at word boundaries. A valid continuation appears in a block below the current line, is announced to assistive technology, and does not enter the document until accepted.
204
+ A failed continuation says so in the notice; the next successful request clears its error. The model may return an empty continuation when the thought is finished or continuing would invent facts or the author's intent. Non-empty output is a single line of at most 15 words; one word or punctuation alone is allowed. The provider returns `NONE`, `TEXT: …` for new words or punctuation, or `WORD: …` for missing letters, so prose quotation marks do not need JSON escaping. The HTTP response still uses `{ suggestion: string }`. Invalid output is reported as an error and never inserted. Spacing is applied at the insertion boundary; repeated tails are removed only at word boundaries. A valid continuation appears in a block below the current line, is announced to assistive technology, and does not enter the document until accepted.
203
205
 
204
206
  - `Tab`: insert the suggestion at its original cursor position.
205
207
  - `Escape`: dismiss it and cancel pending work, even before an answer arrives.
@@ -229,7 +231,7 @@ The settings dialog:
229
231
 
230
232
  All settings take effect immediately; there is no Save button.
231
233
 
232
- When no model is selected, the review, rewrite, chat, and manual-continuation 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.
234
+ When no model is selected, the review, rewrite, chat, and manual-continuation actions re-check Pi status once, then report the missing setup in the notice and open this dialog instead of failing silently. Automatic review and continuation suggestions stay silent and make no request.
233
235
 
234
236
  ## 4. State and privacy
235
237
 
@@ -256,13 +258,15 @@ Each edit updates the browser working copy and schedules a disk save after 800 m
256
258
 
257
259
  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.
258
260
 
259
- 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.
261
+ If browser and disk differ at startup, or the disk revision changes when the window regains focus or visibility, Litura never opens a modal. When the browser copy is untouched since the last confirmed save it takes the disk copy silently; when only one side moved there is nothing to decide. Only two genuinely diverged copies raise a slim banner above the draft — `Load disk`, `Keep mine`, and a `What changed` word diff — while the draft stays editable and only disk writes wait. Choosing a copy resumes revision-checked saving; another external change can raise the banner again. Nothing is merged automatically.
260
262
 
261
263
  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.
262
264
 
263
265
  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.
264
266
 
265
- `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.
267
+ `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 import without asking. Import replaces the current workspace draft's contents; it does not switch the server to the imported file's path. History restores a previous version at once while keeping a checkpoint of the current text. `New`, import and restore never ask first: the replaced text goes to History, and the notice offers `Undo` for a few seconds.
268
+
269
+ Everything the app reports outside the conversation — save and load trouble, model errors, confirmations with `Undo` — appears in one notice at the bottom-left, one at a time. Passing notices fade after six seconds; problems stay until their action or `×`. A chat answer never shows the raw edit list: a response that drops the `<litura-edits>` tags still yields edit cards.
266
270
 
267
271
  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.
268
272
 
@@ -343,6 +347,8 @@ All bodies and non-streaming responses are JSON unless noted.
343
347
  | `GET` | `/draft` | `{ text, path, id, exists, revision }`; a missing file has empty text and revision `missing` |
344
348
  | `PUT` | `/draft` | Save `{ text, revision }`; returns `{ saved: true, text, path, id, exists, revision }`, or `409` with `{ error, current }` when the revision differs |
345
349
  | `GET` | `/draft/history` | `{ snapshots }`, latest 20 disk backups with `text`, `at` and `revision` |
350
+ | `GET` | `/files` | `{ files }`: Markdown and text files next to the draft, each with `name`, `size`, `mtime` and a markup-stripped `excerpt` (first meaningful prose, ~160 chars) for the card preview, sorted by name |
351
+ | `GET` | `/file?name=` | `{ name, text }` for one workspace file by basename; `400` for anything that is not a plain Markdown or text name, `404` when it is gone, `413` past 1 MB |
346
352
  | `GET` | `/api/version` | `{ name, current }`; with `?check=1` also `latest` from the npm registry, or `error` if it is unreachable |
347
353
  | `GET` | `/api/agent/status` | Providers, models, auth status, and default selection |
348
354
  | `POST` | `/api/agent/credentials` | Save a provider API key through Pi |
package/document-store.js CHANGED
@@ -24,8 +24,16 @@ export function documentStore(filename) {
24
24
  let files;
25
25
  try { files = fs.readdirSync(historyDir); }
26
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')));
27
+ const items = [];
28
+ for (const name of files.filter(name => /^\d+-[a-f0-9-]+\.json$/.test(name)).sort().reverse().slice(0, 20)) {
29
+ try {
30
+ const item = JSON.parse(fs.readFileSync(path.join(historyDir, name), 'utf8'));
31
+ if (item && typeof item.text === 'string' && typeof item.at === 'string' && typeof item.revision === 'string') items.push(item);
32
+ } catch {
33
+ // One corrupt snapshot must not hide the rest of the history.
34
+ }
35
+ }
36
+ return items;
29
37
  }
30
38
  function write(text, revision) {
31
39
  if (typeof text !== 'string' || typeof revision !== 'string') throw Object.assign(new Error('text and revision are required strings'), { status: 400 });
package/editing.js CHANGED
@@ -62,3 +62,30 @@ function coarseDiff(a, b) {
62
62
  { type: 'keep', text: end ? a.slice(-end).join('') : '' },
63
63
  ].filter(op => op.text);
64
64
  }
65
+
66
+ // Whose turn it is when the disk copy moved under the editor. lastDisk is
67
+ // the text we last confirmed on disk (durable across restarts); local is
68
+ // the editor now, disk is freshly read. No merge exists, only a verdict:
69
+ // - 'in-sync': nothing to do, adopt the fresh revision quietly.
70
+ // - 'fast-forward': the writer changed nothing since the last save — take
71
+ // the disk copy silently, no question asked.
72
+ // - 'diverged': both sides moved since the baseline — ask, but never modal.
73
+ // An unknown baseline (legacy installs) can only answer 'diverged': without
74
+ // proof the writer stood still, silence would discard their work.
75
+ export function resolveDrift(local, disk, lastDisk) {
76
+ if (local === disk) return 'in-sync';
77
+ if (lastDisk !== undefined && local === lastDisk) return 'fast-forward';
78
+ return 'diverged';
79
+ }
80
+
81
+ // Mark bubble box inside `area` (the writing pane below the header). Above
82
+ // the line, clear of its words; below with a gap when the header leaves no
83
+ // room above; pinned inside when the pane is narrower than the bubble.
84
+ export function clampBubble(x, y, size, area) {
85
+ const width = Math.min(size.w, area.right - area.left);
86
+ const left = Math.max(area.left, Math.min(x - width / 2, area.right - width));
87
+ let top = y - size.h - 4;
88
+ if (top < area.top) top = y + 20;
89
+ if (top + size.h > area.bottom) top = Math.max(area.top, area.bottom - size.h);
90
+ return { left, top };
91
+ }
package/index.js CHANGED
@@ -108,6 +108,60 @@ const STYLE_FILE = process.env.STYLE_FILE
108
108
  || [path.join(CWD, 'style.md'), path.join(ROOT, 'style.md')].find(p => fs.existsSync(p))
109
109
  || path.join(CWD, 'style.md');
110
110
 
111
+ // ─── Workspace files ───────────────────────────────────────────────────────
112
+ // The sidebar lists the Markdown and text files sitting next to the draft,
113
+ // so the writer can pull one into the workspace without leaving the page.
114
+ // The anchor is the draft's own folder, not the process working directory:
115
+ // DRAFT_FILE may point elsewhere, and the listing must follow the draft.
116
+ // Listing and reading only: writing stays with the draft CAS. Names are
117
+ // basenames with an approved extension, resolved inside that folder —
118
+ // anything else is a 400, never a filesystem walk.
119
+ const WORKSPACE_EXTENSIONS = new Set(['.md', '.markdown', '.txt']);
120
+ const FILES_DIR = path.dirname(path.resolve(DRAFT_FILE));
121
+ function workspaceFile(name) {
122
+ if (typeof name !== 'string' || !name || name.includes('\0')) return null;
123
+ if (path.basename(name) !== name) return null;
124
+ if (!WORKSPACE_EXTENSIONS.has(path.extname(name).toLowerCase())) return null;
125
+ const resolved = path.join(FILES_DIR, name);
126
+ if (path.resolve(FILES_DIR, name) !== resolved) return null;
127
+ return resolved;
128
+ }
129
+
130
+ // Two-line card preview: the first ~160 chars of actual prose. Markup is
131
+ // stripped line by line — headings, links, images, tags, comments and
132
+ // frontmatter — so the card never shows `<img …>` or `#` artefacts.
133
+ // Cut on a word boundary; a sliced half-word reads as a typo.
134
+ function cardExcerpt(text) {
135
+ const lines = text.replace(/<!--[\s\S]*?-->/g, ' ').split('\n');
136
+ let inFrontmatter = false;
137
+ const kept = [];
138
+ let length = 0;
139
+ for (let i = 0; i < lines.length && length < 200; i++) {
140
+ const raw = lines[i].trim();
141
+ if (i === 0 && /^---$/.test(raw)) { inFrontmatter = true; continue; }
142
+ if (inFrontmatter) { if (/^(---|\.\.\.)$/.test(raw)) inFrontmatter = false; continue; }
143
+ const line = raw
144
+ .replace(/<[^>]*>/g, ' ')
145
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
146
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
147
+ .replace(/(\*\*|__)(.*?)\1/g, '$2')
148
+ .replace(/(`{1,3})(.*?)\1/g, '$2')
149
+ .replace(/~~(.*?)~~/g, '$1')
150
+ .replace(/^#{1,6}\s+/, '')
151
+ .replace(/^>\s?/, '')
152
+ .replace(/^([-*+]\s+|\d+[.)]\s+)/, '')
153
+ .replace(/[*_~`|]+/g, '')
154
+ .replace(/\s+/g, ' ')
155
+ .trim();
156
+ if (!line || /^[-*_]{3,}$/.test(line)) continue;
157
+ kept.push(line);
158
+ length += line.length + 1;
159
+ }
160
+ const joined = kept.join(' ');
161
+ if (joined.length <= 160) return joined;
162
+ return joined.slice(0, 160).replace(/\s+\S*$/, '').trim();
163
+ }
164
+
111
165
  // ─── Style guide ───────────────────────────────────────────────────────────
112
166
  let styleWarnedOnce = false;
113
167
 
@@ -237,11 +291,21 @@ function readBody(req) {
237
291
  });
238
292
  }
239
293
 
294
+ // macOS gates Documents, Desktop and Downloads behind a per-app consent
295
+ // prompt; a past "Don't Allow" surfaces as EPERM on every open, with no
296
+ // re-prompt. Name the fix, not the errno.
297
+ function fsErrorMessage(error) {
298
+ if (error?.code === 'EPERM' || error?.code === 'EACCES') {
299
+ return 'Litura may not open this folder. On macOS allow it under System Settings → Privacy & Security → Files and Folders, or pick a folder outside Documents, Desktop and Downloads.';
300
+ }
301
+ return error?.message || String(error);
302
+ }
303
+
240
304
  // ─── Server ────────────────────────────────────────────────────────────────
241
305
  const server = http.createServer((req, res) => {
242
306
  handleRequest(req, res).catch(error => {
243
307
  console.error('[request]', error.message);
244
- if (!res.headersSent) sendJson(res, error.status || 500, { error: error.message });
308
+ if (!res.headersSent) sendJson(res, error.status || 500, { error: fsErrorMessage(error) });
245
309
  else res.end();
246
310
  });
247
311
  });
@@ -313,13 +377,58 @@ async function handleRequest(req, res) {
313
377
  sendJson(res, 200, { saved: true, ...draftStore.write(body.text, body.revision) });
314
378
  } catch (error) {
315
379
  console.error('[/draft]', error.message);
316
- sendJson(res, error.status || 500, { error: error.message, current: error.current });
380
+ sendJson(res, error.status || 500, { error: fsErrorMessage(error), current: error.current });
317
381
  }
318
382
  return;
319
383
  }
320
384
  }
321
385
  if (req.method === 'GET' && url.pathname === '/draft/history') return sendJson(res, 200, { snapshots: draftStore.history() });
322
386
 
387
+ if (req.method === 'GET' && url.pathname === '/files') {
388
+ let entries;
389
+ try { entries = fs.readdirSync(FILES_DIR, { withFileTypes: true }); }
390
+ catch (error) { sendJson(res, 500, { error: fsErrorMessage(error) }); return; }
391
+ const files = [];
392
+ for (const entry of entries) {
393
+ if (!entry.isFile() || entry.name.startsWith('.')) continue;
394
+ if (!WORKSPACE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) continue;
395
+ try {
396
+ const full = path.join(FILES_DIR, entry.name);
397
+ const stats = fs.statSync(full);
398
+ // Card excerpt: first meaningful text, not raw markup. Comments,
399
+ // tags, images and markdown markers never reach the two-line preview.
400
+ // Read capped — a 1 MB draft must not be fully read for a preview.
401
+ let excerpt = '';
402
+ try {
403
+ const fd = fs.openSync(full, 'r');
404
+ try {
405
+ const buf = Buffer.alloc(2048);
406
+ const n = fs.readSync(fd, buf, 0, 2048, 0);
407
+ excerpt = cardExcerpt(buf.subarray(0, n).toString('utf8').replace(/\uFFFD+$/g, ''));
408
+ } finally { fs.closeSync(fd); }
409
+ } catch { excerpt = ''; }
410
+ files.push({ name: entry.name, size: stats.size, mtime: stats.mtime.toISOString(), excerpt });
411
+ } catch { /* raced deletion — skip */ }
412
+ }
413
+ files.sort((a, b) => a.name.localeCompare(b.name));
414
+ sendJson(res, 200, { files });
415
+ return;
416
+ }
417
+ if (req.method === 'GET' && url.pathname === '/file') {
418
+ const file = workspaceFile(url.searchParams.get('name'));
419
+ if (!file) { sendJson(res, 400, { error: 'Open a Markdown or text file by name.' }); return; }
420
+ let text;
421
+ try { text = fs.readFileSync(file, 'utf8'); }
422
+ catch (error) {
423
+ const missing = error.code === 'ENOENT';
424
+ sendJson(res, missing ? 404 : 500, { error: missing ? 'File no longer exists.' : fsErrorMessage(error) });
425
+ return;
426
+ }
427
+ if (Buffer.byteLength(text) > MAX_DOCUMENT_BYTES) { sendJson(res, 413, { error: 'File exceeds the 1 MB editing limit.' }); return; }
428
+ sendJson(res, 200, { name: path.basename(file), text });
429
+ return;
430
+ }
431
+
323
432
  // ── Static
324
433
  if (req.method === 'GET') {
325
434
  if (url.pathname === '/') serveStatic(res, path.join(PUBLIC, 'index.html'));
package/markdown.js CHANGED
@@ -12,10 +12,9 @@ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&
12
12
  const escapeHtml = text => text.replace(/[&<>"']/g, char => ESCAPES[char]);
13
13
 
14
14
  // NUL cannot appear in model output and survives escaping, so a stashed code
15
- // block never collides with ordinary prose like "I have 3 apples".
16
- const MARK = '\u0000';
17
- const PLACEHOLDER = new RegExp(`${MARK}(\\d+)${MARK}`, 'g');
18
- const IS_PLACEHOLDER = new RegExp(`^${MARK}\\d+${MARK}$`);
15
+ // block never collides with ordinary prose like "I have 3 apples". If a model
16
+ // ever does emit one, fall back to a private-use character that is absent.
17
+ const MARKS = ['\u0000', '\uE000', '\uE001', '\uE002'];
19
18
 
20
19
  function inline(text) {
21
20
  return text
@@ -29,9 +28,9 @@ function inline(text) {
29
28
  .replace(/(^|[^_\w])_([^_\n]+)_(?![\w_])/g, '$1<em>$2</em>');
30
29
  }
31
30
 
32
- function block(text) {
31
+ function block(text, isPlaceholder) {
33
32
  if (!text) return '';
34
- if (IS_PLACEHOLDER.test(text)) return text; // a fenced block stands alone
33
+ if (isPlaceholder.test(text)) return text; // a fenced block stands alone
35
34
 
36
35
  const lines = text.split('\n');
37
36
  if (lines.every(line => /^\s*[-*+]\s+/.test(line))) {
@@ -47,17 +46,21 @@ function block(text) {
47
46
  }
48
47
 
49
48
  export function renderMarkdown(source) {
49
+ const raw = String(source ?? '');
50
+ const mark = MARKS.find(candidate => !raw.includes(candidate)) ?? MARKS[0];
50
51
  const stash = [];
51
- const keep = html => `${MARK}${stash.push(html) - 1}${MARK}`;
52
+ const keep = html => `${mark}${stash.push(html) - 1}${mark}`;
53
+ const isPlaceholder = new RegExp(`^${mark}\\d+${mark}$`);
54
+ const placeholder = new RegExp(`${mark}(\\d+)${mark}`, 'g');
52
55
 
53
56
  // Lift code out before anything else, so emphasis rules never run inside it.
54
- const text = escapeHtml(String(source ?? ''))
57
+ const text = escapeHtml(raw)
55
58
  .replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, body) => keep(`<pre><code>${body.replace(/\n$/, '')}</code></pre>`))
56
59
  .replace(/`([^`\n]+)`/g, (_, body) => keep(`<code>${body}</code>`));
57
60
 
58
61
  return text
59
62
  .split(/\n{2,}/)
60
- .map(part => block(part.trim()))
63
+ .map(part => block(part.trim(), isPlaceholder))
61
64
  .join('')
62
- .replace(PLACEHOLDER, (_, index) => stash[Number(index)]);
65
+ .replace(placeholder, (_, index) => stash[Number(index)]);
63
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "litura-app",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "A local AI-assisted editor for sharper, more human writing",
5
5
  "keywords": [
6
6
  "writing",
@@ -45,6 +45,7 @@
45
45
  "start": "node index.js",
46
46
  "build": "esbuild src/app.js --bundle --outfile=public/app.js --format=iife",
47
47
  "build:server": "node scripts/build-server.mjs",
48
+ "build:artifact": "node scripts/build-artifact.mjs",
48
49
  "desktop:prepare": "npm run build && npm run build:server && node scripts/fetch-node.mjs",
49
50
  "desktop": "tauri dev",
50
51
  "desktop:build": "tauri build",
@@ -58,14 +59,14 @@
58
59
  "prepublishOnly": "npm run build"
59
60
  },
60
61
  "dependencies": {
61
- "@earendil-works/pi-ai": "0.84.2",
62
- "@earendil-works/pi-coding-agent": "0.84.2"
62
+ "@earendil-works/pi-ai": "^0.87.1",
63
+ "@earendil-works/pi-coding-agent": "^0.87.1"
63
64
  },
64
65
  "devDependencies": {
65
- "@tauri-apps/cli": "^2.9.1",
66
+ "@tauri-apps/cli": "^2.11.5",
66
67
  "codemirror": "^6.0.1",
67
68
  "dictionary-en": "^4.0.0",
68
- "esbuild": "^0.25.0",
69
+ "esbuild": "^0.28.2",
69
70
  "nspell": "^2.1.5"
70
71
  }
71
72
  }
package/pi.js CHANGED
@@ -25,7 +25,16 @@ const runtime = () => runtimePromise ??= ModelRuntime.create({ allowModelNetwork
25
25
  .catch(error => {
26
26
  // No network, or a provider that will not answer: the snapshot still works.
27
27
  console.warn('[pi] model catalog refresh failed, using the bundled list —', error.message);
28
+ // The in-flight assignment already happened; clear it so a later call
29
+ // retries instead of reusing this rejection.
30
+ runtimePromise = undefined;
28
31
  return ModelRuntime.create({ allowModelNetwork: false });
32
+ })
33
+ .catch(error => {
34
+ // Even the offline snapshot can fail. Never park a rejected promise in the
35
+ // cache — the next call must try again.
36
+ runtimePromise = undefined;
37
+ throw error;
29
38
  });
30
39
 
31
40
  const requestedThinking = () => THINKING_LEVELS.includes(process.env.PI_THINKING_LEVEL)