@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/config.js
CHANGED
|
@@ -2,12 +2,32 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { sameCwd } from "./cwd.js";
|
|
7
|
+
import { normalizeContext } from "./compact.js";
|
|
5
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
9
|
const PKG_ROOT = path.resolve(__dirname, "..");
|
|
7
10
|
export const NEUTRINO_DIR = path.join(os.homedir(), ".neutrino");
|
|
8
11
|
export const MODEL_FILE = path.join(NEUTRINO_DIR, "model.json");
|
|
9
12
|
export const SESSION_DIR = path.join(NEUTRINO_DIR, "session");
|
|
10
13
|
export const CONFIG_FILE = path.join(NEUTRINO_DIR, "config.json");
|
|
14
|
+
export const DEFAULT_CONTEXT_WINDOW = 1_000_000;
|
|
15
|
+
export const CONTEXT_COMPRESSION_RATIO = 0.8;
|
|
16
|
+
export const CONTEXT_WINDOW_OPTIONS = [32_000, 64_000, 128_000, 200_000, 256_000, 512_000, 1_000_000, 2_000_000, 4_000_000];
|
|
17
|
+
export function isContextWindow(value) {
|
|
18
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
19
|
+
}
|
|
20
|
+
export function contextWindow(value) {
|
|
21
|
+
return isContextWindow(value) ? value : DEFAULT_CONTEXT_WINDOW;
|
|
22
|
+
}
|
|
23
|
+
/** Reload the exact provider/model so settings also apply to active chats. */
|
|
24
|
+
export function modelContextWindow(provider, model) {
|
|
25
|
+
const saved = loadModels().providers.find((p) => p.name === provider)?.models.find((m) => m.name === model.name);
|
|
26
|
+
return contextWindow((saved ?? model).context_window);
|
|
27
|
+
}
|
|
28
|
+
export function contextCompressionThreshold(provider, model) {
|
|
29
|
+
return Math.max(1, Math.floor(modelContextWindow(provider, model) * CONTEXT_COMPRESSION_RATIO));
|
|
30
|
+
}
|
|
11
31
|
export const DEFAULT_MODELS = {
|
|
12
32
|
providers: [
|
|
13
33
|
{
|
|
@@ -36,9 +56,16 @@ export const DEFAULT_CONFIG = {
|
|
|
36
56
|
language: "en",
|
|
37
57
|
theme: "default",
|
|
38
58
|
default_intensity: "High",
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
// Default for new conversations; each conversation can override it.
|
|
60
|
+
approval_mode: "request",
|
|
61
|
+
// Delete-class commands need a human unless this and Auto Approval are on.
|
|
62
|
+
auto_approve_extreme_risk: false,
|
|
63
|
+
// Audible cue when a turn ends or an approval request is waiting.
|
|
64
|
+
sound_alerts: true,
|
|
65
|
+
// Model that reviews approval requests. Empty = same model as the chat.
|
|
66
|
+
approval_model: "",
|
|
67
|
+
// Initialized as a salted hash of the default Web password on first use.
|
|
68
|
+
web_password_hash: "",
|
|
42
69
|
};
|
|
43
70
|
export const INTENSITY_LEVELS = ["Low", "Medium", "High", "Max", "Ultra"];
|
|
44
71
|
export const INTENSITY_PARAMS = {
|
|
@@ -48,32 +75,28 @@ export const INTENSITY_PARAMS = {
|
|
|
48
75
|
Max: { temperature: 1.0, top_p: 1.0 },
|
|
49
76
|
Ultra: { temperature: 1.5, top_p: 1.0 },
|
|
50
77
|
};
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
];
|
|
78
|
+
export function normalizeApprovalMode(value) {
|
|
79
|
+
// Config files are hand-edited: "Auto" or " autO " must not silently
|
|
80
|
+
// downgrade the conversation to a prompt on every tool call.
|
|
81
|
+
return typeof value === "string" && value.trim().toLowerCase() === "auto"
|
|
82
|
+
? "auto"
|
|
83
|
+
: "request";
|
|
84
|
+
}
|
|
63
85
|
export function ensureDirs() {
|
|
64
86
|
fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
|
|
65
87
|
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
66
88
|
}
|
|
67
89
|
export function loadModels() {
|
|
68
|
-
ensureDirs();
|
|
69
|
-
if (!fs.existsSync(MODEL_FILE)) {
|
|
70
|
-
saveModels(DEFAULT_MODELS);
|
|
71
|
-
return DEFAULT_MODELS;
|
|
72
|
-
}
|
|
73
90
|
try {
|
|
91
|
+
ensureDirs();
|
|
92
|
+
if (!fs.existsSync(MODEL_FILE)) {
|
|
93
|
+
saveModels(DEFAULT_MODELS);
|
|
94
|
+
return DEFAULT_MODELS;
|
|
95
|
+
}
|
|
74
96
|
return JSON.parse(fs.readFileSync(MODEL_FILE, "utf-8"));
|
|
75
97
|
}
|
|
76
98
|
catch {
|
|
99
|
+
// Same reasoning as loadConfig: this sits on the approval path.
|
|
77
100
|
return DEFAULT_MODELS;
|
|
78
101
|
}
|
|
79
102
|
}
|
|
@@ -82,15 +105,17 @@ export function saveModels(data) {
|
|
|
82
105
|
fs.writeFileSync(MODEL_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
83
106
|
}
|
|
84
107
|
export function loadConfig() {
|
|
85
|
-
ensureDirs();
|
|
86
|
-
if (!fs.existsSync(CONFIG_FILE)) {
|
|
87
|
-
saveConfig(DEFAULT_CONFIG);
|
|
88
|
-
return { ...DEFAULT_CONFIG };
|
|
89
|
-
}
|
|
90
108
|
try {
|
|
109
|
+
ensureDirs();
|
|
110
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
111
|
+
saveConfig(DEFAULT_CONFIG);
|
|
112
|
+
return { ...DEFAULT_CONFIG };
|
|
113
|
+
}
|
|
91
114
|
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
|
|
92
115
|
}
|
|
93
116
|
catch {
|
|
117
|
+
// The approval gate reads this on every tool call; a directory we cannot
|
|
118
|
+
// create or a file we cannot parse must fall back, not throw.
|
|
94
119
|
return { ...DEFAULT_CONFIG };
|
|
95
120
|
}
|
|
96
121
|
}
|
|
@@ -98,6 +123,18 @@ export function saveConfig(data) {
|
|
|
98
123
|
ensureDirs();
|
|
99
124
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
100
125
|
}
|
|
126
|
+
/** Look a model up by name across every configured provider. */
|
|
127
|
+
export function findModel(name) {
|
|
128
|
+
const wanted = name.trim();
|
|
129
|
+
if (!wanted)
|
|
130
|
+
return null;
|
|
131
|
+
for (const [provider, models] of Object.entries(getModelsByProvider())) {
|
|
132
|
+
const model = models.find((m) => m.name === wanted);
|
|
133
|
+
if (model)
|
|
134
|
+
return { provider, model };
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
101
138
|
export function getModelsByProvider() {
|
|
102
139
|
const data = loadModels();
|
|
103
140
|
const result = {};
|
|
@@ -106,10 +143,30 @@ export function getModelsByProvider() {
|
|
|
106
143
|
}
|
|
107
144
|
return result;
|
|
108
145
|
}
|
|
109
|
-
|
|
146
|
+
/** Resolve a provider/model pair exactly, including when names repeat across providers. */
|
|
147
|
+
export function resolveModelSelection(value, requireKey = true) {
|
|
148
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
149
|
+
throw new Error("invalid model selection");
|
|
150
|
+
const input = value;
|
|
151
|
+
if (typeof input.provider !== "string" || typeof input.model !== "string" ||
|
|
152
|
+
typeof input.intensity !== "string")
|
|
153
|
+
throw new Error("provider, model and intensity are required");
|
|
154
|
+
if (!INTENSITY_LEVELS.includes(input.intensity))
|
|
155
|
+
throw new Error("invalid thinking intensity");
|
|
156
|
+
const provider = loadModels().providers.find((p) => p.name === input.provider);
|
|
157
|
+
if (!provider)
|
|
158
|
+
throw new Error("unknown provider");
|
|
159
|
+
const model = provider.models.find((m) => m.name === input.model);
|
|
160
|
+
if (!model)
|
|
161
|
+
throw new Error("unknown model for this provider");
|
|
162
|
+
if (requireKey && !model.api_key?.trim())
|
|
163
|
+
throw new Error("this model has no API key configured");
|
|
164
|
+
return { provider: provider.name, model: { ...model }, intensity: input.intensity };
|
|
165
|
+
}
|
|
166
|
+
export function saveSession(messages, modelName, provider, intensity, cwd, sessionId, tokens, context, approvalMode) {
|
|
110
167
|
ensureDirs();
|
|
111
168
|
const now = new Date();
|
|
112
|
-
const id = sessionId ?? now.toISOString().replace(/[:.]/g, "-")
|
|
169
|
+
const id = sessionId ?? `${now.toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
|
113
170
|
const existing = sessionId ? loadSession(sessionId) : null;
|
|
114
171
|
const created_at = existing ? existing.created_at : now.toISOString();
|
|
115
172
|
let title = "";
|
|
@@ -130,9 +187,37 @@ export function saveSession(messages, modelName, provider, intensity, cwd, sessi
|
|
|
130
187
|
title,
|
|
131
188
|
messages,
|
|
132
189
|
tokens,
|
|
190
|
+
approval_mode: normalizeApprovalMode(approvalMode ?? existing?.approval_mode),
|
|
133
191
|
};
|
|
192
|
+
const validContext = normalizeContext(context, messages.filter((m) => m.role !== "system").length);
|
|
193
|
+
if (validContext)
|
|
194
|
+
session.context = validContext;
|
|
134
195
|
const filePath = path.join(SESSION_DIR, `${id}.json`);
|
|
135
|
-
|
|
196
|
+
// Keep the last complete transcript intact if writing or replacing fails.
|
|
197
|
+
// The temporary file shares the destination directory so rename is atomic.
|
|
198
|
+
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
199
|
+
const contents = JSON.stringify(session, null, 2);
|
|
200
|
+
let fd;
|
|
201
|
+
try {
|
|
202
|
+
fd = fs.openSync(tempPath, "wx", 0o600);
|
|
203
|
+
fs.writeFileSync(fd, contents, "utf-8");
|
|
204
|
+
fs.fsyncSync(fd);
|
|
205
|
+
fs.closeSync(fd);
|
|
206
|
+
fd = undefined;
|
|
207
|
+
fs.renameSync(tempPath, filePath);
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
if (fd !== undefined) {
|
|
211
|
+
try {
|
|
212
|
+
fs.closeSync(fd);
|
|
213
|
+
}
|
|
214
|
+
catch { /* Preserve the write error. */ }
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
fs.rmSync(tempPath, { force: true });
|
|
218
|
+
}
|
|
219
|
+
catch { /* Best-effort cleanup. */ }
|
|
220
|
+
}
|
|
136
221
|
return id;
|
|
137
222
|
}
|
|
138
223
|
export function loadSession(sessionId) {
|
|
@@ -186,6 +271,14 @@ export function listSessions() {
|
|
|
186
271
|
sessions.sort((a, b) => (b.updated_at || b.created_at).localeCompare(a.updated_at || a.created_at));
|
|
187
272
|
return sessions;
|
|
188
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Sessions that were started in `cwd` (the directory neutrino runs in).
|
|
276
|
+
* The history menu is scoped to the current project so sessions started in
|
|
277
|
+
* other directories do not clutter it.
|
|
278
|
+
*/
|
|
279
|
+
export function listSessionsForCwd(cwd = process.cwd()) {
|
|
280
|
+
return listSessions().filter((s) => sameCwd(s.cwd, cwd));
|
|
281
|
+
}
|
|
189
282
|
function readAsset(...candidates) {
|
|
190
283
|
for (const candidate of candidates) {
|
|
191
284
|
try {
|
package/dist/cwd.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for deciding whether a stored session belongs to the directory the
|
|
3
|
+
* agent is currently running in.
|
|
4
|
+
*
|
|
5
|
+
* Every agent records the working directory in its own flavour — `C:\a\b`,
|
|
6
|
+
* `C:/a/b`, a trailing slash, a differently cased drive letter, or a WSL style
|
|
7
|
+
* `/mnt/c/a/b` — so the comparison has to be forgiving about the spelling and
|
|
8
|
+
* strict about the actual location.
|
|
9
|
+
*/
|
|
10
|
+
/** Reduce a directory path to a canonical form that can be compared for equality. */
|
|
11
|
+
export function normalizeCwd(dir) {
|
|
12
|
+
let p = (dir ?? "").trim();
|
|
13
|
+
if (!p)
|
|
14
|
+
return "";
|
|
15
|
+
// The Windows long-path prefix ("\\?\C:\a") carries no meaning for us.
|
|
16
|
+
p = p.replace(/^\\\\\?\\/, "");
|
|
17
|
+
p = p.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
|
18
|
+
let drive = "";
|
|
19
|
+
let rest = p;
|
|
20
|
+
const wslDrive = /^\/(?:mnt|cygdrive)\/([a-zA-Z])(\/.*)?$/.exec(p);
|
|
21
|
+
const winDrive = /^([a-zA-Z]):(\/.*)?$/.exec(p);
|
|
22
|
+
if (wslDrive) {
|
|
23
|
+
drive = wslDrive[1];
|
|
24
|
+
rest = wslDrive[2] ?? "/";
|
|
25
|
+
}
|
|
26
|
+
else if (winDrive) {
|
|
27
|
+
drive = winDrive[1];
|
|
28
|
+
rest = winDrive[2] ?? "/";
|
|
29
|
+
}
|
|
30
|
+
if (drive) {
|
|
31
|
+
if (rest.length > 1)
|
|
32
|
+
rest = rest.replace(/\/+$/, "");
|
|
33
|
+
// Drive paths are Windows paths, which are case-insensitive.
|
|
34
|
+
return `${drive.toLowerCase()}:${(rest || "/").toLowerCase()}`;
|
|
35
|
+
}
|
|
36
|
+
// POSIX paths are case-sensitive: only drop trailing separators.
|
|
37
|
+
return p.length > 1 ? p.replace(/\/+$/, "") : p;
|
|
38
|
+
}
|
|
39
|
+
/** True when both paths point at the same directory. Empty paths never match. */
|
|
40
|
+
export function sameCwd(a, b) {
|
|
41
|
+
const na = normalizeCwd(a);
|
|
42
|
+
const nb = normalizeCwd(b);
|
|
43
|
+
return na !== "" && na === nb;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Claude Code names each project directory after the working directory with
|
|
47
|
+
* every non-alphanumeric character replaced by a dash. That lets us attribute
|
|
48
|
+
* transcripts that do not carry an explicit `cwd` field.
|
|
49
|
+
*/
|
|
50
|
+
export function claudeProjectKey(dir) {
|
|
51
|
+
return normalizeCwd(dir)
|
|
52
|
+
.replace(/[^a-zA-Z0-9]/g, "-")
|
|
53
|
+
.toLowerCase();
|
|
54
|
+
}
|
package/dist/header.js
CHANGED
|
@@ -2,6 +2,14 @@ import { C } from "./colors.js";
|
|
|
2
2
|
import { truncateByWidth } from "./width.js";
|
|
3
3
|
export function drawHeader(buf, state) {
|
|
4
4
|
const w = buf.width;
|
|
5
|
+
if (state.approval) {
|
|
6
|
+
// A tool call is waiting for an answer: the whole bar turns orange so it
|
|
7
|
+
// cannot be missed while the agent sits paused mid-turn.
|
|
8
|
+
const bg = C.orange;
|
|
9
|
+
buf.fillRect(0, 0, w, 1, { bg });
|
|
10
|
+
buf.centerText(0, "Request Approval", { fg: C.black, bg, bold: true });
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
5
13
|
if (state.isThinking) {
|
|
6
14
|
// Flashing indicator: the whole bar alternates between highlighted and
|
|
7
15
|
// dark while the agent is working, so the user always notices it.
|