lobstack 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/stream.mjs ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Read an OpenAI-compatible SSE stream, and keep the receipt.
3
+ *
4
+ * Two things here are load-bearing.
5
+ *
6
+ * The splitter buffers across chunk boundaries. A JSON frame can be cut in the
7
+ * middle by the network, and parsing per chunk instead of per frame drops
8
+ * tokens — which surfaces as answers that end mid-sentence and that nobody can
9
+ * reproduce.
10
+ *
11
+ * The last frame carries `x_lobstack`. That is where the Gateway puts the price
12
+ * on a streamed response, because headers are written before the provider has
13
+ * counted a token. Pricing the token counts against a local rate card instead
14
+ * is exactly what our own desktop client did, and it printed $0.00 for three
15
+ * months next to a correct invoice. This reads the number the seller sent.
16
+ */
17
+
18
+ export async function* sseFrames(body) {
19
+ const reader = body.getReader();
20
+ const decoder = new TextDecoder();
21
+ let buffer = '';
22
+ for (;;) {
23
+ const { done, value } = await reader.read();
24
+ if (done) break;
25
+ buffer += decoder.decode(value, { stream: true });
26
+ let i;
27
+ while ((i = buffer.indexOf('\n\n')) !== -1) {
28
+ const raw = buffer.slice(0, i);
29
+ buffer = buffer.slice(i + 2);
30
+ for (const line of raw.split('\n')) {
31
+ if (!line.startsWith('data:')) continue;
32
+ const payload = line.slice(5).trim();
33
+ if (payload) yield payload;
34
+ }
35
+ }
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Consume a completion stream. Calls `onText` per delta; returns the receipt.
41
+ */
42
+ export async function consume(body, onText) {
43
+ let text = '';
44
+ let usage = null;
45
+ let receipt = null;
46
+ let model = null;
47
+
48
+ for await (const payload of sseFrames(body)) {
49
+ if (payload === '[DONE]') break;
50
+ let frame;
51
+ try {
52
+ frame = JSON.parse(payload);
53
+ } catch {
54
+ continue; // a half-frame is not worth ending a turn over
55
+ }
56
+ if (frame.error) {
57
+ throw new Error(frame.error.message || 'the gateway reported an error mid-stream');
58
+ }
59
+ if (frame.model) model = frame.model;
60
+ if (frame.usage) usage = frame.usage;
61
+ if (frame.x_lobstack) receipt = frame.x_lobstack;
62
+
63
+ const delta = frame.choices?.[0]?.delta?.content;
64
+ if (typeof delta === 'string' && delta.length) {
65
+ text += delta;
66
+ onText?.(delta);
67
+ }
68
+ }
69
+ return { text, usage, receipt, model };
70
+ }
package/src/tty.mjs ADDED
@@ -0,0 +1,490 @@
1
+ /**
2
+ * The terminal, by hand.
3
+ *
4
+ * No ink, no blessed, no chalk. This process holds an `lsk_live_` credential;
5
+ * the reason `lobstack` has no dependencies is that there should be nothing
6
+ * between a user's key and us, and a TUI is not a good enough reason to change
7
+ * that. `node:readline` already ships a battle-tested escape-sequence decoder
8
+ * (`emitKeypressEvents`), the rest of a full-screen UI is nine escape
9
+ * sequences, and both are in the runtime.
10
+ *
11
+ * Everything here is deliberately conservative. The clever sequence and the
12
+ * safe sequence usually differ only in how badly they fail, and the worst
13
+ * outcome this program can produce is not an ugly frame - it is handing the
14
+ * user back a shell with the cursor hidden, raw mode on, and mouse reporting
15
+ * spraying escape bytes at their prompt.
16
+ *
17
+ * What is used, and what is refused:
18
+ *
19
+ * - `?1049h/l` for the alternate screen. One sequence, and it saves and
20
+ * restores the cursor itself. The older `?47h` + `?1048` pair needs two
21
+ * sequences and leaves the cursor wherever the app left it, and `?47` is
22
+ * the one tmux and screen historically mangled.
23
+ * - `?25l/h` to hide and show the cursor. Universal.
24
+ * - `CUP` + `EL` per changed line. No full clear per frame, because a full
25
+ * clear flickers in every terminal that does not implement synchronised
26
+ * output - which is most of them.
27
+ * - NO mouse reporting (`?1000`, `?1002`, `?1003`, `?1006`). JetBrains and
28
+ * several tmux configurations break click-to-select once it is on, and a
29
+ * process that dies before sending the disable sequence leaves the user's
30
+ * shell receiving mouse packets as keystrokes. Keyboard-only costs this
31
+ * program nothing.
32
+ * - NO synchronised output (`?2026`) and NO focus reporting (`?1004`). They
33
+ * are private modes that most terminals ignore politely and a few (older
34
+ * ConEmu, some JetBrains builds) echo as literal text into the frame.
35
+ * Per-line diffing removes the flicker they would have fixed.
36
+ * - NO application cursor keys (`?1h`). `emitKeypressEvents` decodes both
37
+ * the normal and the application form, so turning it on buys nothing and
38
+ * is one more mode to restore.
39
+ * - NO DECAWM off (`?7l`). Instead the last column is never written. See
40
+ * `usableWidth`.
41
+ */
42
+
43
+ import { emitKeypressEvents } from 'node:readline';
44
+
45
+ /* -- escape sequences --------------------------------------------------- */
46
+
47
+ const ESC = String.fromCharCode(27);
48
+ const CSI = ESC + '[';
49
+ const BEL = String.fromCharCode(7);
50
+
51
+ export const ANSI = {
52
+ enterAlt: `${CSI}?1049h`,
53
+ leaveAlt: `${CSI}?1049l`,
54
+ hideCursor: `${CSI}?25l`,
55
+ showCursor: `${CSI}?25h`,
56
+ clearScreen: `${CSI}2J`,
57
+ resetSgr: `${CSI}0m`,
58
+ /** 1-based, like the terminal counts. */
59
+ moveTo: (row, col) => `${CSI}${row};${col}H`,
60
+ clearLine: `${CSI}2K`,
61
+ };
62
+
63
+ /**
64
+ * The narrowest width this UI claims to look right at. Below it the layout
65
+ * stacks instead of tabulating; it never writes past the edge either way.
66
+ */
67
+ export const MIN_WIDTH = 40;
68
+
69
+ /**
70
+ * One column is left unwritten on every row, always.
71
+ *
72
+ * Writing the final cell is only safe on terminals with deferred wrap: they
73
+ * park the cursor in the margin and wrap on the *next* printable character.
74
+ * ConPTY and a few emulators wrap eagerly instead, and an eager wrap on the
75
+ * bottom row scrolls the alternate screen, which corrupts every frame after
76
+ * it. A column is cheap; a whole class of platform-specific corruption is not.
77
+ */
78
+ export const usableWidth = (columns) => Math.max(1, columns - 1);
79
+
80
+ /* -- capability detection ----------------------------------------------- */
81
+
82
+ /**
83
+ * What this terminal can actually do. Nothing here is assumed from the fact
84
+ * that a TTY exists.
85
+ *
86
+ * `force` makes the streams count as terminals when they are not. It exists
87
+ * for the real cases where `isTTY` lies - a wrapper, a pty-less runner that is
88
+ * nonetheless watched by a human, `docker run` without `-t` - and it is what
89
+ * the tests use to drive the UI without a pty.
90
+ */
91
+ export function detect({
92
+ stdout = process.stdout,
93
+ stdin = process.stdin,
94
+ env = process.env,
95
+ force = false,
96
+ } = {}) {
97
+ const outTTY = force || Boolean(stdout.isTTY);
98
+ const inTTY = force || Boolean(stdin.isTTY);
99
+ const term = String(env.TERM || '').toLowerCase();
100
+
101
+ // `dumb` and `unknown` are the two terminfo names that promise no cursor
102
+ // addressing. An *unset* TERM is not one of them: containers and IDE
103
+ // terminals routinely leave it empty while being perfectly ANSI, and
104
+ // `isTTY` has already told us a terminal is there.
105
+ const dumb = term === 'dumb' || term === 'unknown';
106
+
107
+ // NO_COLOR, read the same way render.mjs reads it, so one process never
108
+ // disagrees with itself about whether colour is allowed.
109
+ const noColor = Boolean(env.NO_COLOR);
110
+
111
+ let color = 0;
112
+ if (outTTY && !noColor && !dumb) {
113
+ const ct = String(env.COLORTERM || '').toLowerCase();
114
+ if (ct === 'truecolor' || ct === '24bit') color = 24;
115
+ else if (/256/.test(term)) color = 8;
116
+ else color = 4;
117
+ }
118
+
119
+ return {
120
+ outTTY,
121
+ inTTY,
122
+ dumb,
123
+ /** 0 = none, 4 = the original sixteen, 8 = 256, 24 = truecolour. */
124
+ color,
125
+ unicode: detectUnicode(env),
126
+ /** Full-screen drawing is possible: a terminal on both ends, addressable. */
127
+ fullscreen: outTTY && inTTY && !dumb,
128
+ columns: Math.max(1, stdout.columns || 80),
129
+ rows: Math.max(1, stdout.rows || 24),
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Whether box-drawing characters will render or turn into mojibake.
135
+ *
136
+ * A wrong "yes" is two garbage bytes per rule; a wrong "no" is a slightly
137
+ * plainer frame. So this leans towards no on POSIX unless the locale says
138
+ * UTF-8, and towards yes on the two platforms that have no other option.
139
+ */
140
+ function detectUnicode(env) {
141
+ if (env.LOBSTACK_ASCII) return false;
142
+ const locale = `${env.LC_ALL || ''} ${env.LC_CTYPE || ''} ${env.LANG || ''}`.toLowerCase();
143
+ if (/utf-?8/.test(locale)) return true;
144
+ if (process.platform === 'darwin') return true; // UTF-8 only for a decade
145
+ if (process.platform === 'win32') {
146
+ // Windows Terminal, VS Code and ConEmu are UTF-8 capable. A bare
147
+ // conhost.exe in a legacy code page is not, and that is still the default.
148
+ return Boolean(env.WT_SESSION || env.TERM_PROGRAM === 'vscode' || env.ConEmuANSI === 'ON');
149
+ }
150
+ return false;
151
+ }
152
+
153
+ /* -- colour ------------------------------------------------------------- */
154
+
155
+ /**
156
+ * A styling function for a given colour depth.
157
+ *
158
+ * Returns `(style, text) => string`. At depth 0 it is the identity, which is
159
+ * what makes the view renderable as plain text in a test and on a `TERM=dumb`
160
+ * terminal from the same code path.
161
+ */
162
+ export function styler(depth) {
163
+ if (!depth) return (_style, text) => text;
164
+
165
+ // SGR 2 (faint) is the honest way to say "chrome, not content", but a
166
+ // handful of terminals render it as invisible or ignore it outright. Where
167
+ // 256 colours are available a mid grey is more predictable.
168
+ const wide = depth >= 8;
169
+ const table = {
170
+ dim: wide ? '38;5;245' : '2',
171
+ bold: '1',
172
+ heading: wide ? '1;38;5;252' : '1',
173
+ accent: wide ? '38;5;80' : '36',
174
+ good: wide ? '38;5;78' : '32',
175
+ warn: wide ? '38;5;179' : '33',
176
+ bad: wide ? '38;5;210' : '31',
177
+ you: wide ? '1;38;5;110' : '1;34',
178
+ };
179
+ return (style, text) => {
180
+ const code = table[style];
181
+ return code ? `${CSI}${code}m${text}${ANSI.resetSgr}` : text;
182
+ };
183
+ }
184
+
185
+ /* -- text measurement and sanitising ------------------------------------ */
186
+
187
+ /**
188
+ * Display width of a string in cells.
189
+ *
190
+ * A pragmatic subset of UAX #11, not a full implementation: combining marks
191
+ * are zero, the East Asian Wide and Fullwidth blocks plus emoji presentation
192
+ * are two, everything else is one. It gets CJK, Hangul, Kana and the common
193
+ * emoji right, which is what actually turns up in a model's answer. Merely
194
+ * close is still far better than `String.length`, which is wrong by a factor
195
+ * of two on a line of Japanese and pushes every following frame sideways.
196
+ */
197
+ export function width(str) {
198
+ let w = 0;
199
+ for (const ch of str) {
200
+ const cp = ch.codePointAt(0);
201
+ if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) continue; // control: never drawn
202
+ if (
203
+ (cp >= 0x0300 && cp <= 0x036f) || // combining diacriticals
204
+ (cp >= 0x200b && cp <= 0x200f) || // zero-width space and marks
205
+ cp === 0xfe0f ||
206
+ cp === 0xfe0e || // variation selectors
207
+ (cp >= 0x20d0 && cp <= 0x20f0)
208
+ ) {
209
+ continue;
210
+ }
211
+ if (
212
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
213
+ (cp >= 0x2e80 && cp <= 0xa4cf) || // CJK radicals through Yi
214
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
215
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility
216
+ (cp >= 0xfe30 && cp <= 0xfe6f) || // CJK compatibility forms
217
+ (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
218
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
219
+ (cp >= 0x1f300 && cp <= 0x1f64f) || // emoji and pictographs
220
+ (cp >= 0x1f900 && cp <= 0x1f9ff) ||
221
+ (cp >= 0x20000 && cp <= 0x3fffd) // CJK extension B and later
222
+ ) {
223
+ w += 2;
224
+ continue;
225
+ }
226
+ w += 1;
227
+ }
228
+ return w;
229
+ }
230
+
231
+ /** Truncate to `max` display cells, counting the same way `width` does. */
232
+ export function clip(str, max) {
233
+ if (max <= 0) return '';
234
+ let out = '';
235
+ let w = 0;
236
+ for (const ch of str) {
237
+ const cw = width(ch);
238
+ if (w + cw > max) break;
239
+ out += ch;
240
+ w += cw;
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /**
246
+ * Strip anything that would move the cursor or change colour.
247
+ *
248
+ * This is a security boundary, not tidiness. Model output, a gateway error
249
+ * message and a proxied request body are all attacker-influenced text that
250
+ * this program is about to paste into a terminal. Left alone, a clear-screen
251
+ * sequence inside a completion wipes the frame, and an OSC sequence rewrites
252
+ * the window title. Escapes are removed rather than escaped, because there is
253
+ * no reason to render them at all.
254
+ */
255
+ export function sanitize(str) {
256
+ const oscTerm = `${BEL}${ESC}`;
257
+ return String(str)
258
+ .replace(/\r\n?/g, '\n')
259
+ .replace(/\t/g, ' ')
260
+ .replace(new RegExp(`${ESC}\\][^${oscTerm}]*(?:${BEL}|${ESC}\\\\)`, 'g'), '') // OSC
261
+ .replace(new RegExp(`${ESC}[[\\]()#;?]*[0-9;]*[A-Za-z]?`, 'g'), '') // CSI and friends
262
+ .replace(/[^\n -~ -￿]/g, '');
263
+ }
264
+
265
+ /**
266
+ * Wrap text to `max` cells, breaking on spaces and hard-breaking a word that
267
+ * is wider than the line. Returns at least one (possibly empty) line.
268
+ */
269
+ export function wrapText(str, max) {
270
+ if (max <= 0) return [''];
271
+ const out = [];
272
+ for (const paragraph of sanitize(str).split('\n')) {
273
+ let line = '';
274
+ let lineW = 0;
275
+ for (const word of paragraph.split(' ')) {
276
+ const wordW = width(word);
277
+ if (lineW && lineW + 1 + wordW > max) {
278
+ out.push(line);
279
+ line = '';
280
+ lineW = 0;
281
+ }
282
+ if (wordW > max) {
283
+ // A URL or a base64 blob. Break it rather than overflow the row.
284
+ let rest = word;
285
+ if (lineW) {
286
+ out.push(line);
287
+ line = '';
288
+ lineW = 0;
289
+ }
290
+ while (width(rest) > max) {
291
+ const head = clip(rest, max);
292
+ if (!head) break;
293
+ out.push(head);
294
+ rest = rest.slice(head.length);
295
+ }
296
+ line = rest;
297
+ lineW = width(rest);
298
+ continue;
299
+ }
300
+ line = lineW ? `${line} ${word}` : word;
301
+ lineW += (lineW ? 1 : 0) + wordW;
302
+ }
303
+ out.push(line);
304
+ }
305
+ return out.length ? out : [''];
306
+ }
307
+
308
+ /* -- the frame writer --------------------------------------------------- */
309
+
310
+ /**
311
+ * Paints an array of ready-made lines, writing only what changed.
312
+ *
313
+ * Line diffing is what keeps this readable in a terminal with no synchronised
314
+ * output: a streaming answer touches one or two rows per frame, so one or two
315
+ * rows get repainted, and nothing else on screen so much as flickers.
316
+ */
317
+ export class Screen {
318
+ constructor(out) {
319
+ this.out = out;
320
+ this.prev = [];
321
+ }
322
+
323
+ /** Forget what is on screen. Call after a resize, or on a requested redraw. */
324
+ invalidate() {
325
+ this.prev = [];
326
+ }
327
+
328
+ /**
329
+ * @param {string[]} lines one entry per row, already styled and clipped
330
+ * @param {{row:number,col:number}|null} caret 1-based; null hides the cursor
331
+ */
332
+ paint(lines, caret) {
333
+ let buf = ANSI.hideCursor;
334
+ if (!this.prev.length) buf += ANSI.clearScreen;
335
+
336
+ for (let i = 0; i < lines.length; i++) {
337
+ if (this.prev[i] === lines[i]) continue;
338
+ // Erase the whole row before writing it: the new content may be shorter
339
+ // than the old, and the absolute move that follows the write cancels any
340
+ // deferred wrap the write may have armed.
341
+ buf += ANSI.moveTo(i + 1, 1) + ANSI.clearLine + lines[i];
342
+ }
343
+ for (let i = lines.length; i < this.prev.length; i++) {
344
+ buf += ANSI.moveTo(i + 1, 1) + ANSI.clearLine;
345
+ }
346
+
347
+ if (caret) buf += ANSI.moveTo(caret.row, caret.col) + ANSI.showCursor;
348
+ else buf += ANSI.moveTo(Math.max(1, lines.length), 1);
349
+
350
+ this.out.write(buf);
351
+ this.prev = lines.slice();
352
+ }
353
+ }
354
+
355
+ /* -- lifecycle ---------------------------------------------------------- */
356
+
357
+ /**
358
+ * Owns the terminal's modes, and gives them all back.
359
+ *
360
+ * `restore()` is idempotent and is wired to every exit this process has: a
361
+ * normal return, `process.exit` from anywhere (including `fail()` in
362
+ * render.mjs), SIGINT, SIGTERM, SIGHUP, SIGQUIT, an uncaught throw and an
363
+ * unhandled rejection. The `exit` listener is the one that cannot be skipped,
364
+ * so it is the backstop; on a TTY `process.stdout.write` is synchronous, which
365
+ * is the only reason writing escape sequences from an `exit` handler works.
366
+ *
367
+ * Installing signal listeners suppresses Node's default handling, so each one
368
+ * has to finish the job itself and exit with the conventional 128 + signal.
369
+ */
370
+ export class Terminal {
371
+ constructor({ stdout = process.stdout, stdin = process.stdin, caps } = {}) {
372
+ this.out = stdout;
373
+ this.in = stdin;
374
+ this.caps = caps || detect({ stdout, stdin });
375
+ this.screen = new Screen(stdout);
376
+ this.entered = false;
377
+ this.restored = false;
378
+ this.handlers = [];
379
+ this.onResize = null;
380
+ }
381
+
382
+ get columns() {
383
+ return Math.max(1, this.out.columns || this.caps.columns || 80);
384
+ }
385
+
386
+ get rows() {
387
+ return Math.max(1, this.out.rows || this.caps.rows || 24);
388
+ }
389
+
390
+ /** Raw mode, alternate screen, cursor hidden, and every way out guarded. */
391
+ enter() {
392
+ if (this.entered) return this;
393
+ this.entered = true;
394
+
395
+ // Guards go on *before* any mode is changed, so a throw between here and
396
+ // the end of this method still hands the terminal back.
397
+ this.#guard();
398
+
399
+ this.out.write(ANSI.enterAlt + ANSI.hideCursor);
400
+
401
+ // `--force` with a pipe on stdin has no raw mode to set. Everything else
402
+ // still works; keystrokes simply arrive line-buffered.
403
+ if (typeof this.in.setRawMode === 'function' && this.in.isTTY) this.in.setRawMode(true);
404
+ emitKeypressEvents(this.in);
405
+ this.in.resume();
406
+
407
+ this.resizeListener = () => {
408
+ // Every row's content depends on the width, so nothing on screen is
409
+ // reusable. Drop the diff baseline and repaint from scratch.
410
+ this.screen.invalidate();
411
+ this.onResize?.();
412
+ };
413
+ this.out.on('resize', this.resizeListener);
414
+ return this;
415
+ }
416
+
417
+ onKey(handler) {
418
+ this.keyListener = handler;
419
+ this.in.on('keypress', handler);
420
+ return this;
421
+ }
422
+
423
+ paint(lines, caret) {
424
+ this.screen.paint(lines, caret);
425
+ }
426
+
427
+ /** Idempotent, and safe to call from a signal handler or an `exit` hook. */
428
+ restore() {
429
+ if (this.restored || !this.entered) return;
430
+ this.restored = true;
431
+
432
+ try {
433
+ if (this.keyListener) this.in.removeListener('keypress', this.keyListener);
434
+ if (this.resizeListener) this.out.removeListener('resize', this.resizeListener);
435
+ if (typeof this.in.setRawMode === 'function' && this.in.isTTY) this.in.setRawMode(false);
436
+ this.in.pause();
437
+ // Order matters: reset colour and show the cursor *inside* the alternate
438
+ // screen, then leave it. Leaving first and writing after would print the
439
+ // sequences onto whatever row the user's scrollback had ended on.
440
+ this.out.write(ANSI.resetSgr + ANSI.showCursor + ANSI.leaveAlt);
441
+ } catch {
442
+ // A closed or broken stdout during shutdown is not worth a second error
443
+ // on top of whatever is already going wrong.
444
+ }
445
+ for (const off of this.handlers) {
446
+ try {
447
+ off();
448
+ } catch {
449
+ /* removing a listener cannot usefully fail */
450
+ }
451
+ }
452
+ this.handlers = [];
453
+ }
454
+
455
+ #guard() {
456
+ const on = (event, fn) => {
457
+ process.on(event, fn);
458
+ this.handlers.push(() => process.removeListener(event, fn));
459
+ };
460
+
461
+ // The backstop. Runs for a normal return and for every `process.exit`,
462
+ // including the one inside `fail()`.
463
+ on('exit', () => this.restore());
464
+
465
+ for (const [sig, code] of [
466
+ ['SIGINT', 130],
467
+ ['SIGTERM', 143],
468
+ ['SIGHUP', 129],
469
+ ['SIGQUIT', 131],
470
+ ]) {
471
+ on(sig, () => {
472
+ this.restore();
473
+ process.exit(code);
474
+ });
475
+ }
476
+
477
+ on('uncaughtException', (err) => {
478
+ this.restore();
479
+ // Having a listener means Node will not print this itself, so print it -
480
+ // a UI that vanishes without saying why is worse than a stack trace.
481
+ process.stderr.write(`\n${(err && err.stack) || err}\n`);
482
+ process.exit(1);
483
+ });
484
+ on('unhandledRejection', (err) => {
485
+ this.restore();
486
+ process.stderr.write(`\n${(err && err.stack) || err}\n`);
487
+ process.exit(1);
488
+ });
489
+ }
490
+ }