@xingjisoft/neutrino 0.1.2 → 0.1.4
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/README.md +36 -2
- package/dist/app.js +65 -1
- package/dist/colors.js +2 -0
- package/dist/compact.js +68 -0
- package/dist/config.js +15 -0
- package/dist/llm/client.js +309 -84
- package/dist/markdown.js +1 -1
- package/dist/sanitize.js +61 -0
- package/dist/screens/agentConversation.js +29 -2
- package/dist/screens/chat.js +284 -27
- package/dist/screens/settings.js +83 -44
- package/dist/screens/start.js +25 -0
- package/dist/selection.js +25 -11
- package/dist/terminal/buffer.js +24 -3
- package/dist/terminal/input.js +75 -1
- package/dist/terminal/renderer.js +43 -3
- package/dist/terminal/screen.js +29 -0
- package/dist/ui.js +105 -38
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,6 +7,9 @@ A CLI coding agent that lives in your terminal. Neutrino gives you an interactiv
|
|
|
7
7
|
- **Interactive terminal UI** — logo screen, chat, model select, history, and settings screens.
|
|
8
8
|
- **Full mouse support** — hover to highlight, click to select/open/position the cursor, wheel to scroll, right-click to go back (like OpenCode).
|
|
9
9
|
- **Select & copy** — drag the mouse over any text (agent output, the input box, menus, …) to select it; releasing the button copies the selection to the clipboard (via OSC 52, with a native fallback).
|
|
10
|
+
- **Paste-friendly input** — like OpenCode, a pasted block has its newlines turned into spaces so it stays one line, and only a manually typed `Enter` submits the message.
|
|
11
|
+
- **Faithful log rendering** — text in the chat log keeps its indentation, blank lines and column alignment; when a long tool-output line wraps, continuation lines stay indented instead of snapping back to the left margin.
|
|
12
|
+
- **Scrollable context log** — a vertical scrollbar is drawn on the right edge of the chat (and the agent-conversation viewer) whenever the conversation is taller than the view. Scroll with the wheel, `PgUp`/`PgDn` or `↑`/`↓`, or grab the scrollbar with the mouse and drag it like in a GUI.
|
|
10
13
|
- **LLM providers** — ships with DeepSeek, OpenAI, and Anthropic presets; works with any OpenAI-compatible endpoint.
|
|
11
14
|
- **Tool calling** — the agent can run shell commands, read files, write files, and list directories in your working directory.
|
|
12
15
|
- **Streaming responses** — token-by-token output with pause/resume (`ESC ESC`).
|
|
@@ -48,7 +51,7 @@ Type a message on the start screen and press `Enter` to begin a new chat.
|
|
|
48
51
|
|
|
49
52
|
| Keys | Action |
|
|
50
53
|
| --- | --- |
|
|
51
|
-
| `Enter` | Send message / confirm |
|
|
54
|
+
| `Enter` | Send message / confirm (only a manually typed Enter sends) |
|
|
52
55
|
| `←`/`→`, `↑`/`↓` | Move focus / navigate |
|
|
53
56
|
| `Ctrl+E` | Open model config |
|
|
54
57
|
| `Ctrl+B` | Back to menu (from chat) |
|
|
@@ -69,10 +72,11 @@ Type a message on the start screen and press `Enter` to begin a new chat.
|
|
|
69
72
|
| Click and drag | Select text under the rectangle; releasing copies it to the clipboard |
|
|
70
73
|
| Click the header (left of `ⓘ`) | Open the model selector |
|
|
71
74
|
| Click `ⓘ` (chat header) | Toggle token usage stats |
|
|
75
|
+
| Drag the scrollbar | Grab the thumb on the right edge of the chat/log and drag it to scroll |
|
|
72
76
|
| Scroll wheel | Scroll the chat/log, or move through lists |
|
|
73
77
|
| Right-click | Go back (like `Esc`) |
|
|
74
78
|
|
|
75
|
-
The selection highlight is drawn over whatever is on screen, so it works uniformly on the chat log, the input box and every menu. Selected text is copied with trailing whitespace trimmed per line (shell-style), so it can be pasted straight back into a terminal.
|
|
79
|
+
The selection highlight is drawn over whatever is on screen, so it works uniformly on the chat log, the input box and every menu. Selected text is copied with trailing whitespace trimmed per line (shell-style), so it can be pasted straight back into a terminal. UI chrome (such as the scrollbar) is skipped when copying and highlighting, so a copy never picks up the bar or the padding in front of it.
|
|
76
80
|
|
|
77
81
|
## Configuration
|
|
78
82
|
|
|
@@ -113,17 +117,47 @@ npm test # build, then run every test below
|
|
|
113
117
|
Individual tests:
|
|
114
118
|
|
|
115
119
|
```sh
|
|
120
|
+
node scripts/test-paste.mjs # verify pasted newlines are flattened to spaces
|
|
116
121
|
node scripts/test-mouse.mjs # verify mouse escape-sequence parsing
|
|
117
122
|
node scripts/test-cursor.mjs # verify click-to-position-cursor math
|
|
118
123
|
node scripts/test-selection.mjs # verify selection highlighting & text extraction
|
|
124
|
+
node scripts/test-copy.mjs # verify a copy never includes the scrollbar or trailing padding
|
|
125
|
+
node scripts/test-scrollbar.mjs # verify the conversation scrollbar size/position and drag maths
|
|
126
|
+
node scripts/test-markdown.mjs # verify markdown/table rendering (incl. ragged rows)
|
|
127
|
+
node scripts/test-wrap.mjs # verify indentation & spacing are preserved when wrapping
|
|
119
128
|
node scripts/test-render.mjs # fuzz the diff renderer + render the chat screen,
|
|
120
129
|
# asserting no stale/residual cells are left behind
|
|
130
|
+
node scripts/test-sanitize.mjs # verify ANSI/control codes never reach the screen
|
|
131
|
+
node scripts/test-drift.mjs # verify the input area self-heals after terminal drift
|
|
132
|
+
node scripts/test-fetch.mjs # verify streamed requests release aborts and retry drops
|
|
133
|
+
node scripts/test-compact.mjs # verify context-compression planning
|
|
134
|
+
node scripts/test-tools.mjs # verify tool-output folding
|
|
121
135
|
```
|
|
122
136
|
|
|
123
137
|
### Rendering model
|
|
124
138
|
|
|
125
139
|
The screen is drawn into a `Buffer` of cells which is diffed against the previous frame, so only changed rows are emitted. Rows are erased to the end of the line before being repainted, and the buffer keeps double-width glyphs consistent (writing over one half of a wide character blanks the other half). Together these guarantee that a repainted row can never leave a stale cell behind.
|
|
126
140
|
|
|
141
|
+
Because only *changed* rows are emitted, a row whose contents happen to be identical from one frame to the next is never rewritten — so if the terminal shifts its own contents behind our back (ConPTY reflowing the buffer mid-resize, a size-race repaint, a stray scroll) that row would stay stale. Two defences cover this: the persistent chrome (input box, tabs, key hints) is repainted on every frame via `Screen.alwaysRepaintRows()`, and the app periodically re-emits the whole cached frame (`Renderer.repaint()`) as a safety net. Both are no-ops when nothing drifted.
|
|
142
|
+
|
|
143
|
+
### Text hygiene
|
|
144
|
+
|
|
145
|
+
A cell's glyph is copied straight into the output stream, so a cell holding a *control* character becomes a command the terminal executes — an ESC sequence can move the cursor, clear the screen or leave the alternate buffer, and a TAB shifts the rest of the row. Tool output (ANSI colours from `ls --color`, `git`, `npm`, progress bars, …), file contents and pasted text all carry such bytes, so they are neutralised in three places:
|
|
146
|
+
|
|
147
|
+
1. `src/sanitize.ts` — `sanitizeText()` removes whole escape sequences (not just the ESC byte, which would leave `[31m` behind as literal text), expands tabs so measured widths match what the terminal draws, and drops any remaining control code. It is applied where untrusted text enters the display (chat messages, tool results, pasted input).
|
|
148
|
+
2. `Buffer` — writing a control character stores a space instead, preserving the column width the caller measured, so no cell can ever hold one.
|
|
149
|
+
3. `Renderer` — a last-resort check when emitting a row, so the byte stream cannot carry a control code even if a cell were populated another way.
|
|
150
|
+
|
|
151
|
+
### Network resilience
|
|
152
|
+
|
|
153
|
+
Model responses arrive over a streamed connection that can drop at any point. A single blip should not end the session, so three cases are handled:
|
|
154
|
+
|
|
155
|
+
- **The request never goes out** (`fetch` rejects) — retried a couple of times with a short backoff, bounded by a connect timeout.
|
|
156
|
+
- **The stream dies before the first byte** — the server or a proxy closed the connection mid-flight (`TypeError: terminated`, `other side closed`, …). Nothing has reached the user, so the request is simply re-issued.
|
|
157
|
+
- **The stream dies after output has started** — retrying would duplicate text, so the error is surfaced instead, with the real reason expanded out of the `error.cause` chain (`fetch failed` alone says nothing useful).
|
|
158
|
+
|
|
159
|
+
Any stream the consumer walks away from (pause, or a newer turn taking over) always has its body cancelled, so sockets are not leaked.
|
|
160
|
+
|
|
127
161
|
## License
|
|
128
162
|
|
|
129
163
|
Apache-2.0. See [LICENSE](./LICENSE).
|
package/dist/app.js
CHANGED
|
@@ -6,6 +6,7 @@ import { defaultState } from "./state.js";
|
|
|
6
6
|
import { loadConfig, saveConfig } from "./config.js";
|
|
7
7
|
import { C } from "./colors.js";
|
|
8
8
|
import { applySelection, selectionText } from "./selection.js";
|
|
9
|
+
import { offsetFromThumbTop, scrollbarGeometry } from "./ui.js";
|
|
9
10
|
import { copyToClipboard } from "./clipboard.js";
|
|
10
11
|
import { ChatScreen } from "./screens/chat.js";
|
|
11
12
|
import { HistoryScreen } from "./screens/history.js";
|
|
@@ -15,6 +16,10 @@ import { SettingsScreen } from "./screens/settings.js";
|
|
|
15
16
|
import { StartScreen } from "./screens/start.js";
|
|
16
17
|
const CURSOR_BLINK_MS = 530;
|
|
17
18
|
const TOAST_MS = 1600;
|
|
19
|
+
// How often the whole frame is re-emitted as a safety net, so terminal-side
|
|
20
|
+
// drift (ConPTY reflow, stray scroll) can never leave any row stale for long.
|
|
21
|
+
// Re-emitting the cached frame is a no-op when nothing drifted.
|
|
22
|
+
const REPAINT_MS = 2000;
|
|
18
23
|
export class App {
|
|
19
24
|
state = defaultState();
|
|
20
25
|
width;
|
|
@@ -27,6 +32,7 @@ export class App {
|
|
|
27
32
|
blinkTimer = null;
|
|
28
33
|
cursorTimer = null;
|
|
29
34
|
toastTimer = null;
|
|
35
|
+
repaintTimer = null;
|
|
30
36
|
toast = "";
|
|
31
37
|
// Most recently rendered frame. Kept so a selection can be turned back into
|
|
32
38
|
// text without having to re-run the screen's render.
|
|
@@ -37,6 +43,9 @@ export class App {
|
|
|
37
43
|
// never moves, so it is forwarded to the screen and leaves no selection.
|
|
38
44
|
pressed = false;
|
|
39
45
|
pressStart = null;
|
|
46
|
+
// Active scrollbar drag: how many rows below the thumb's top the press
|
|
47
|
+
// landed, so the thumb stays under the pointer as it moves.
|
|
48
|
+
barDrag = null;
|
|
40
49
|
resizeHandler = () => this.onResize();
|
|
41
50
|
constructor() {
|
|
42
51
|
this.width = process.stdout.columns || 80;
|
|
@@ -77,6 +86,7 @@ export class App {
|
|
|
77
86
|
if (this.syncSize())
|
|
78
87
|
this.render();
|
|
79
88
|
}, 200);
|
|
89
|
+
this.repaintTimer = setInterval(() => this.renderer.repaint(), REPAINT_MS);
|
|
80
90
|
this.pushScreen(new StartScreen());
|
|
81
91
|
}
|
|
82
92
|
onResize() {
|
|
@@ -129,7 +139,7 @@ export class App {
|
|
|
129
139
|
if (this.state.selection)
|
|
130
140
|
applySelection(buf, this.state.selection);
|
|
131
141
|
this.drawToast(buf);
|
|
132
|
-
this.renderer.render(buf);
|
|
142
|
+
this.renderer.render(buf, this.top?.alwaysRepaintRows() ?? []);
|
|
133
143
|
}
|
|
134
144
|
drawToast(buf) {
|
|
135
145
|
if (!this.toast)
|
|
@@ -157,6 +167,14 @@ export class App {
|
|
|
157
167
|
this.state.cursorOn = true;
|
|
158
168
|
}
|
|
159
169
|
handleEvent(ev) {
|
|
170
|
+
if (ev.type === "paste") {
|
|
171
|
+
// Pasted text arrives with its newlines already flattened to spaces, so
|
|
172
|
+
// it is inserted into the input instead of being submitted.
|
|
173
|
+
this.resetMouse();
|
|
174
|
+
this.wakeCursor();
|
|
175
|
+
this.top?.onPaste(ev.text);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
160
178
|
if (ev.type === "key") {
|
|
161
179
|
const k = ev.key;
|
|
162
180
|
// Any keystroke ends a pending mouse gesture (in case a release was lost).
|
|
@@ -194,6 +212,28 @@ export class App {
|
|
|
194
212
|
handleMouse(ev) {
|
|
195
213
|
if (ev.type === "click") {
|
|
196
214
|
this.wakeCursor();
|
|
215
|
+
// A left-press on the scrollbar starts a thumb drag rather than a text
|
|
216
|
+
// selection or a click on the screen behind it.
|
|
217
|
+
if (ev.button === 0) {
|
|
218
|
+
const bar = this.top?.scrollbar();
|
|
219
|
+
if (bar && ev.x >= bar.x && ev.y >= bar.top && ev.y <= bar.bottom) {
|
|
220
|
+
const g = scrollbarGeometry(bar.top, bar.bottom, bar.total, bar.viewport, bar.offset);
|
|
221
|
+
let grab = ev.y - bar.top - g.thumbTop;
|
|
222
|
+
if (grab < 0 || grab >= g.thumbH) {
|
|
223
|
+
// Pressed the groove: centre the thumb under the pointer.
|
|
224
|
+
grab = Math.floor(g.thumbH / 2);
|
|
225
|
+
const thumbTop = Math.max(0, Math.min(ev.y - bar.top - grab, g.maxThumbTop));
|
|
226
|
+
bar.scrollTo(offsetFromThumbTop(g, thumbTop));
|
|
227
|
+
}
|
|
228
|
+
this.barDrag = { grab };
|
|
229
|
+
this.pressed = false;
|
|
230
|
+
this.pressStart = null;
|
|
231
|
+
if (this.state.selection)
|
|
232
|
+
this.state.selection = null;
|
|
233
|
+
this.render();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
197
237
|
this.pressed = true;
|
|
198
238
|
this.pressStart = { x: ev.x, y: ev.y };
|
|
199
239
|
if (this.state.selection) {
|
|
@@ -206,6 +246,24 @@ export class App {
|
|
|
206
246
|
return;
|
|
207
247
|
}
|
|
208
248
|
if (ev.type === "move") {
|
|
249
|
+
// A release can be lost (the pointer left the window, the terminal did
|
|
250
|
+
// not report button-up, …). Motion with no button held means the gesture
|
|
251
|
+
// is over, so drop any stale press/drag instead of letting a bare hover
|
|
252
|
+
// keep scrolling the log or grow a phantom selection.
|
|
253
|
+
if (!ev.drag) {
|
|
254
|
+
this.pressed = false;
|
|
255
|
+
this.pressStart = null;
|
|
256
|
+
this.barDrag = null;
|
|
257
|
+
}
|
|
258
|
+
if (this.barDrag) {
|
|
259
|
+
const bar = this.top?.scrollbar();
|
|
260
|
+
if (bar) {
|
|
261
|
+
const g = scrollbarGeometry(bar.top, bar.bottom, bar.total, bar.viewport, bar.offset);
|
|
262
|
+
const thumbTop = Math.max(0, Math.min(ev.y - bar.top - this.barDrag.grab, g.maxThumbTop));
|
|
263
|
+
bar.scrollTo(offsetFromThumbTop(g, thumbTop));
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
209
267
|
if (this.pressed && this.pressStart) {
|
|
210
268
|
const s = this.pressStart;
|
|
211
269
|
if (ev.x !== s.x || ev.y !== s.y) {
|
|
@@ -223,6 +281,7 @@ export class App {
|
|
|
223
281
|
const sel = this.state.selection;
|
|
224
282
|
this.pressed = false;
|
|
225
283
|
this.pressStart = null;
|
|
284
|
+
this.barDrag = null;
|
|
226
285
|
if (sel) {
|
|
227
286
|
if (this.lastBuffer) {
|
|
228
287
|
const text = selectionText(this.lastBuffer, sel);
|
|
@@ -245,6 +304,7 @@ export class App {
|
|
|
245
304
|
resetMouse() {
|
|
246
305
|
this.pressed = false;
|
|
247
306
|
this.pressStart = null;
|
|
307
|
+
this.barDrag = null;
|
|
248
308
|
}
|
|
249
309
|
copySelection() {
|
|
250
310
|
const sel = this.state.selection;
|
|
@@ -328,6 +388,10 @@ export class App {
|
|
|
328
388
|
this.running = true;
|
|
329
389
|
if (this.sizeTimer)
|
|
330
390
|
clearInterval(this.sizeTimer);
|
|
391
|
+
if (this.repaintTimer) {
|
|
392
|
+
clearInterval(this.repaintTimer);
|
|
393
|
+
this.repaintTimer = null;
|
|
394
|
+
}
|
|
331
395
|
if (this.blinkTimer) {
|
|
332
396
|
clearInterval(this.blinkTimer);
|
|
333
397
|
this.blinkTimer = null;
|
package/dist/colors.js
CHANGED
|
@@ -12,5 +12,7 @@ export const C = {
|
|
|
12
12
|
red: [247, 118, 142],
|
|
13
13
|
codeBg: [27, 35, 49], // dark blue-grey used behind code blocks
|
|
14
14
|
selection: [56, 92, 138], // muted blue used to highlight selected text
|
|
15
|
+
scrollTrack: [45, 58, 62], // dim groove behind the scrollbar thumb
|
|
16
|
+
scrollThumb: [130, 142, 190], // draggable scrollbar thumb
|
|
15
17
|
};
|
|
16
18
|
export const EMPTY_STYLE = {};
|
package/dist/compact.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for compressing a long conversation so it fits back inside the
|
|
3
|
+
* model's context window.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is pure: deciding *what* to summarise, and flattening that
|
|
6
|
+
* part into a plain transcript. The actual "ask the model to summarise" step
|
|
7
|
+
* lives in `LLMClient.summarize`, so this module stays trivially unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
/** How many trailing messages are always kept verbatim when compressing. */
|
|
10
|
+
export const COMPACT_KEEP = 4;
|
|
11
|
+
/** Per-message character budget fed to the summariser, to bound its own input. */
|
|
12
|
+
export const COMPACT_MAX_CHARS_PER_MESSAGE = 4000;
|
|
13
|
+
/**
|
|
14
|
+
* Split `messages` into an older prefix to summarise and a recent suffix to
|
|
15
|
+
* keep. The kept suffix never begins with a bare tool result — that would be an
|
|
16
|
+
* orphan without the assistant tool-call that produced it — so such messages
|
|
17
|
+
* are folded into the prefix instead.
|
|
18
|
+
*
|
|
19
|
+
* Returns null when there is nothing worth compressing (the conversation is
|
|
20
|
+
* already short enough).
|
|
21
|
+
*/
|
|
22
|
+
export function planCompaction(messages, keep = COMPACT_KEEP) {
|
|
23
|
+
if (keep < 1)
|
|
24
|
+
keep = 1;
|
|
25
|
+
const head = [];
|
|
26
|
+
const tail = [];
|
|
27
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
28
|
+
if (tail.length < keep)
|
|
29
|
+
tail.unshift(messages[i]);
|
|
30
|
+
else
|
|
31
|
+
head.push(messages[i]);
|
|
32
|
+
}
|
|
33
|
+
head.reverse();
|
|
34
|
+
// Never open the kept suffix with an orphan tool result.
|
|
35
|
+
while (tail.length > 1 && tail[0].role === "tool") {
|
|
36
|
+
head.push(tail.shift());
|
|
37
|
+
}
|
|
38
|
+
if (head.length === 0 || tail.length === 0)
|
|
39
|
+
return null;
|
|
40
|
+
return { head, tail };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Flatten messages into a plain transcript for the summariser, clipping each
|
|
44
|
+
* message so a single huge tool result cannot dominate the summary request.
|
|
45
|
+
*/
|
|
46
|
+
export function transcriptFor(messages, maxCharsPerMessage = COMPACT_MAX_CHARS_PER_MESSAGE) {
|
|
47
|
+
const clip = (s) => {
|
|
48
|
+
const t = s.replace(/\r\n?/g, "\n").trim();
|
|
49
|
+
return t.length > maxCharsPerMessage ? t.slice(0, maxCharsPerMessage) + "\n…[truncated]" : t;
|
|
50
|
+
};
|
|
51
|
+
const parts = [];
|
|
52
|
+
for (const m of messages) {
|
|
53
|
+
const body = clip(m.content ?? "");
|
|
54
|
+
if (m.role === "tool") {
|
|
55
|
+
parts.push(`TOOL ${m.name ?? ""} RESULT:\n${body}`);
|
|
56
|
+
}
|
|
57
|
+
else if (m.role === "assistant") {
|
|
58
|
+
parts.push(`ASSISTANT:\n${body}`);
|
|
59
|
+
}
|
|
60
|
+
else if (m.role === "user") {
|
|
61
|
+
parts.push(`USER:\n${body}`);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
parts.push(`${m.role.toUpperCase()}:\n${body}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return parts.join("\n\n");
|
|
68
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -36,6 +36,9 @@ export const DEFAULT_CONFIG = {
|
|
|
36
36
|
language: "en",
|
|
37
37
|
theme: "default",
|
|
38
38
|
default_intensity: "High",
|
|
39
|
+
// Estimated prompt tokens above which the conversation is auto-compressed.
|
|
40
|
+
// 0 disables auto-compression.
|
|
41
|
+
context_compress_threshold: 32000,
|
|
39
42
|
};
|
|
40
43
|
export const INTENSITY_LEVELS = ["Low", "Medium", "High", "Max", "Ultra"];
|
|
41
44
|
export const INTENSITY_PARAMS = {
|
|
@@ -45,6 +48,18 @@ export const INTENSITY_PARAMS = {
|
|
|
45
48
|
Max: { temperature: 1.0, top_p: 1.0 },
|
|
46
49
|
Ultra: { temperature: 1.5, top_p: 1.0 },
|
|
47
50
|
};
|
|
51
|
+
/**
|
|
52
|
+
* Selectable auto-compression thresholds, in estimated prompt tokens.
|
|
53
|
+
* `0` turns the feature off. Rendered as-is in the settings screen.
|
|
54
|
+
*/
|
|
55
|
+
export const COMPRESS_THRESHOLD_OPTIONS = [
|
|
56
|
+
{ value: 0, label: "Off" },
|
|
57
|
+
{ value: 8000, label: "8K tokens" },
|
|
58
|
+
{ value: 16000, label: "16K tokens" },
|
|
59
|
+
{ value: 32000, label: "32K tokens" },
|
|
60
|
+
{ value: 64000, label: "64K tokens" },
|
|
61
|
+
{ value: 128000, label: "128K tokens" },
|
|
62
|
+
];
|
|
48
63
|
export function ensureDirs() {
|
|
49
64
|
fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
|
|
50
65
|
fs.mkdirSync(SESSION_DIR, { recursive: true });
|