litura-app 0.1.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/index.js ADDED
@@ -0,0 +1,530 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Litura — local AI-assisted writing editor
4
+ * Split-pane editor with LLM assistance via Pi
5
+ *
6
+ * Usage:
7
+ * npx litura (in the folder holding your draft)
8
+ * node index.js
9
+ */
10
+
11
+ import http from 'http';
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { spawn } from 'child_process';
15
+ import { fileURLToPath } from 'url';
16
+ import { completeText, getAgentStatus, removeProviderApiKey, saveProviderApiKey, streamText } from './pi.js';
17
+ import {
18
+ markSelection, parseVariants, selectionSlot, trimOverlap, variantLimit,
19
+ SELECT_CLOSE, SELECT_OPEN, SELECT_SLOT,
20
+ } from './review.js';
21
+ import { requestReview } from './review-model.js';
22
+ import { buildReviewTask, buildReviewUser, reviewCodesForPass } from './review-prompt.js';
23
+
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+ const MANIFEST = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
26
+ const VERSION = MANIFEST.version;
27
+
28
+ // ─── CLI ───────────────────────────────────────────────────────────────────
29
+ // Both flags run before the bundler and the server: they answer and exit.
30
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
31
+ console.log(VERSION);
32
+ process.exit(0);
33
+ }
34
+
35
+ // Litura opens no connection of its own — every request it makes belongs to a
36
+ // model call the writer asked for. So the update check is a command you run,
37
+ // not something that happens quietly at startup.
38
+ if (process.argv.includes('--check-update')) {
39
+ const name = MANIFEST.name;
40
+ // A 404 is the registry answering, not failing: an unpublished build asking
41
+ // about itself. Say so instead of reporting version "undefined".
42
+ const latest = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`)
43
+ .then(async response => {
44
+ if (response.status === 404) return null;
45
+ if (!response.ok) throw new Error(`registry returned ${response.status}`);
46
+ return (await response.json()).version;
47
+ })
48
+ .catch(error => {
49
+ console.error(`[update] npm registry unreachable — ${error.message}`);
50
+ process.exit(1);
51
+ });
52
+ if (latest === null) {
53
+ console.log(`Litura ${VERSION} — ${name} is not published; nothing to compare against.`);
54
+ process.exit(0);
55
+ }
56
+ // Stated, not judged: a local build can legitimately run ahead of the
57
+ // registry, and ranking two versions correctly is a semver dependency.
58
+ console.log(latest === VERSION
59
+ ? `Litura ${VERSION} — same as the published release.`
60
+ : `Published: ${name}@${latest}. Running: ${VERSION}.\n` +
61
+ ` npx ${name} — always runs the published release\n` +
62
+ ` npm i -g ${name} — if you installed it globally`);
63
+ process.exit(0);
64
+ }
65
+
66
+ // ─── Bundle frontend (CodeMirror 6 → public/app.js) ────────────────────────
67
+ // Only in a source checkout. The published package ships public/app.js already
68
+ // built and has no esbuild, so src/ missing is the signal to skip.
69
+ if (fs.existsSync(path.join(__dirname, 'src/app.js'))) {
70
+ try {
71
+ const { build } = await import('esbuild');
72
+ await build({
73
+ entryPoints: [path.join(__dirname, 'src/app.js')],
74
+ bundle: true,
75
+ outfile: path.join(__dirname, 'public/app.js'),
76
+ format: 'iife',
77
+ logLevel: 'warning',
78
+ });
79
+ console.log('[build] Frontend bundled ✓');
80
+ } catch (e) {
81
+ console.error('[build] Frontend build failed — server will serve stale bundle.\n', e.message);
82
+ }
83
+ }
84
+
85
+ const PORT = parseInt(process.env.PORT || '3456', 10);
86
+ const PUBLIC = path.join(__dirname, 'public');
87
+
88
+ // Draft and style live in the folder Litura was started from, not inside the
89
+ // install directory — `npx litura` in a notes folder edits that folder. The
90
+ // bundled style.md is the fallback when the folder has none of its own.
91
+ const CWD = process.cwd();
92
+ const DRAFT_FILE = process.env.DRAFT_FILE || path.join(CWD, 'draft.md');
93
+ const STYLE_FILE = process.env.STYLE_FILE
94
+ || [path.join(CWD, 'style.md'), path.join(__dirname, 'style.md')].find(p => fs.existsSync(p))
95
+ || path.join(CWD, 'style.md');
96
+
97
+ // ─── Style guide ───────────────────────────────────────────────────────────
98
+ let styleWarnedOnce = false;
99
+
100
+ function readStyle() {
101
+ try {
102
+ return fs.readFileSync(STYLE_FILE, 'utf8').trim();
103
+ } catch {
104
+ if (!styleWarnedOnce) {
105
+ console.warn(`[style] No style.md found at ${STYLE_FILE} — proceeding without style guide`);
106
+ styleWarnedOnce = true;
107
+ }
108
+ return null;
109
+ }
110
+ }
111
+
112
+ // The same patterns /review flags. Every route that generates prose gets these,
113
+ // so the app never writes what it is about to underline.
114
+ const NO_SLOP =
115
+ 'Never produce throat-clearing, vague attribution ("experts agree", "studies show"), empty puffery, ' +
116
+ 'faux insight, generic filler, "not just X, but Y" contrasts, robotic parallel rhythm, ' +
117
+ 'dramatic one-word fragments, stacked hedging, or decorative emphasis. Prefer concrete, specific wording over general claims. ' +
118
+ 'Do not give tools or abstractions human understanding or intent. ';
119
+
120
+ // A variant several times the length of the selection is the whole-document
121
+ // rewrite failure, not a stylistic choice. Name it once and take the retry.
122
+ // A malformed answer is retried the same way review retries: an occasional bad
123
+ // response should cost a second call, not show the writer a parser error.
124
+ const REWRITE_ATTEMPTS = 3;
125
+
126
+ async function completeVariants({ system, user, body }) {
127
+ const selected = String(body.selected ?? '');
128
+ const limit = variantLimit(selected);
129
+ let prompt = user;
130
+ for (let attempt = 1; ; attempt++) {
131
+ const raw = await completeText({
132
+ systemPrompt: system,
133
+ userPrompt: prompt,
134
+ selection: body.agent,
135
+ // Shared with reasoning tokens: at 1500 a thinking model spends the whole
136
+ // budget deliberating and returns an empty string. Three short strings
137
+ // cost nothing, so give it the same room /review has.
138
+ maxTokens: 4000,
139
+ signal: AbortSignal.timeout(90_000),
140
+ });
141
+ let variants;
142
+ try {
143
+ variants = parseVariants(raw);
144
+ } catch (error) {
145
+ if (attempt >= REWRITE_ATTEMPTS) throw error;
146
+ console.warn(`[/rewrite] ${error.message} — retrying (${attempt}/${REWRITE_ATTEMPTS}); model said: ${JSON.stringify(raw.slice(0, 200))}`);
147
+ continue;
148
+ }
149
+ if (attempt >= REWRITE_ATTEMPTS || !variants.some(variant => variant.length > limit)) return variants;
150
+ console.warn('[/rewrite] out-of-scope variants — retrying with the selection restated');
151
+ prompt = `${user}\n\n---\n\nYour previous answer rewrote text outside the selection. ` +
152
+ `Replace only ${SELECT_OPEN}${selected}${SELECT_CLOSE}, keep every other word of the sentence untouched, ` +
153
+ `and stay under ${limit} characters per variant.`;
154
+ }
155
+ }
156
+
157
+ function buildSystemPrompt(task) {
158
+ const style = readStyle();
159
+ const stylePart = style ? `Follow this writing style guide:\n\n${style}\n\n---\n\n` : '';
160
+ return stylePart + task;
161
+ }
162
+
163
+ // ─── Static file serving ───────────────────────────────────────────────────
164
+ const MIME = {
165
+ '.html': 'text/html; charset=utf-8',
166
+ '.css': 'text/css; charset=utf-8',
167
+ '.js': 'application/javascript; charset=utf-8',
168
+ '.woff2': 'font/woff2',
169
+ '.woff': 'font/woff',
170
+ };
171
+
172
+ function serveStatic(res, filePath) {
173
+ const ext = path.extname(filePath);
174
+ const mime = MIME[ext] || 'text/plain';
175
+ // Fonts never change under a given name; the bundle changes with every
176
+ // update, and a tab holding a stale app.js against a new server is the
177
+ // classic post-upgrade bug. Revalidate it on each load.
178
+ const cache = ext === '.woff2' ? 'public, max-age=31536000, immutable' : 'no-cache';
179
+ try {
180
+ const data = fs.readFileSync(filePath);
181
+ res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': cache });
182
+ res.end(data);
183
+ } catch {
184
+ res.writeHead(404);
185
+ res.end('Not found');
186
+ }
187
+ }
188
+
189
+ function sendJson(res, status, value) {
190
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
191
+ res.end(JSON.stringify(value));
192
+ }
193
+
194
+ // ─── Request body ──────────────────────────────────────────────────────────
195
+ function readBody(req) {
196
+ return new Promise((resolve, reject) => {
197
+ let data = '';
198
+ req.on('data', chunk => { data += chunk; });
199
+ req.on('end', () => {
200
+ try { resolve(JSON.parse(data)); }
201
+ catch (e) { reject(e); }
202
+ });
203
+ req.on('error', reject);
204
+ });
205
+ }
206
+
207
+ // ─── Server ────────────────────────────────────────────────────────────────
208
+ const server = http.createServer(async (req, res) => {
209
+ const url = new URL(req.url, `http://localhost`);
210
+
211
+ if (req.method === 'GET' && url.pathname === '/api/agent/status') {
212
+ sendJson(res, 200, await getAgentStatus());
213
+ return;
214
+ }
215
+
216
+ if ((req.method === 'POST' || req.method === 'DELETE') && url.pathname === '/api/agent/credentials') {
217
+ try {
218
+ const body = await readBody(req);
219
+ const provider = String(body.provider ?? '').trim();
220
+ if (req.method === 'POST') await saveProviderApiKey(provider, String(body.apiKey ?? ''));
221
+ else await removeProviderApiKey(provider);
222
+ sendJson(res, 200, await getAgentStatus());
223
+ } catch (error) {
224
+ sendJson(res, 400, { error: error.message });
225
+ }
226
+ return;
227
+ }
228
+
229
+ // ── Draft file
230
+ //
231
+ // One local Markdown file is the durable copy of the working document.
232
+ // The browser keeps writing to localStorage for instant reload; this is
233
+ // what survives clearing browser data and what other editors can open.
234
+ //
235
+ if (url.pathname === '/draft') {
236
+ if (req.method === 'GET') {
237
+ let text = '';
238
+ try { text = fs.readFileSync(DRAFT_FILE, 'utf8'); } catch {}
239
+ sendJson(res, 200, { text, path: DRAFT_FILE });
240
+ return;
241
+ }
242
+ if (req.method === 'PUT') {
243
+ try {
244
+ const body = await readBody(req);
245
+ fs.writeFileSync(DRAFT_FILE, String(body.text ?? ''), 'utf8');
246
+ sendJson(res, 200, { saved: true });
247
+ } catch (error) {
248
+ console.error('[/draft]', error.message);
249
+ sendJson(res, 500, { error: error.message });
250
+ }
251
+ return;
252
+ }
253
+ }
254
+
255
+ // ── Static
256
+ if (req.method === 'GET') {
257
+ if (url.pathname === '/') serveStatic(res, path.join(PUBLIC, 'index.html'));
258
+ else if (url.pathname === '/style.css') serveStatic(res, path.join(PUBLIC, 'style.css'));
259
+ else if (url.pathname === '/app.js') serveStatic(res, path.join(PUBLIC, 'app.js'));
260
+ else if (url.pathname.startsWith('/fonts/')) {
261
+ const fontFile = path.basename(url.pathname);
262
+ serveStatic(res, path.join(PUBLIC, 'fonts', fontFile));
263
+ }
264
+ else { res.writeHead(404); res.end('Not found'); }
265
+ return;
266
+ }
267
+
268
+ // ── API
269
+ if (req.method === 'POST') {
270
+ let body;
271
+ try { body = await readBody(req); }
272
+ catch { res.writeHead(400); res.end('Bad request'); return; }
273
+
274
+ // POST /idea — SSE stream
275
+ if (url.pathname === '/idea') {
276
+ const system = buildSystemPrompt(
277
+ 'You are a writing assistant. Given the context material and the current working document, ' +
278
+ 'expand the following idea into a well-written passage that fits the tone and topic. ' +
279
+ NO_SLOP +
280
+ 'Return only the passage, no commentary, no preamble.'
281
+ );
282
+ const user = [
283
+ body.context ? `CONTEXT:\n${body.context}` : '',
284
+ body.document ? `WORKING DOCUMENT:\n${body.document}` : '',
285
+ `IDEA TO EXPAND:\n${body.idea}`,
286
+ ].filter(Boolean).join('\n\n---\n\n');
287
+
288
+ try {
289
+ res.writeHead(200, {
290
+ 'Content-Type': 'text/event-stream',
291
+ 'Cache-Control': 'no-cache',
292
+ 'Connection': 'keep-alive',
293
+ });
294
+ await streamText({
295
+ systemPrompt: system,
296
+ userPrompt: user,
297
+ selection: body.agent,
298
+ signal: AbortSignal.timeout(90_000),
299
+ onText: text => res.write(`data: ${JSON.stringify({ text })}\n\n`),
300
+ });
301
+ res.write('data: [DONE]\n\n');
302
+ res.end();
303
+ } catch (e) {
304
+ console.error('[/idea]', e.message);
305
+ if (!res.headersSent) sendJson(res, 500, { error: e.message });
306
+ else res.end(`data: ${JSON.stringify({ error: e.message })}\n\n`);
307
+ }
308
+ return;
309
+ }
310
+
311
+ // POST /chat — SSE conversation about the draft
312
+ //
313
+ // The agent reads and proposes; it never edits. Anything that reaches the
314
+ // document does so because the writer clicked it.
315
+ //
316
+ if (url.pathname === '/chat') {
317
+ const history = Array.isArray(body.messages) ? body.messages : [];
318
+ const turns = history
319
+ .filter(message => (message?.role === 'user' || message?.role === 'assistant') && String(message.content ?? '').trim())
320
+ .map(message => ({ role: message.role, content: String(message.content) }))
321
+ .slice(-20);
322
+ if (!turns.length) { sendJson(res, 400, { error: 'No messages' }); return; }
323
+
324
+ const system = buildSystemPrompt(
325
+ 'You are a writing assistant working alongside the author on the draft below. ' +
326
+ 'Answer questions about it and propose concrete wording when asked. ' +
327
+ 'You cannot edit the document yourself — the author applies what they choose, ' +
328
+ 'so give text they can paste rather than describing an edit you claim to have made. ' +
329
+ 'Be brief. Skip preamble, restating the question, and offers of further help. ' +
330
+ NO_SLOP +
331
+ (body.selection ? 'The author has selected a passage; treat it as the subject unless they say otherwise.' : '')
332
+ );
333
+ const user = [
334
+ `DRAFT:\n${body.document || '(empty)'}`,
335
+ body.selection ? `SELECTED PASSAGE:\n${body.selection}` : '',
336
+ ].filter(Boolean).join('\n\n---\n\n');
337
+
338
+ try {
339
+ res.writeHead(200, {
340
+ 'Content-Type': 'text/event-stream',
341
+ 'Cache-Control': 'no-cache',
342
+ 'Connection': 'keep-alive',
343
+ });
344
+ await streamText({
345
+ systemPrompt: `${system}\n\n---\n\n${user}`,
346
+ messages: turns,
347
+ selection: body.agent,
348
+ signal: AbortSignal.timeout(120_000),
349
+ onText: text => res.write(`data: ${JSON.stringify({ text })}\n\n`),
350
+ });
351
+ res.write('data: [DONE]\n\n');
352
+ res.end();
353
+ } catch (e) {
354
+ console.error('[/chat]', e.message);
355
+ if (!res.headersSent) sendJson(res, 500, { error: e.message });
356
+ else res.end(`data: ${JSON.stringify({ error: e.message })}\n\n`);
357
+ }
358
+ return;
359
+ }
360
+
361
+ // POST /rewrite — 3 variants
362
+ //
363
+ // The document is context, the selection is the target. Sending the draft
364
+ // as a plain block ahead of a short selection made the model rewrite the
365
+ // draft, so the selection now leads and the document carries markers
366
+ // showing exactly which span is being replaced.
367
+ //
368
+ if (url.pathname === '/rewrite') {
369
+ const system = buildSystemPrompt(
370
+ 'You are a writing assistant. Generate exactly 3 different replacements for the SELECTED TEXT ' +
371
+ 'and nothing else. Each variant must be distinct in phrasing and approach. ' +
372
+ 'The document is context: it shows where the selection sits and must not be rewritten, ' +
373
+ `summarised, or included in your output. The selection is marked ${SELECT_OPEN}like this${SELECT_CLOSE} inside it. ` +
374
+ `Each variant is substituted for the marked span alone — it fills the ${SELECT_SLOT} gap in the sentence shown below it: ` +
375
+ 'never restate, absorb, or repeat any words outside the markers, and keep roughly the length of the selection. ' +
376
+ 'Follow the instruction — it names the specific problem this replacement has to fix. ' +
377
+ NO_SLOP +
378
+ 'Example — selection "Studies show that" in "Studies show that remote teams ship faster.", ' +
379
+ 'instruction "Fix vague attribution": ["Our 2023 delivery data shows that", "Two of the three teams we tracked found that", ' +
380
+ '"In the six months after the switch,"] — not a rewrite of the whole sentence. ' +
381
+ 'Return ONLY a JSON array with exactly 3 strings: ["variant1","variant2","variant3"]. ' +
382
+ 'No markdown fences, no commentary, no explanation — just the raw JSON array.'
383
+ );
384
+ const user = [
385
+ `SELECTED TEXT — replace exactly this, nothing more:\n${body.selected}`,
386
+ body.instruction
387
+ ? `INSTRUCTION:\n${body.instruction}`
388
+ : 'Rewrite the selected text in 3 distinct ways.',
389
+ body.document && selectionSlot(body.document, body.selected, body.from)
390
+ ? `EACH VARIANT FILLS ${SELECT_SLOT} AND MUST READ CORRECTLY IN PLACE:\n` +
391
+ selectionSlot(body.document, body.selected, body.from)
392
+ : '',
393
+ body.context ? `CONTEXT:\n${body.context}` : '',
394
+ body.document
395
+ ? `DOCUMENT (context only — do not rewrite it):\n${markSelection(body.document, body.selected, body.from)}`
396
+ : '',
397
+ ].filter(Boolean).join('\n\n---\n\n');
398
+
399
+ try {
400
+ const variants = await completeVariants({ system, user, body });
401
+ res.writeHead(200, { 'Content-Type': 'application/json' });
402
+ res.end(JSON.stringify({ variants }));
403
+ } catch (e) {
404
+ console.error('[/rewrite]', e.message);
405
+ res.writeHead(500, { 'Content-Type': 'application/json' });
406
+ res.end(JSON.stringify({ error: e.message }));
407
+ }
408
+ return;
409
+ }
410
+
411
+ // POST /review — named, checkable writing-pattern findings
412
+ //
413
+ // body.target (optional) narrows the audit to specific passages while the
414
+ // full document stays in the prompt as context. The incremental
415
+ // per-sentence review uses it; the toolbar button omits it.
416
+ //
417
+ if (url.pathname === '/review') {
418
+ const document = String(body.document ?? '').trim();
419
+ const target = String(body.target ?? '').trim();
420
+ if (!document) { sendJson(res, 200, { findings: [] }); return; }
421
+
422
+ const user = buildReviewUser({ document, target, context: body.context });
423
+ const phases = target ? ['local'] : ['global', 'local'];
424
+ const prompts = phases.map(phase => ({
425
+ systemPrompt: buildSystemPrompt(buildReviewTask({ targeted: Boolean(target), phase })),
426
+ userPrompt: user,
427
+ allowedCodes: reviewCodesForPass(Boolean(target), phase),
428
+ source: target || document,
429
+ }));
430
+
431
+ try {
432
+ const findings = await requestReview({
433
+ prompts,
434
+ selection: body.agent,
435
+ });
436
+ sendJson(res, 200, { findings });
437
+ } catch (e) {
438
+ console.error('[/review]', e.message);
439
+ sendJson(res, 500, { error: e.message });
440
+ }
441
+ return;
442
+ }
443
+
444
+ // POST /suggest — inline ghost-text suggestion (VS Code style)
445
+ //
446
+ // Request: { context, document, cursor }
447
+ // cursor is the character offset where the ghost text will appear.
448
+ //
449
+ // Returns: { suggestion: string } — a short natural continuation
450
+ //
451
+ if (url.pathname === '/suggest') {
452
+ const { context, document: doc, cursor } = body;
453
+
454
+ const at = cursor ?? (doc ?? '').length;
455
+ const prefix = (doc ?? '').slice(0, at);
456
+ const suffix = (doc ?? '').slice(at);
457
+
458
+ // The same patterns /review flags — the assistant must not generate what
459
+ // the assistant is about to underline.
460
+ const system = buildSystemPrompt(
461
+ 'You are an inline writing assistant. ' +
462
+ 'Continue the text with 5 to 15 words — just enough to finish the thought, then stop. ' +
463
+ 'Match the draft\'s voice, vocabulary, and rhythm; stay on the specific subject of the last sentence. ' +
464
+ NO_SLOP +
465
+ 'Carry the thought to its next concrete step — a fact, an action, a consequence — not a general claim. ' +
466
+ 'Resume from exactly where the text stops: never repeat or restate words already written, ' +
467
+ 'and never start the sentence over. ' +
468
+ 'Return ONLY the continuation. No commentary, no quotes, no explanation.'
469
+ );
470
+
471
+ const userMsg = [
472
+ context ? `CONTEXT:\n${context}` : '',
473
+ suffix.trim() ? `TEXT THAT ALREADY FOLLOWS (do not repeat or contradict it):\n${suffix}` : '',
474
+ `Continue:\n\n${prefix}`,
475
+ ].filter(Boolean).join('\n\n---\n\n');
476
+
477
+ try {
478
+ const suggestion = await completeText({
479
+ systemPrompt: system,
480
+ userPrompt: userMsg,
481
+ selection: body.agent,
482
+ maxTokens: 80,
483
+ signal: AbortSignal.timeout(30_000),
484
+ });
485
+ res.writeHead(200, { 'Content-Type': 'application/json' });
486
+ res.end(JSON.stringify({ suggestion: trimOverlap(prefix, suggestion) }));
487
+ } catch (e) {
488
+ console.error('[/suggest]', e.message);
489
+ res.writeHead(500, { 'Content-Type': 'application/json' });
490
+ res.end(JSON.stringify({ error: e.message }));
491
+ }
492
+ return;
493
+ }
494
+
495
+ res.writeHead(404); res.end('Not found');
496
+ }
497
+ });
498
+
499
+ function openBrowser(url) {
500
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
501
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
502
+ : ['xdg-open', [url]];
503
+ spawn(cmd, args, { stdio: 'ignore', detached: true })
504
+ .on('error', () => {}) // no browser to open — the URL is already printed
505
+ .unref();
506
+ }
507
+
508
+ // A stale Litura, or anything else, may hold 3456. Walk up rather than die.
509
+ function listen(port, attempt = 0) {
510
+ // A failed attempt leaves its handlers queued; without this the next success
511
+ // fires every one of them and announces a port nothing is bound to.
512
+ server.removeAllListeners('error');
513
+ server.removeAllListeners('listening');
514
+
515
+ server.once('error', (error) => {
516
+ if (error.code === 'EADDRINUSE' && attempt < 10) return listen(port + 1, attempt + 1);
517
+ console.error(`[server] ${error.message}`);
518
+ process.exit(1);
519
+ });
520
+ server.once('listening', () => {
521
+ const url = `http://127.0.0.1:${port}`;
522
+ console.log(`Litura → ${url}`);
523
+ console.log(`Draft → ${DRAFT_FILE}`);
524
+ console.log(`Style → ${STYLE_FILE}`);
525
+ if (!process.env.LITURA_NO_OPEN) openBrowser(url);
526
+ });
527
+ server.listen(port, '127.0.0.1');
528
+ }
529
+
530
+ listen(PORT);
package/markdown.js ADDED
@@ -0,0 +1,63 @@
1
+ // Minimal Markdown for chat replies.
2
+ //
3
+ // Safety comes from order: the source is HTML-escaped first, so nothing the
4
+ // model writes can become a tag. Only the fixed set of elements below is ever
5
+ // emitted, which is why this needs no sanitiser and no dependency.
6
+ //
7
+ // ponytail: covers what shows up in short answers — emphasis, code, lists,
8
+ // links. No tables, footnotes, or nested lists. Reach for a real parser if
9
+ // replies ever grow into documents.
10
+
11
+ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
12
+ const escapeHtml = text => text.replace(/[&<>"']/g, char => ESCAPES[char]);
13
+
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}$`);
19
+
20
+ function inline(text) {
21
+ return text
22
+ // href is already escaped; the scheme check keeps out javascript: and data:
23
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (whole, label, href) =>
24
+ /^https?:\/\//i.test(href)
25
+ ? `<a href="${href}" target="_blank" rel="noreferrer noopener">${label}</a>`
26
+ : whole)
27
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
28
+ .replace(/(^|[^*\w])\*([^*\n]+)\*/g, '$1<em>$2</em>')
29
+ .replace(/(^|[^_\w])_([^_\n]+)_(?![\w_])/g, '$1<em>$2</em>');
30
+ }
31
+
32
+ function block(text) {
33
+ if (!text) return '';
34
+ if (IS_PLACEHOLDER.test(text)) return text; // a fenced block stands alone
35
+
36
+ const lines = text.split('\n');
37
+ if (lines.every(line => /^\s*[-*+]\s+/.test(line))) {
38
+ return `<ul>${lines.map(line => `<li>${inline(line.replace(/^\s*[-*+]\s+/, ''))}</li>`).join('')}</ul>`;
39
+ }
40
+ if (lines.every(line => /^\s*\d+[.)]\s+/.test(line))) {
41
+ return `<ol>${lines.map(line => `<li>${inline(line.replace(/^\s*\d+[.)]\s+/, ''))}</li>`).join('')}</ol>`;
42
+ }
43
+ const heading = lines.length === 1 && text.match(/^#{1,6}\s+(.+)$/);
44
+ if (heading) return `<p class="md-heading">${inline(heading[1])}</p>`;
45
+
46
+ return `<p>${inline(text)}</p>`;
47
+ }
48
+
49
+ export function renderMarkdown(source) {
50
+ const stash = [];
51
+ const keep = html => `${MARK}${stash.push(html) - 1}${MARK}`;
52
+
53
+ // Lift code out before anything else, so emphasis rules never run inside it.
54
+ const text = escapeHtml(String(source ?? ''))
55
+ .replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, body) => keep(`<pre><code>${body.replace(/\n$/, '')}</code></pre>`))
56
+ .replace(/`([^`\n]+)`/g, (_, body) => keep(`<code>${body}</code>`));
57
+
58
+ return text
59
+ .split(/\n{2,}/)
60
+ .map(part => block(part.trim()))
61
+ .join('')
62
+ .replace(PLACEHOLDER, (_, index) => stash[Number(index)]);
63
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "litura-app",
3
+ "version": "0.1.0",
4
+ "description": "A local AI-assisted editor for sharper, more human writing",
5
+ "keywords": ["writing", "editor", "ai", "prose", "markdown", "local"],
6
+ "license": "MIT",
7
+ "author": "Vadim Chirkov",
8
+ "homepage": "https://github.com/vadimchirkov/litura#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/vadimchirkov/litura.git"
12
+ },
13
+ "bugs": "https://github.com/vadimchirkov/litura/issues",
14
+ "type": "module",
15
+ "main": "index.js",
16
+ "bin": {
17
+ "litura": "index.js"
18
+ },
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "files": [
23
+ "index.js",
24
+ "pi.js",
25
+ "markdown.js",
26
+ "review.js",
27
+ "review-model.js",
28
+ "review-prompt.js",
29
+ "public",
30
+ "style.md",
31
+ "SPEC.md"
32
+ ],
33
+ "scripts": {
34
+ "start": "node index.js",
35
+ "build": "esbuild src/app.js --bundle --outfile=public/app.js --format=iife",
36
+ "check": "node --check index.js && node --check pi.js && node --check review-model.js && node --check deepcheck.js && npm run build && node selfcheck.js",
37
+ "check:deep": "node deepcheck.js",
38
+ "prepublishOnly": "npm run build"
39
+ },
40
+ "dependencies": {
41
+ "@earendil-works/pi-ai": "0.84.2",
42
+ "@earendil-works/pi-coding-agent": "0.84.2"
43
+ },
44
+ "devDependencies": {
45
+ "codemirror": "^6.0.1",
46
+ "esbuild": "^0.25.0"
47
+ }
48
+ }