@xingjisoft/neutrino 0.1.3 → 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.
@@ -0,0 +1,87 @@
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
+ /** 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
+ }
28
+ /** How many trailing messages are always kept verbatim when compressing. */
29
+ export const COMPACT_KEEP = 4;
30
+ /** Per-message character budget fed to the summariser, to bound its own input. */
31
+ export const COMPACT_MAX_CHARS_PER_MESSAGE = 4000;
32
+ /**
33
+ * Split `messages` into an older prefix to summarise and a recent suffix to
34
+ * keep. The kept suffix never begins with a bare tool result — that would be an
35
+ * orphan without the assistant tool-call that produced it — so such messages
36
+ * are folded into the prefix instead.
37
+ *
38
+ * Returns null when there is nothing worth compressing (the conversation is
39
+ * already short enough).
40
+ */
41
+ export function planCompaction(messages, keep = COMPACT_KEEP) {
42
+ if (keep < 1)
43
+ keep = 1;
44
+ const head = [];
45
+ const tail = [];
46
+ for (let i = messages.length - 1; i >= 0; i--) {
47
+ if (tail.length < keep)
48
+ tail.unshift(messages[i]);
49
+ else
50
+ head.push(messages[i]);
51
+ }
52
+ head.reverse();
53
+ // Never open the kept suffix with an orphan tool result.
54
+ while (tail.length > 1 && tail[0].role === "tool") {
55
+ head.push(tail.shift());
56
+ }
57
+ if (head.length === 0 || tail.length === 0)
58
+ return null;
59
+ return { head, tail };
60
+ }
61
+ /**
62
+ * Flatten messages into a plain transcript for the summariser, clipping each
63
+ * message so a single huge tool result cannot dominate the summary request.
64
+ */
65
+ export function transcriptFor(messages, maxCharsPerMessage = COMPACT_MAX_CHARS_PER_MESSAGE) {
66
+ const clip = (s) => {
67
+ const t = s.replace(/\r\n?/g, "\n").trim();
68
+ return t.length > maxCharsPerMessage ? t.slice(0, maxCharsPerMessage) + "\n…[truncated]" : t;
69
+ };
70
+ const parts = [];
71
+ for (const m of messages) {
72
+ const body = clip(m.content ?? "");
73
+ if (m.role === "tool") {
74
+ parts.push(`TOOL ${m.name ?? ""} RESULT:\n${body}`);
75
+ }
76
+ else if (m.role === "assistant") {
77
+ parts.push(`ASSISTANT:\n${body}`);
78
+ }
79
+ else if (m.role === "user") {
80
+ parts.push(`USER:\n${body}`);
81
+ }
82
+ else {
83
+ parts.push(`${m.role.toUpperCase()}:\n${body}`);
84
+ }
85
+ }
86
+ return parts.join("\n\n");
87
+ }
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,6 +56,16 @@ export const DEFAULT_CONFIG = {
36
56
  language: "en",
37
57
  theme: "default",
38
58
  default_intensity: "High",
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: "",
39
69
  };
40
70
  export const INTENSITY_LEVELS = ["Low", "Medium", "High", "Max", "Ultra"];
41
71
  export const INTENSITY_PARAMS = {
@@ -45,20 +75,28 @@ export const INTENSITY_PARAMS = {
45
75
  Max: { temperature: 1.0, top_p: 1.0 },
46
76
  Ultra: { temperature: 1.5, top_p: 1.0 },
47
77
  };
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
+ }
48
85
  export function ensureDirs() {
49
86
  fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
50
87
  fs.mkdirSync(SESSION_DIR, { recursive: true });
51
88
  }
52
89
  export function loadModels() {
53
- ensureDirs();
54
- if (!fs.existsSync(MODEL_FILE)) {
55
- saveModels(DEFAULT_MODELS);
56
- return DEFAULT_MODELS;
57
- }
58
90
  try {
91
+ ensureDirs();
92
+ if (!fs.existsSync(MODEL_FILE)) {
93
+ saveModels(DEFAULT_MODELS);
94
+ return DEFAULT_MODELS;
95
+ }
59
96
  return JSON.parse(fs.readFileSync(MODEL_FILE, "utf-8"));
60
97
  }
61
98
  catch {
99
+ // Same reasoning as loadConfig: this sits on the approval path.
62
100
  return DEFAULT_MODELS;
63
101
  }
64
102
  }
@@ -67,15 +105,17 @@ export function saveModels(data) {
67
105
  fs.writeFileSync(MODEL_FILE, JSON.stringify(data, null, 2), "utf-8");
68
106
  }
69
107
  export function loadConfig() {
70
- ensureDirs();
71
- if (!fs.existsSync(CONFIG_FILE)) {
72
- saveConfig(DEFAULT_CONFIG);
73
- return { ...DEFAULT_CONFIG };
74
- }
75
108
  try {
109
+ ensureDirs();
110
+ if (!fs.existsSync(CONFIG_FILE)) {
111
+ saveConfig(DEFAULT_CONFIG);
112
+ return { ...DEFAULT_CONFIG };
113
+ }
76
114
  return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
77
115
  }
78
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.
79
119
  return { ...DEFAULT_CONFIG };
80
120
  }
81
121
  }
@@ -83,6 +123,18 @@ export function saveConfig(data) {
83
123
  ensureDirs();
84
124
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), "utf-8");
85
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
+ }
86
138
  export function getModelsByProvider() {
87
139
  const data = loadModels();
88
140
  const result = {};
@@ -91,10 +143,30 @@ export function getModelsByProvider() {
91
143
  }
92
144
  return result;
93
145
  }
94
- export function saveSession(messages, modelName, provider, intensity, cwd, sessionId, tokens) {
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) {
95
167
  ensureDirs();
96
168
  const now = new Date();
97
- const id = sessionId ?? now.toISOString().replace(/[:.]/g, "-");
169
+ const id = sessionId ?? `${now.toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
98
170
  const existing = sessionId ? loadSession(sessionId) : null;
99
171
  const created_at = existing ? existing.created_at : now.toISOString();
100
172
  let title = "";
@@ -115,9 +187,37 @@ export function saveSession(messages, modelName, provider, intensity, cwd, sessi
115
187
  title,
116
188
  messages,
117
189
  tokens,
190
+ approval_mode: normalizeApprovalMode(approvalMode ?? existing?.approval_mode),
118
191
  };
192
+ const validContext = normalizeContext(context, messages.filter((m) => m.role !== "system").length);
193
+ if (validContext)
194
+ session.context = validContext;
119
195
  const filePath = path.join(SESSION_DIR, `${id}.json`);
120
- fs.writeFileSync(filePath, JSON.stringify(session, null, 2), "utf-8");
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
+ }
121
221
  return id;
122
222
  }
123
223
  export function loadSession(sessionId) {
@@ -171,6 +271,14 @@ export function listSessions() {
171
271
  sessions.sort((a, b) => (b.updated_at || b.created_at).localeCompare(a.updated_at || a.created_at));
172
272
  return sessions;
173
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
+ }
174
282
  function readAsset(...candidates) {
175
283
  for (const candidate of candidates) {
176
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.