@xingjisoft/neutrino 0.3.0 → 0.4.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.
Files changed (42) hide show
  1. package/README.md +312 -464
  2. package/assets/pro/backend_output_model.txt +5 -0
  3. package/assets/pro/claude-module-prompt.txt +65 -0
  4. package/assets/pro/common.txt +9 -0
  5. package/assets/pro/confirm_model.txt +5 -0
  6. package/assets/pro/front_output_model.txt +9 -0
  7. package/assets/pro/genimage_model.txt +1 -0
  8. package/assets/pro/main_model.txt +5 -0
  9. package/assets/pro/model-template.json +376 -0
  10. package/assets/pro/modeling_model.txt +5 -0
  11. package/assets/pro/plan_model.txt +8 -0
  12. package/assets/pro/thinking_model.txt +3 -0
  13. package/dist/app.js +43 -7
  14. package/dist/bin.js +5 -2
  15. package/dist/browser.js +57 -0
  16. package/dist/config.js +170 -26
  17. package/dist/index.js +1 -1
  18. package/dist/llm/approval.js +6 -2
  19. package/dist/llm/client.js +197 -28
  20. package/dist/llm/subagent.js +37 -1
  21. package/dist/pro/agents.js +104 -0
  22. package/dist/pro/config.js +88 -0
  23. package/dist/pro/preflight.js +172 -0
  24. package/dist/screens/chat.js +14 -8
  25. package/dist/screens/modelSelect.js +15 -4
  26. package/dist/screens/settings.js +9 -7
  27. package/dist/screens/start.js +48 -15
  28. package/dist/ui.js +58 -0
  29. package/dist/web/hub.js +22 -4
  30. package/dist/web/server.js +279 -66
  31. package/dist/web/session.js +258 -22
  32. package/dist/web/settings.js +91 -24
  33. package/package.json +37 -37
  34. package/web/README.md +87 -13
  35. package/web/app.js +304 -14
  36. package/web/i18n.js +382 -345
  37. package/web/index.html +90 -14
  38. package/web/neutrino-pro.gif +0 -0
  39. package/web/neutrino.png +0 -0
  40. package/web/pro.js +184 -0
  41. package/web/settings.js +322 -247
  42. package/web/style.css +297 -0
@@ -0,0 +1,57 @@
1
+ import { spawn } from "node:child_process";
2
+ import process from "node:process";
3
+ /**
4
+ * Where the browser console's Pro workspace lives, given the console's base
5
+ * URL.
6
+ *
7
+ * The base comes straight from the running server, but it is normalised here
8
+ * anyway: a trailing slash must not produce `//pro`, and a URL that already
9
+ * points at `/pro` must not become `/pro/pro`. A missing/blank base yields the
10
+ * path alone instead of throwing, so a half-configured console can still be
11
+ * reported instead of crashing the click that asked for it.
12
+ */
13
+ export function proModeUrl(baseUrl) {
14
+ const base = String(baseUrl ?? "").trim().replace(/\/+$/, "");
15
+ if (/\/pro$/i.test(base))
16
+ return base;
17
+ return `${base}/pro`;
18
+ }
19
+ /**
20
+ * Open `url` in the user's default browser. Fire-and-forget: the child is
21
+ * detached, its stdio is ignored and it is unref'd so the terminal is never
22
+ * held open (or blocked) by the browser. Every failure is swallowed — opening
23
+ * a browser is best-effort and must never take the TUI down.
24
+ */
25
+ export function openInBrowser(url) {
26
+ try {
27
+ let command;
28
+ let args;
29
+ if (process.platform === "win32") {
30
+ // `start` is a cmd built-in. The empty "" is the (required) window title,
31
+ // so a quoted URL is never mistaken for one.
32
+ command = "cmd";
33
+ args = ["/c", "start", "", url];
34
+ }
35
+ else if (process.platform === "darwin") {
36
+ command = "open";
37
+ args = [url];
38
+ }
39
+ else {
40
+ command = "xdg-open";
41
+ args = [url];
42
+ }
43
+ const child = spawn(command, args, {
44
+ detached: true,
45
+ stdio: "ignore",
46
+ windowsHide: true,
47
+ });
48
+ // A missing opener (xdg-open is not installed everywhere) is reported
49
+ // asynchronously; without a listener that 'error' event would crash the
50
+ // whole process.
51
+ child.on("error", () => { });
52
+ child.unref();
53
+ }
54
+ catch {
55
+ // Best-effort only.
56
+ }
57
+ }
package/dist/config.js CHANGED
@@ -11,6 +11,36 @@ export const NEUTRINO_DIR = path.join(os.homedir(), ".neutrino");
11
11
  export const MODEL_FILE = path.join(NEUTRINO_DIR, "model.json");
12
12
  export const SESSION_DIR = path.join(NEUTRINO_DIR, "session");
13
13
  export const CONFIG_FILE = path.join(NEUTRINO_DIR, "config.json");
14
+ /**
15
+ * The browser console has two workspaces: the regular one and the isolated
16
+ * "Pro" workspace served at `/pro`. Everything a workspace owns — transcripts,
17
+ * model defaults, plan/compaction settings, reviewer model and interface
18
+ * language — is stored per mode; only the credentials and the approval policy
19
+ * below are shared, because they protect or gate the whole process.
20
+ */
21
+ export const MODES = ["standard", "pro"];
22
+ export const PRO_DIR = path.join(NEUTRINO_DIR, "pro");
23
+ export const PRO_CONFIG_FILE = path.join(PRO_DIR, "config.json");
24
+ export const PRO_SESSION_DIR = path.join(PRO_DIR, "session");
25
+ /**
26
+ * Settings that intentionally apply to both workspaces. The Web password, the
27
+ * approval policy and the console's interface are properties of this process,
28
+ * not of a conversation workspace.
29
+ */
30
+ export const SHARED_CONFIG_KEYS = ["web_password_hash", "approval_mode", "auto_approve_extreme_risk", "web_lan"];
31
+ export function normalizeMode(value) {
32
+ return typeof value === "string" && value.trim().toLowerCase() === "pro" ? "pro" : "standard";
33
+ }
34
+ export function configFile(mode = "standard") {
35
+ return mode === "pro" ? PRO_CONFIG_FILE : CONFIG_FILE;
36
+ }
37
+ export function sessionDir(mode = "standard") {
38
+ return mode === "pro" ? PRO_SESSION_DIR : SESSION_DIR;
39
+ }
40
+ /** Key used to remember a model's context capacity per workspace. */
41
+ export function modelContextWindowKey(provider, modelName) {
42
+ return `${provider}/${modelName}`;
43
+ }
14
44
  /**
15
45
  * Whether assistant messages must be sent back with a `reasoning_content`
16
46
  * field. DeepSeek's thinking mode requires one on *every* assistant message of
@@ -32,12 +62,26 @@ export function contextWindow(value) {
32
62
  return isContextWindow(value) ? value : DEFAULT_CONTEXT_WINDOW;
33
63
  }
34
64
  /** Reload the exact provider/model so settings also apply to active chats. */
35
- export function modelContextWindow(provider, model) {
65
+ export function modelContextWindow(provider, model, mode = "standard") {
66
+ // The Pro workspace keeps its own context capacities: changing one there must
67
+ // not move the compression threshold of the regular workspace (and vice
68
+ // versa). The capacity is a per-workspace setting, not shared model metadata,
69
+ // so an unset Pro value starts from the documented default instead of
70
+ // inheriting whatever the regular workspace saved in model.json.
71
+ if (mode === "pro") {
72
+ const configured = loadConfig("pro").model_context_windows;
73
+ if (configured && typeof configured === "object" && !Array.isArray(configured)) {
74
+ const override = configured[modelContextWindowKey(provider, model.name)];
75
+ if (isContextWindow(override))
76
+ return override;
77
+ }
78
+ return contextWindow(undefined);
79
+ }
36
80
  const saved = loadModels().providers.find((p) => p.name === provider)?.models.find((m) => m.name === model.name);
37
81
  return contextWindow((saved ?? model).context_window);
38
82
  }
39
- export function contextCompressionThreshold(provider, model) {
40
- return Math.max(1, Math.floor(modelContextWindow(provider, model) * CONTEXT_COMPRESSION_RATIO));
83
+ export function contextCompressionThreshold(provider, model, mode = "standard") {
84
+ return Math.max(1, Math.floor(modelContextWindow(provider, model, mode) * CONTEXT_COMPRESSION_RATIO));
41
85
  }
42
86
  export const DEFAULT_MODELS = {
43
87
  providers: [
@@ -89,6 +133,9 @@ export const DEFAULT_CONFIG = {
89
133
  // Mount the browser UI alongside the terminal UI unless it is turned off
90
134
  // here or on the command line.
91
135
  web_console: true,
136
+ // Listen on every interface (the equivalent of --lan) instead of loopback.
137
+ // The listener belongs to the process, so both workspaces share this value.
138
+ web_lan: false,
92
139
  // Working directories opened from the browser, newest first. They remain
93
140
  // listed even when they hold no conversations yet.
94
141
  web_workspaces: [],
@@ -103,6 +150,54 @@ export const INTENSITY_PARAMS = {
103
150
  Max: { temperature: 1.0, top_p: 1.0 },
104
151
  Ultra: { temperature: 1.5, top_p: 1.0 },
105
152
  };
153
+ /** Canonical spelling of a configured intensity name, or null when unknown. */
154
+ export function normalizeIntensity(value) {
155
+ if (typeof value !== "string")
156
+ return null;
157
+ const wanted = value.trim().toLowerCase();
158
+ return INTENSITY_LEVELS.find((level) => level.toLowerCase() === wanted) ?? null;
159
+ }
160
+ /**
161
+ * The intensities a model accepts, in the canonical Low → Ultra order.
162
+ *
163
+ * An absent or empty list keeps the historical behaviour: every level is
164
+ * allowed. Unknown names are ignored rather than making the model unusable, so
165
+ * a typo in `model.json` falls back to the remaining valid entries (or to all
166
+ * levels when none is valid).
167
+ */
168
+ export function modelIntensities(model) {
169
+ const raw = model?.intensity;
170
+ const entries = Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : [];
171
+ const allowed = new Set();
172
+ for (const entry of entries) {
173
+ const level = normalizeIntensity(entry);
174
+ if (level)
175
+ allowed.add(level);
176
+ }
177
+ if (allowed.size === 0)
178
+ return [...INTENSITY_LEVELS];
179
+ return INTENSITY_LEVELS.filter((level) => allowed.has(level));
180
+ }
181
+ /**
182
+ * Pick a usable intensity for a model. The preferred value wins when the model
183
+ * accepts it; otherwise the configured fallback, then High, then the first
184
+ * supported level. This is the one place stale UI state, a saved session or a
185
+ * hand-edited `config.json` is reconciled with the model's limits.
186
+ */
187
+ export function intensityForModel(model, preferred, fallback) {
188
+ const allowed = modelIntensities(model);
189
+ for (const candidate of [preferred, fallback, "High"]) {
190
+ const level = normalizeIntensity(candidate);
191
+ if (level && allowed.includes(level))
192
+ return level;
193
+ }
194
+ return allowed[0] ?? "High";
195
+ }
196
+ /** Exact model lookup within a provider (names may repeat across providers). */
197
+ export function findConfiguredModel(providerName, modelName) {
198
+ return loadModels().providers.find((provider) => provider.name === providerName)
199
+ ?.models.find((model) => model.name === modelName) ?? null;
200
+ }
106
201
  export function normalizeApprovalMode(value) {
107
202
  // Config files are hand-edited: "Auto" or " autO " must not silently
108
203
  // downgrade the conversation to a prompt on every tool call.
@@ -110,9 +205,11 @@ export function normalizeApprovalMode(value) {
110
205
  ? "auto"
111
206
  : "request";
112
207
  }
113
- export function ensureDirs() {
208
+ export function ensureDirs(mode = "standard") {
114
209
  fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
115
- fs.mkdirSync(SESSION_DIR, { recursive: true });
210
+ fs.mkdirSync(sessionDir(mode), { recursive: true });
211
+ if (mode === "pro")
212
+ fs.mkdirSync(PRO_DIR, { recursive: true });
116
213
  }
117
214
  export function loadModels() {
118
215
  try {
@@ -132,7 +229,26 @@ export function saveModels(data) {
132
229
  ensureDirs();
133
230
  fs.writeFileSync(MODEL_FILE, JSON.stringify(data, null, 2), "utf-8");
134
231
  }
135
- export function loadConfig() {
232
+ /** Read a config file without creating it; null when missing or unreadable. */
233
+ function readConfigFile(file) {
234
+ try {
235
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
236
+ }
237
+ catch {
238
+ return null;
239
+ }
240
+ }
241
+ export function loadConfig(mode = "standard") {
242
+ if (mode === "pro") {
243
+ // Pro keeps its own file, with credentials/approval policy layered on top
244
+ // from the main config so those stay shared by construction.
245
+ const own = readConfigFile(PRO_CONFIG_FILE) ?? {};
246
+ const shared = readConfigFile(CONFIG_FILE) ?? DEFAULT_CONFIG;
247
+ const merged = { ...DEFAULT_CONFIG, ...own };
248
+ for (const key of SHARED_CONFIG_KEYS)
249
+ merged[key] = shared[key] ?? DEFAULT_CONFIG[key];
250
+ return merged;
251
+ }
136
252
  try {
137
253
  ensureDirs();
138
254
  if (!fs.existsSync(CONFIG_FILE)) {
@@ -147,8 +263,30 @@ export function loadConfig() {
147
263
  return { ...DEFAULT_CONFIG };
148
264
  }
149
265
  }
150
- export function saveConfig(data) {
151
- ensureDirs();
266
+ export function saveConfig(data, mode = "standard") {
267
+ ensureDirs(mode);
268
+ if (mode === "pro") {
269
+ fs.mkdirSync(PRO_DIR, { recursive: true });
270
+ const own = { ...data };
271
+ for (const key of SHARED_CONFIG_KEYS)
272
+ delete own[key];
273
+ fs.writeFileSync(PRO_CONFIG_FILE, JSON.stringify(own, null, 2), "utf-8");
274
+ // A Pro settings save that touched a shared key (password, approval policy)
275
+ // writes it back to the main config, where the terminal and both pages read it.
276
+ const shared = readConfigFile(CONFIG_FILE) ?? { ...DEFAULT_CONFIG };
277
+ let changed = false;
278
+ for (const key of SHARED_CONFIG_KEYS) {
279
+ if (data[key] === undefined)
280
+ continue;
281
+ if (JSON.stringify(data[key]) === JSON.stringify(shared[key]))
282
+ continue;
283
+ shared[key] = data[key];
284
+ changed = true;
285
+ }
286
+ if (changed)
287
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(shared, null, 2), "utf-8");
288
+ return;
289
+ }
152
290
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), "utf-8");
153
291
  }
154
292
  /** Look a model up by name across every configured provider. */
@@ -179,7 +317,8 @@ export function resolveModelSelection(value, requireKey = true) {
179
317
  if (typeof input.provider !== "string" || typeof input.model !== "string" ||
180
318
  typeof input.intensity !== "string")
181
319
  throw new Error("provider, model and intensity are required");
182
- if (!INTENSITY_LEVELS.includes(input.intensity))
320
+ const intensity = normalizeIntensity(input.intensity);
321
+ if (!intensity)
183
322
  throw new Error("invalid thinking intensity");
184
323
  const provider = loadModels().providers.find((p) => p.name === input.provider);
185
324
  if (!provider)
@@ -187,15 +326,17 @@ export function resolveModelSelection(value, requireKey = true) {
187
326
  const model = provider.models.find((m) => m.name === input.model);
188
327
  if (!model)
189
328
  throw new Error("unknown model for this provider");
329
+ if (!modelIntensities(model).includes(intensity))
330
+ throw new Error("this model does not support that thinking intensity");
190
331
  if (requireKey && !model.api_key?.trim())
191
332
  throw new Error("this model has no API key configured");
192
- return { provider: provider.name, model: { ...model }, intensity: input.intensity };
333
+ return { provider: provider.name, model: { ...model }, intensity };
193
334
  }
194
- export function saveSession(messages, modelName, provider, intensity, cwd, sessionId, tokens, context, approvalMode, plan) {
195
- ensureDirs();
335
+ export function saveSession(messages, modelName, provider, intensity, cwd, sessionId, tokens, context, approvalMode, plan, mode = "standard", proPreset) {
336
+ ensureDirs(mode);
196
337
  const now = new Date();
197
338
  const id = sessionId ?? `${now.toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
198
- const existing = sessionId ? loadSession(sessionId) : null;
339
+ const existing = sessionId ? loadSession(sessionId, mode) : null;
199
340
  const created_at = existing ? existing.created_at : now.toISOString();
200
341
  let title = "";
201
342
  for (const msg of messages) {
@@ -216,6 +357,8 @@ export function saveSession(messages, modelName, provider, intensity, cwd, sessi
216
357
  messages,
217
358
  tokens,
218
359
  approval_mode: normalizeApprovalMode(approvalMode ?? existing?.approval_mode),
360
+ ...(mode === "pro" && (proPreset ?? existing?.pro_preset)
361
+ ? { pro_preset: proPreset ?? existing?.pro_preset } : {}),
219
362
  };
220
363
  const validContext = normalizeContext(context, messages.filter((m) => m.role !== "system").length);
221
364
  if (validContext)
@@ -224,7 +367,7 @@ export function saveSession(messages, modelName, provider, intensity, cwd, sessi
224
367
  // how far it got, and a stopped plan is still the record of what was agreed.
225
368
  if (plan && plan.steps.length)
226
369
  session.plan = plan;
227
- const filePath = path.join(SESSION_DIR, `${id}.json`);
370
+ const filePath = path.join(sessionDir(mode), `${id}.json`);
228
371
  // Keep the last complete transcript intact if writing or replacing fails.
229
372
  // The temporary file shares the destination directory so rename is atomic.
230
373
  const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
@@ -252,8 +395,8 @@ export function saveSession(messages, modelName, provider, intensity, cwd, sessi
252
395
  }
253
396
  return id;
254
397
  }
255
- export function loadSession(sessionId) {
256
- const filePath = path.join(SESSION_DIR, `${sessionId}.json`);
398
+ export function loadSession(sessionId, mode = "standard") {
399
+ const filePath = path.join(sessionDir(mode), `${sessionId}.json`);
257
400
  if (!fs.existsSync(filePath))
258
401
  return null;
259
402
  try {
@@ -263,27 +406,28 @@ export function loadSession(sessionId) {
263
406
  return null;
264
407
  }
265
408
  }
266
- export function deleteSession(sessionId) {
267
- const filePath = path.join(SESSION_DIR, `${sessionId}.json`);
409
+ export function deleteSession(sessionId, mode = "standard") {
410
+ const filePath = path.join(sessionDir(mode), `${sessionId}.json`);
268
411
  if (fs.existsSync(filePath)) {
269
412
  fs.unlinkSync(filePath);
270
413
  return true;
271
414
  }
272
415
  return false;
273
416
  }
274
- export function listSessions() {
275
- ensureDirs();
417
+ export function listSessions(mode = "standard") {
418
+ ensureDirs(mode);
276
419
  const sessions = [];
420
+ const dir = sessionDir(mode);
277
421
  let files = [];
278
422
  try {
279
- files = fs.readdirSync(SESSION_DIR).filter((f) => f.endsWith(".json"));
423
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
280
424
  }
281
425
  catch {
282
426
  return sessions;
283
427
  }
284
428
  for (const f of files) {
285
429
  try {
286
- const data = JSON.parse(fs.readFileSync(path.join(SESSION_DIR, f), "utf-8"));
430
+ const data = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
287
431
  sessions.push({
288
432
  id: data.id ?? f.replace(/\.json$/, ""),
289
433
  created_at: data.created_at ?? "",
@@ -308,8 +452,8 @@ export function listSessions() {
308
452
  * The history menu is scoped to the current project so sessions started in
309
453
  * other directories do not clutter it.
310
454
  */
311
- export function listSessionsForCwd(cwd = process.cwd()) {
312
- return listSessions().filter((s) => sameCwd(s.cwd, cwd));
455
+ export function listSessionsForCwd(cwd = process.cwd(), mode = "standard") {
456
+ return listSessions(mode).filter((s) => sameCwd(s.cwd, cwd));
313
457
  }
314
458
  function readAsset(...candidates) {
315
459
  for (const candidate of candidates) {
@@ -360,13 +504,13 @@ export function loadSubAgentPrompt() {
360
504
  * `NEUTRINO_PLAN=off` overrides both ways for a single run, the way the other
361
505
  * environment variables override the saved configuration.
362
506
  */
363
- export function planFirstEnabled() {
507
+ export function planFirstEnabled(mode = "standard") {
364
508
  const override = String(process.env.NEUTRINO_PLAN ?? "").trim().toLowerCase();
365
509
  if (override === "off" || override === "0" || override === "false" || override === "no")
366
510
  return false;
367
511
  if (override === "on" || override === "1" || override === "true" || override === "yes")
368
512
  return true;
369
- return loadConfig().plan_first !== false;
513
+ return loadConfig(mode).plan_first !== false;
370
514
  }
371
515
  let cachedVersion = null;
372
516
  /**
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { App } from "./app.js";
2
2
  export { defaultState } from "./state.js";
3
- export { INTENSITY_LEVELS, INTENSITY_PARAMS, DEFAULT_MODELS, loadModels, saveModels, loadConfig, saveConfig, listSessions, loadSession, deleteSession, saveSession, } from "./config.js";
3
+ export { INTENSITY_LEVELS, INTENSITY_PARAMS, DEFAULT_MODELS, MODES, normalizeMode, normalizeIntensity, modelIntensities, intensityForModel, findConfiguredModel, loadModels, saveModels, loadConfig, saveConfig, listSessions, loadSession, deleteSession, saveSession, } from "./config.js";
4
4
  export { LLMClient } from "./llm/client.js";
5
5
  export { executeTool, getToolDefinitions } from "./llm/tools.js";
@@ -511,9 +511,13 @@ export class ApprovalGate {
511
511
  // Resolved inside the try: an unresolvable reviewer model is a failed
512
512
  // vote (and therefore a denial), not an exception that aborts the call.
513
513
  const { model, provider, intensity } = this.options.reviewer();
514
- const client = new LLMClient(model, provider, intensity);
514
+ const client = this.options.createReviewerClient?.() ?? new LLMClient(model, provider, intensity);
515
515
  const timeout = this.options.reviewTimeoutMs?.() ?? REVIEW_TIMEOUT_MS;
516
- const raw = await withTimeout(client.complete(REVIEW_SYSTEM_PROMPT, prompt), timeout);
516
+ const rolePrompt = this.options.reviewerSystemPrompt?.();
517
+ const system = rolePrompt ? `${rolePrompt}\n\n${REVIEW_SYSTEM_PROMPT}` : REVIEW_SYSTEM_PROMPT;
518
+ const controller = new AbortController();
519
+ const raw = await withTimeout(client.complete(system, prompt, { signal: controller.signal, cancelled: this.options.cancelled }), timeout)
520
+ .finally(() => controller.abort());
517
521
  if (raw === null) {
518
522
  // Fail closed: a reviewer that never answers must not stall the turn.
519
523
  return {