@xingjisoft/neutrino 0.1.4 → 0.1.5
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 +398 -163
- package/assets/prompt.txt +4 -0
- package/dist/agents.js +114 -15
- package/dist/app.js +85 -3
- package/dist/bin.js +177 -5
- package/dist/colors.js +2 -0
- package/dist/compact.js +19 -0
- package/dist/config.js +121 -28
- package/dist/cwd.js +54 -0
- package/dist/header.js +8 -0
- package/dist/llm/approval.js +543 -0
- package/dist/llm/client.js +14 -0
- package/dist/llm/risk.js +377 -0
- package/dist/llm/tools.js +37 -21
- package/dist/persistence.js +51 -0
- package/dist/screens/agentHistory.js +7 -4
- package/dist/screens/chat.js +533 -45
- package/dist/screens/history.js +9 -7
- package/dist/screens/settings.js +131 -14
- package/dist/screens/start.js +50 -16
- package/dist/state.js +1 -0
- package/dist/terminal/input.js +14 -3
- package/dist/terminal/renderer.js +31 -6
- package/dist/terminal/restore.js +147 -0
- package/dist/ui.js +16 -0
- package/dist/web/auth.js +82 -0
- package/dist/web/hub.js +180 -0
- package/dist/web/server.js +688 -0
- package/dist/web/session.js +572 -0
- package/dist/web/settings.js +101 -0
- package/dist/width.js +20 -0
- package/package.json +6 -4
- package/web/README.md +208 -0
- package/web/app.js +999 -0
- package/web/index.html +87 -0
- package/web/settings.js +193 -0
- package/web/style.css +941 -0
package/dist/agents.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
+
import { claudeProjectKey, sameCwd } from "./cwd.js";
|
|
5
6
|
const HOME = os.homedir();
|
|
6
7
|
function parseJsonlLines(filePath) {
|
|
7
8
|
let raw;
|
|
@@ -27,6 +28,37 @@ function parseJsonlLines(filePath) {
|
|
|
27
28
|
}
|
|
28
29
|
return out;
|
|
29
30
|
}
|
|
31
|
+
const HEAD_BYTES = 64 * 1024;
|
|
32
|
+
/**
|
|
33
|
+
* Read at most `maxBytes` from the start of a file.
|
|
34
|
+
*
|
|
35
|
+
* Session transcripts can be several megabytes, but the working directory is
|
|
36
|
+
* always recorded up front, so scanning just the head keeps the history list
|
|
37
|
+
* fast when a project holds many sessions.
|
|
38
|
+
*/
|
|
39
|
+
function readHead(filePath, maxBytes = HEAD_BYTES) {
|
|
40
|
+
let fd = null;
|
|
41
|
+
try {
|
|
42
|
+
fd = fs.openSync(filePath, "r");
|
|
43
|
+
const len = Math.min(fs.fstatSync(fd).size, maxBytes);
|
|
44
|
+
if (len <= 0)
|
|
45
|
+
return "";
|
|
46
|
+
const buf = Buffer.alloc(len);
|
|
47
|
+
fs.readSync(fd, buf, 0, len, 0);
|
|
48
|
+
return buf.toString("utf-8");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
if (fd !== null) {
|
|
55
|
+
try {
|
|
56
|
+
fs.closeSync(fd);
|
|
57
|
+
}
|
|
58
|
+
catch { }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
30
62
|
function contentText(content) {
|
|
31
63
|
if (typeof content === "string")
|
|
32
64
|
return content;
|
|
@@ -78,9 +110,27 @@ function claudeTitle(lines) {
|
|
|
78
110
|
}
|
|
79
111
|
return "";
|
|
80
112
|
}
|
|
81
|
-
|
|
113
|
+
const CWD_FIELD_RE = /"cwd"\s*:\s*("(?:[^"\\]|\\.)*")/;
|
|
114
|
+
/** Working directory recorded in a Claude Code transcript ("" when unknown). */
|
|
115
|
+
function claudeSessionCwd(filePath) {
|
|
116
|
+
const m = CWD_FIELD_RE.exec(readHead(filePath));
|
|
117
|
+
if (!m)
|
|
118
|
+
return "";
|
|
119
|
+
try {
|
|
120
|
+
const v = JSON.parse(m[1]);
|
|
121
|
+
return typeof v === "string" ? v : "";
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return "";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function listClaude(cwd) {
|
|
82
128
|
const metas = [];
|
|
129
|
+
const wantKey = claudeProjectKey(cwd);
|
|
83
130
|
for (const dir of claudeProjectDirs()) {
|
|
131
|
+
// Claude names each project dir after its cwd, so the dir name still tells
|
|
132
|
+
// us the directory when a transcript predates the `cwd` field.
|
|
133
|
+
const dirKey = path.basename(dir).toLowerCase();
|
|
84
134
|
let files;
|
|
85
135
|
try {
|
|
86
136
|
files = fs.readdirSync(dir);
|
|
@@ -99,6 +149,10 @@ function listClaude() {
|
|
|
99
149
|
catch {
|
|
100
150
|
continue;
|
|
101
151
|
}
|
|
152
|
+
// History is scoped to the current project: skip other directories.
|
|
153
|
+
const sessionCwd = claudeSessionCwd(fp);
|
|
154
|
+
if (sessionCwd ? !sameCwd(sessionCwd, cwd) : dirKey !== wantKey)
|
|
155
|
+
continue;
|
|
102
156
|
const id = f.slice(0, -".jsonl".length);
|
|
103
157
|
const title = cleanTitle(claudeTitle(parseJsonlLines(fp)) || id.slice(0, 8));
|
|
104
158
|
metas.push({
|
|
@@ -216,11 +270,41 @@ function codexMessageText(payload) {
|
|
|
216
270
|
.join("")
|
|
217
271
|
.trim();
|
|
218
272
|
}
|
|
219
|
-
|
|
273
|
+
/** Working directory recorded in a Codex rollout file ("" when unknown). */
|
|
274
|
+
function codexSessionCwd(filePath) {
|
|
275
|
+
let fallback = "";
|
|
276
|
+
for (const line of readHead(filePath).split("\n")) {
|
|
277
|
+
const t = line.trim();
|
|
278
|
+
if (!t)
|
|
279
|
+
continue;
|
|
280
|
+
let o;
|
|
281
|
+
try {
|
|
282
|
+
o = JSON.parse(t);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const payload = o.payload;
|
|
288
|
+
const c = typeof payload?.cwd === "string" ? payload.cwd : typeof o.cwd === "string" ? o.cwd : "";
|
|
289
|
+
if (!c)
|
|
290
|
+
continue;
|
|
291
|
+
// session_meta is authoritative; other records (turn_context) only serve
|
|
292
|
+
// as a fallback for older rollouts.
|
|
293
|
+
if (o.type === "session_meta")
|
|
294
|
+
return c;
|
|
295
|
+
if (!fallback)
|
|
296
|
+
fallback = c;
|
|
297
|
+
}
|
|
298
|
+
return fallback;
|
|
299
|
+
}
|
|
300
|
+
function listCodex(cwd) {
|
|
220
301
|
const index = codexIndex();
|
|
221
302
|
const files = codexRolloutFiles();
|
|
222
303
|
const metas = [];
|
|
223
304
|
for (const [id, fp] of files) {
|
|
305
|
+
// History is scoped to the current project: skip other directories.
|
|
306
|
+
if (!sameCwd(codexSessionCwd(fp), cwd))
|
|
307
|
+
continue;
|
|
224
308
|
const info = index.get(id);
|
|
225
309
|
let title = info?.title ?? "";
|
|
226
310
|
let updated = info?.updated_at ?? "";
|
|
@@ -320,15 +404,28 @@ function opencodeModelName(modelJson) {
|
|
|
320
404
|
return "";
|
|
321
405
|
}
|
|
322
406
|
}
|
|
323
|
-
function listOpencode() {
|
|
407
|
+
function listOpencode(cwd) {
|
|
324
408
|
const db = opencodeDbHandle();
|
|
325
409
|
if (!db)
|
|
326
410
|
return [];
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
411
|
+
// Current schemas store the project directory on the session row; older ones
|
|
412
|
+
// keep it on the related project row. Try both, newest schema first.
|
|
413
|
+
const queries = [
|
|
414
|
+
"SELECT id, title, agent, model, time_created, time_updated, directory AS dir FROM session ORDER BY time_updated DESC",
|
|
415
|
+
"SELECT s.id AS id, s.title AS title, s.agent AS agent, s.model AS model, s.time_created AS time_created, s.time_updated AS time_updated, p.worktree AS dir FROM session s LEFT JOIN project p ON p.id = s.project_id ORDER BY s.time_updated DESC",
|
|
416
|
+
];
|
|
417
|
+
for (const sql of queries) {
|
|
418
|
+
let rows;
|
|
419
|
+
try {
|
|
420
|
+
rows = db.prepare(sql).all();
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
continue; // this schema has no project path column, try the next query
|
|
424
|
+
}
|
|
425
|
+
// History is scoped to the current project: skip other directories.
|
|
426
|
+
return rows
|
|
427
|
+
.filter((r) => sameCwd(typeof r.dir === "string" ? r.dir : "", cwd))
|
|
428
|
+
.map((r) => ({
|
|
332
429
|
id: String(r.id ?? ""),
|
|
333
430
|
title: cleanTitle(typeof r.title === "string" ? r.title : "").slice(0, 80),
|
|
334
431
|
created_at: iso(r.time_created),
|
|
@@ -336,9 +433,7 @@ function listOpencode() {
|
|
|
336
433
|
model: opencodeModelName(r.model) || (typeof r.agent === "string" ? r.agent : ""),
|
|
337
434
|
}));
|
|
338
435
|
}
|
|
339
|
-
|
|
340
|
-
return [];
|
|
341
|
-
}
|
|
436
|
+
return [];
|
|
342
437
|
}
|
|
343
438
|
function loadOpencode(id) {
|
|
344
439
|
const db = opencodeDbHandle();
|
|
@@ -380,12 +475,16 @@ function loadOpencode(id) {
|
|
|
380
475
|
}
|
|
381
476
|
}
|
|
382
477
|
// ---------------- Public API ----------------
|
|
383
|
-
|
|
478
|
+
/**
|
|
479
|
+
* Sessions of `agent` that were started in `cwd` (the directory neutrino runs
|
|
480
|
+
* in), newest first. Sessions from other directories are not shown.
|
|
481
|
+
*/
|
|
482
|
+
export function listAgentSessions(agent, cwd = process.cwd()) {
|
|
384
483
|
if (agent === "claude")
|
|
385
|
-
return listClaude();
|
|
484
|
+
return listClaude(cwd);
|
|
386
485
|
if (agent === "codex")
|
|
387
|
-
return listCodex();
|
|
388
|
-
return listOpencode();
|
|
486
|
+
return listCodex(cwd);
|
|
487
|
+
return listOpencode(cwd);
|
|
389
488
|
}
|
|
390
489
|
export function loadAgentMessages(agent, id) {
|
|
391
490
|
if (agent === "claude")
|
package/dist/app.js
CHANGED
|
@@ -2,6 +2,7 @@ import process from "node:process";
|
|
|
2
2
|
import { Buffer } from "./terminal/buffer.js";
|
|
3
3
|
import { InputReader } from "./terminal/input.js";
|
|
4
4
|
import { Renderer } from "./terminal/renderer.js";
|
|
5
|
+
import { installCrashGuard, writeCrashLog } from "./terminal/restore.js";
|
|
5
6
|
import { defaultState } from "./state.js";
|
|
6
7
|
import { loadConfig, saveConfig } from "./config.js";
|
|
7
8
|
import { C } from "./colors.js";
|
|
@@ -14,6 +15,7 @@ import { AgentSelectScreen } from "./screens/agentSelect.js";
|
|
|
14
15
|
import { ModelSelectScreen } from "./screens/modelSelect.js";
|
|
15
16
|
import { SettingsScreen } from "./screens/settings.js";
|
|
16
17
|
import { StartScreen } from "./screens/start.js";
|
|
18
|
+
import { startWebServer } from "./web/server.js";
|
|
17
19
|
const CURSOR_BLINK_MS = 530;
|
|
18
20
|
const TOAST_MS = 1600;
|
|
19
21
|
// How often the whole frame is re-emitted as a safety net, so terminal-side
|
|
@@ -34,6 +36,9 @@ export class App {
|
|
|
34
36
|
toastTimer = null;
|
|
35
37
|
repaintTimer = null;
|
|
36
38
|
toast = "";
|
|
39
|
+
// Consecutive failed renders. A screen that cannot draw itself is fatal;
|
|
40
|
+
// a single bad frame is not.
|
|
41
|
+
renderFailures = 0;
|
|
37
42
|
// Most recently rendered frame. Kept so a selection can be turned back into
|
|
38
43
|
// text without having to re-run the screen's render.
|
|
39
44
|
lastBuffer = null;
|
|
@@ -47,6 +52,7 @@ export class App {
|
|
|
47
52
|
// landed, so the thumb stays under the pointer as it moves.
|
|
48
53
|
barDrag = null;
|
|
49
54
|
resizeHandler = () => this.onResize();
|
|
55
|
+
webHandle = null;
|
|
50
56
|
constructor() {
|
|
51
57
|
this.width = process.stdout.columns || 80;
|
|
52
58
|
this.height = process.stdout.rows || 24;
|
|
@@ -76,7 +82,21 @@ export class App {
|
|
|
76
82
|
this.state.selectedModelApiKey = String(config.selected_model_api_key ?? this.state.selectedModelApiKey);
|
|
77
83
|
this.state.selectedModelStreaming = Boolean(config.selected_model_streaming ?? true);
|
|
78
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Mount the browser UI on the given port. Safe to call once; subsequent calls
|
|
87
|
+
* return the already-running server.
|
|
88
|
+
*/
|
|
89
|
+
async enableWeb(port, host, auth = true) {
|
|
90
|
+
if (this.webHandle)
|
|
91
|
+
return this.webHandle;
|
|
92
|
+
this.webHandle = await startWebServer({ port, host, auth });
|
|
93
|
+
return this.webHandle;
|
|
94
|
+
}
|
|
79
95
|
async start() {
|
|
96
|
+
// Installed before anything touches the terminal: from here on, a crash
|
|
97
|
+
// restores mouse tracking, raw mode and the alternate screen instead of
|
|
98
|
+
// handing the shell a terminal that types mouse reports.
|
|
99
|
+
installCrashGuard();
|
|
80
100
|
this.renderer.init();
|
|
81
101
|
this.input.start((ev) => this.handleEvent(ev));
|
|
82
102
|
process.stdout.on("resize", this.resizeHandler);
|
|
@@ -112,6 +132,29 @@ export class App {
|
|
|
112
132
|
this.render();
|
|
113
133
|
}
|
|
114
134
|
render() {
|
|
135
|
+
try {
|
|
136
|
+
this.renderFrame();
|
|
137
|
+
this.renderFailures = 0;
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
// Rendering runs from timers and stream callbacks too, so an escaping
|
|
141
|
+
// throw here is equally fatal.
|
|
142
|
+
this.renderFailures++;
|
|
143
|
+
if (this.renderFailures === 1)
|
|
144
|
+
this.noteInternalError("render", err);
|
|
145
|
+
// A screen that cannot be drawn at all means there is no UI left; stop
|
|
146
|
+
// instead of spinning on a broken frame every 60 ms.
|
|
147
|
+
if (this.renderFailures > 30)
|
|
148
|
+
this.quit(1);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
noteInternalError(kind, err) {
|
|
152
|
+
const file = writeCrashLog(kind, err);
|
|
153
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
154
|
+
const where = file ? ` - details in ${file}` : "";
|
|
155
|
+
this.showToast(`internal error (${kind}): ${detail}${where}`);
|
|
156
|
+
}
|
|
157
|
+
renderFrame() {
|
|
115
158
|
// Start/stop the "thinking" blink animation. While the agent is thinking,
|
|
116
159
|
// a timer toggles the blink phase and re-renders so the top status bar
|
|
117
160
|
// flashes to draw the user's attention.
|
|
@@ -167,6 +210,16 @@ export class App {
|
|
|
167
210
|
this.state.cursorOn = true;
|
|
168
211
|
}
|
|
169
212
|
handleEvent(ev) {
|
|
213
|
+
try {
|
|
214
|
+
this.dispatchEvent(ev);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
// This runs inside a stdin 'data' callback, where a throw is an uncaught
|
|
218
|
+
// exception that would kill the app mid-keystroke.
|
|
219
|
+
this.noteInternalError("input handler", err);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
dispatchEvent(ev) {
|
|
170
223
|
if (ev.type === "paste") {
|
|
171
224
|
// Pasted text arrives with its newlines already flattened to spaces, so
|
|
172
225
|
// it is inserted into the input instead of being submitted.
|
|
@@ -316,6 +369,24 @@ export class App {
|
|
|
316
369
|
copyToClipboard(text);
|
|
317
370
|
this.showToast(`Copied ${text.length} char${text.length === 1 ? "" : "s"}`);
|
|
318
371
|
}
|
|
372
|
+
// Public so screens can surface transient status (e.g. "reviewing 3/3").
|
|
373
|
+
/**
|
|
374
|
+
* Audible cue for something the user is waiting on.
|
|
375
|
+
*
|
|
376
|
+
* Silent when `sound_alerts` is off, so the bell stays a preference rather
|
|
377
|
+
* than unavoidable noise. A pending approval rings twice: it is the one event
|
|
378
|
+
* that actually blocks the agent, and a single blip is easy to miss.
|
|
379
|
+
*/
|
|
380
|
+
bell(kind = "turn") {
|
|
381
|
+
try {
|
|
382
|
+
if (loadConfig().sound_alerts === false)
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
// A config we cannot read must not swallow the cue.
|
|
387
|
+
}
|
|
388
|
+
this.renderer.bell(kind === "approval" ? 2 : 1);
|
|
389
|
+
}
|
|
319
390
|
showToast(text) {
|
|
320
391
|
this.toast = text;
|
|
321
392
|
if (this.toastTimer)
|
|
@@ -344,16 +415,20 @@ export class App {
|
|
|
344
415
|
saveConfig(config);
|
|
345
416
|
}
|
|
346
417
|
startNewChat(text) {
|
|
418
|
+
this.restoreSelection();
|
|
347
419
|
this.pushScreen(new ChatScreen(text));
|
|
348
420
|
}
|
|
349
421
|
loadSession(data) {
|
|
350
422
|
const screen = new ChatScreen("");
|
|
351
423
|
screen.sessionId = data.id;
|
|
352
424
|
screen.loadedMessages = data.messages ?? [];
|
|
425
|
+
screen.loadedCwd = data.cwd ?? null;
|
|
353
426
|
screen.loadedProvider = data.provider || null;
|
|
354
427
|
screen.loadedModel = data.model || null;
|
|
355
428
|
screen.loadedIntensity = data.intensity || null;
|
|
356
429
|
screen.loadedTokens = data.tokens ?? null;
|
|
430
|
+
screen.loadedContext = data.context ?? null;
|
|
431
|
+
screen.loadedApprovalMode = data.approval_mode ?? null;
|
|
357
432
|
if (data.provider)
|
|
358
433
|
this.state.selectedProvider = data.provider;
|
|
359
434
|
if (data.model)
|
|
@@ -382,7 +457,7 @@ export class App {
|
|
|
382
457
|
return;
|
|
383
458
|
this.pushScreen(new SettingsScreen());
|
|
384
459
|
}
|
|
385
|
-
quit() {
|
|
460
|
+
quit(exitCode = 0) {
|
|
386
461
|
if (this.running)
|
|
387
462
|
return;
|
|
388
463
|
this.running = true;
|
|
@@ -404,8 +479,15 @@ export class App {
|
|
|
404
479
|
clearTimeout(this.toastTimer);
|
|
405
480
|
this.toastTimer = null;
|
|
406
481
|
}
|
|
407
|
-
|
|
482
|
+
// Disable mouse reporting before input returns to cooked/echo mode.
|
|
408
483
|
this.renderer.dispose();
|
|
409
|
-
|
|
484
|
+
try {
|
|
485
|
+
this.input.stop();
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
// Terminal restoration has already run, even if stdin is unusable.
|
|
489
|
+
}
|
|
490
|
+
void this.webHandle?.close();
|
|
491
|
+
process.exit(exitCode);
|
|
410
492
|
}
|
|
411
493
|
}
|
package/dist/bin.js
CHANGED
|
@@ -1,9 +1,181 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { App } from "./app.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { installCrashGuard } from "./terminal/restore.js";
|
|
5
|
+
import { startWebServer } from "./web/server.js";
|
|
6
|
+
const DEFAULT_WEB_PORT = 8765;
|
|
7
|
+
/** Binding here makes the UI reachable from every interface. */
|
|
8
|
+
const WILDCARD_HOST = "0.0.0.0";
|
|
9
|
+
function parsePort(raw) {
|
|
10
|
+
if (!raw)
|
|
11
|
+
throw new Error("missing port number");
|
|
12
|
+
if (!/^\d+$/.test(raw))
|
|
13
|
+
throw new Error(`invalid port "${raw}"`);
|
|
14
|
+
const port = Number(raw);
|
|
15
|
+
if (port < 0 || port > 65535)
|
|
16
|
+
throw new Error(`port out of range: ${raw}`);
|
|
17
|
+
return port;
|
|
18
|
+
}
|
|
19
|
+
function envPort() {
|
|
20
|
+
const raw = process.env.NEUTRINO_WEB_PORT;
|
|
21
|
+
if (!raw)
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
return parsePort(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function printHelp() {
|
|
31
|
+
process.stdout.write(`Neutrino
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
neutrino Start the terminal UI.
|
|
35
|
+
neutrino --web-port <port> Start the terminal UI and mount the web UI.
|
|
36
|
+
neutrino web [--port <port>] Start only the web UI (serves saved history).
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
-p, --port <port> Web server port (web mode). Default: ${DEFAULT_WEB_PORT}
|
|
40
|
+
--host <host> Interface to bind. Default: 127.0.0.1
|
|
41
|
+
--lan Short for --host ${WILDCARD_HOST}: reachable from other
|
|
42
|
+
devices on your LAN or VPN.
|
|
43
|
+
--no-auth Serve without password login.
|
|
44
|
+
--web-port <n> Also mount the web UI while the TUI runs.
|
|
45
|
+
-h, --help Show this help.
|
|
46
|
+
|
|
47
|
+
Environment:
|
|
48
|
+
NEUTRINO_WEB_PORT Same as --web-port for the TUI, or the port in web mode.
|
|
49
|
+
NEUTRINO_WEB_HOST Same as --host.
|
|
50
|
+
|
|
51
|
+
The web UI can read every saved conversation and drive an agent with shell
|
|
52
|
+
access, so it binds to loopback by default. Log in with the permanent password
|
|
53
|
+
(default: 114514). Change it in Settings. --no-auth disables password login.
|
|
54
|
+
`);
|
|
55
|
+
}
|
|
56
|
+
/** Read the value that follows a flag, so `--host` alone is an error. */
|
|
57
|
+
function requireValue(argv, index, flag) {
|
|
58
|
+
const value = argv[index];
|
|
59
|
+
if (value === undefined || value.trim() === "") {
|
|
60
|
+
throw new Error(`missing value for ${flag}`);
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
function parseArgs(argv) {
|
|
65
|
+
const options = { mode: "tui", port: null, lan: false, noAuth: false };
|
|
66
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
67
|
+
const arg = argv[i];
|
|
68
|
+
if (arg === "web") {
|
|
69
|
+
options.mode = "web";
|
|
70
|
+
}
|
|
71
|
+
else if (arg === "--port" || arg === "-p") {
|
|
72
|
+
options.port = parsePort(argv[++i]);
|
|
73
|
+
}
|
|
74
|
+
else if (arg.startsWith("--port=")) {
|
|
75
|
+
options.port = parsePort(arg.slice("--port=".length));
|
|
76
|
+
}
|
|
77
|
+
else if (arg === "--web-port") {
|
|
78
|
+
options.port = parsePort(argv[++i]);
|
|
79
|
+
}
|
|
80
|
+
else if (arg.startsWith("--web-port=")) {
|
|
81
|
+
options.port = parsePort(arg.slice("--web-port=".length));
|
|
82
|
+
}
|
|
83
|
+
else if (arg === "--host") {
|
|
84
|
+
options.host = requireValue(argv, ++i, "--host");
|
|
85
|
+
}
|
|
86
|
+
else if (arg.startsWith("--host=")) {
|
|
87
|
+
options.host = arg.slice("--host=".length);
|
|
88
|
+
}
|
|
89
|
+
else if (arg === "--lan") {
|
|
90
|
+
options.lan = true;
|
|
91
|
+
}
|
|
92
|
+
else if (arg === "--no-auth") {
|
|
93
|
+
options.noAuth = true;
|
|
94
|
+
}
|
|
95
|
+
else if (arg === "--help" || arg === "-h") {
|
|
96
|
+
printHelp();
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
throw new Error(`unknown argument "${arg}"`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (options.lan && options.host) {
|
|
104
|
+
throw new Error("use either --lan or --host, not both");
|
|
105
|
+
}
|
|
106
|
+
return options;
|
|
107
|
+
}
|
|
108
|
+
/** Print every address a client can open, plus what it takes to open one. */
|
|
109
|
+
function reportAccess(handle) {
|
|
110
|
+
process.stdout.write(`Neutrino web UI listening on ${handle.host}:${handle.port}\n`);
|
|
111
|
+
const multiple = handle.urls.length > 1;
|
|
112
|
+
process.stdout.write(multiple ? "Open the address that matches your device:\n" : "Open:\n");
|
|
113
|
+
for (const url of handle.urls)
|
|
114
|
+
process.stdout.write(` ${url}\n`);
|
|
115
|
+
const wildcard = handle.host === "0.0.0.0" || handle.host === "::";
|
|
116
|
+
if (wildcard && !handle.urls.some((url) => !url.includes("//127.0.0.1:"))) {
|
|
117
|
+
// Bound to every interface, yet nothing outside this machine is reachable:
|
|
118
|
+
// usually Wi-Fi is off, the VPN is down, or the only adapters left are
|
|
119
|
+
// virtual ones we deliberately hide.
|
|
120
|
+
process.stdout.write("note: no reachable network address found, so only this machine can connect.\n");
|
|
121
|
+
}
|
|
122
|
+
if (handle.authRequired) {
|
|
123
|
+
process.stdout.write("Sign in with your permanent Web password (default: 114514). Change it in Settings.\n");
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
process.stdout.write("Password login is disabled.\n");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
let options;
|
|
130
|
+
try {
|
|
131
|
+
options = parseArgs(process.argv.slice(2));
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
process.stderr.write(`neutrino: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
135
|
+
process.stderr.write("Run 'neutrino --help' for usage.\n");
|
|
136
|
+
process.exit(2);
|
|
137
|
+
}
|
|
138
|
+
// Installed before either mode starts: a crash must leave a usable terminal
|
|
139
|
+
// (TUI) and a log line (both), never a shell flooded with mouse reports.
|
|
140
|
+
installCrashGuard();
|
|
141
|
+
const host = options.lan ? WILDCARD_HOST : options.host || process.env.NEUTRINO_WEB_HOST || undefined;
|
|
142
|
+
const auth = !options.noAuth;
|
|
143
|
+
if (options.mode === "web") {
|
|
144
|
+
// Headless: serve the saved history (and any live sessions in this process,
|
|
145
|
+
// which for a standalone server is none).
|
|
146
|
+
const port = options.port ?? envPort() ?? DEFAULT_WEB_PORT;
|
|
147
|
+
try {
|
|
148
|
+
const handle = await startWebServer({ port, host, auth });
|
|
149
|
+
reportAccess(handle);
|
|
150
|
+
process.stdout.write("Serving saved conversations. Press Ctrl+C to stop.\n");
|
|
151
|
+
const shutdown = () => {
|
|
152
|
+
handle.close().finally(() => process.exit(0));
|
|
153
|
+
};
|
|
154
|
+
process.on("SIGINT", shutdown);
|
|
155
|
+
process.on("SIGTERM", shutdown);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
process.stderr.write(`neutrino: failed to start web server: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
164
|
+
process.stderr.write("neutrino requires an interactive terminal.\n");
|
|
165
|
+
process.stderr.write("To serve the web UI without a terminal, run 'neutrino web --port <port>'.\n");
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
const app = new App();
|
|
169
|
+
const webPort = options.port ?? envPort();
|
|
170
|
+
if (webPort !== null) {
|
|
171
|
+
try {
|
|
172
|
+
const handle = await app.enableWeb(webPort, host, auth);
|
|
173
|
+
reportAccess(handle);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
process.stderr.write(`neutrino: failed to start web server: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
await app.start();
|
|
7
181
|
}
|
|
8
|
-
const app = new App();
|
|
9
|
-
await app.start();
|
package/dist/colors.js
CHANGED
|
@@ -8,6 +8,8 @@ export const C = {
|
|
|
8
8
|
toolHeader: [153, 217, 234], // cyan
|
|
9
9
|
amber: [224, 175, 104], // #E0AF68
|
|
10
10
|
white: [226, 232, 240],
|
|
11
|
+
orange: [255, 158, 61], // #FF9E3D approval highlight
|
|
12
|
+
orangeBg: [61, 36, 14], // #3D240E dim orange behind approval cards
|
|
11
13
|
green: [115, 218, 202],
|
|
12
14
|
red: [247, 118, 142],
|
|
13
15
|
codeBg: [27, 35, 49], // dark blue-grey used behind code blocks
|
package/dist/compact.js
CHANGED
|
@@ -6,6 +6,25 @@
|
|
|
6
6
|
* part into a plain transcript. The actual "ask the model to summarise" step
|
|
7
7
|
* lives in `LLMClient.summarize`, so this module stays trivially unit-testable.
|
|
8
8
|
*/
|
|
9
|
+
/** Ignore stale/corrupt metadata rather than hiding any original messages. */
|
|
10
|
+
export function normalizeContext(value, messageCount) {
|
|
11
|
+
if (!value || typeof value !== "object")
|
|
12
|
+
return null;
|
|
13
|
+
const context = value;
|
|
14
|
+
if (typeof context.end !== "number" || !Number.isInteger(context.end) ||
|
|
15
|
+
context.end <= 0 || context.end > messageCount ||
|
|
16
|
+
typeof context.summary !== "string" || !context.summary.trim())
|
|
17
|
+
return null;
|
|
18
|
+
return { end: context.end, summary: context.summary };
|
|
19
|
+
}
|
|
20
|
+
/** Derive a bounded model history while leaving the complete transcript intact. */
|
|
21
|
+
export function modelHistory(messages, context) {
|
|
22
|
+
const original = messages.filter((message) => message.role !== "system");
|
|
23
|
+
const valid = normalizeContext(context, original.length);
|
|
24
|
+
return valid
|
|
25
|
+
? [{ role: "summary", content: valid.summary }, ...original.slice(valid.end)]
|
|
26
|
+
: original;
|
|
27
|
+
}
|
|
9
28
|
/** How many trailing messages are always kept verbatim when compressing. */
|
|
10
29
|
export const COMPACT_KEEP = 4;
|
|
11
30
|
/** Per-message character budget fed to the summariser, to bound its own input. */
|