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/LICENSE +21 -0
- package/README.md +90 -0
- package/SPEC.md +376 -0
- package/index.js +530 -0
- package/markdown.js +63 -0
- package/package.json +48 -0
- package/pi.js +149 -0
- package/public/app.js +17835 -0
- package/public/fonts/iAWriterDuoS-Bold.woff2 +0 -0
- package/public/fonts/iAWriterDuoS-BoldItalic.woff2 +0 -0
- package/public/fonts/iAWriterDuoS-Italic.woff2 +0 -0
- package/public/fonts/iAWriterDuoS-Regular.woff2 +0 -0
- package/public/index.html +104 -0
- package/public/style.css +849 -0
- package/review-model.js +38 -0
- package/review-prompt.js +93 -0
- package/review.js +265 -0
- package/style.md +150 -0
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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
|
+
}
|