@xingjisoft/neutrino 0.1.6 → 0.2.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.
package/dist/config.js CHANGED
@@ -56,6 +56,9 @@ export const DEFAULT_CONFIG = {
56
56
  language: "en",
57
57
  theme: "default",
58
58
  default_intensity: "High",
59
+ // Plan a request into steps before answering it: the planner agent writes the
60
+ // plan, the worker executes one step per turn. Off = answer in one pass.
61
+ plan_first: true,
59
62
  // Default for new conversations; each conversation can override it.
60
63
  approval_mode: "request",
61
64
  // Delete-class commands need a human unless this and Auto Approval are on.
@@ -64,6 +67,9 @@ export const DEFAULT_CONFIG = {
64
67
  sound_alerts: true,
65
68
  // Model that reviews approval requests. Empty = same model as the chat.
66
69
  approval_model: "",
70
+ // Mount the browser UI alongside the terminal UI unless it is turned off
71
+ // here or on the command line.
72
+ web_console: true,
67
73
  // Initialized as a salted hash of the default Web password on first use.
68
74
  web_password_hash: "",
69
75
  };
@@ -163,7 +169,7 @@ export function resolveModelSelection(value, requireKey = true) {
163
169
  throw new Error("this model has no API key configured");
164
170
  return { provider: provider.name, model: { ...model }, intensity: input.intensity };
165
171
  }
166
- export function saveSession(messages, modelName, provider, intensity, cwd, sessionId, tokens, context, approvalMode) {
172
+ export function saveSession(messages, modelName, provider, intensity, cwd, sessionId, tokens, context, approvalMode, plan) {
167
173
  ensureDirs();
168
174
  const now = new Date();
169
175
  const id = sessionId ?? `${now.toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
@@ -192,6 +198,10 @@ export function saveSession(messages, modelName, provider, intensity, cwd, sessi
192
198
  const validContext = normalizeContext(context, messages.filter((m) => m.role !== "system").length);
193
199
  if (validContext)
194
200
  session.context = validContext;
201
+ // A plan outlives the turn that produced it: reopening a conversation shows
202
+ // how far it got, and a stopped plan is still the record of what was agreed.
203
+ if (plan && plan.steps.length)
204
+ session.plan = plan;
195
205
  const filePath = path.join(SESSION_DIR, `${id}.json`);
196
206
  // Keep the last complete transcript intact if writing or replacing fails.
197
207
  // The temporary file shares the destination directory so rename is atomic.
@@ -302,6 +312,40 @@ export function loadPrompt() {
302
312
  return (readAsset(path.join(PKG_ROOT, "assets", "prompt.txt"), path.join(PKG_ROOT, "prompt.txt")) ??
303
313
  "You are Neutrino, a helpful AI coding assistant.");
304
314
  }
315
+ /** The planning agent's system prompt. */
316
+ export function loadPlannerPrompt() {
317
+ return (readAsset(path.join(PKG_ROOT, "assets", "planner.txt"), path.join(PKG_ROOT, "planner.txt")) ??
318
+ "Split the user's request into ordered, independent steps a coding agent can execute one at a " +
319
+ "time. Answer with JSON only: {\"goal\": \"...\", \"steps\": [{\"title\": \"...\"}]}.");
320
+ }
321
+ /** Template for the per-step system text; `{{index}}`, `{{plan}}`, … are filled in. */
322
+ export function loadStepPrompt() {
323
+ return (readAsset(path.join(PKG_ROOT, "assets", "step.txt"), path.join(PKG_ROOT, "step.txt")) ??
324
+ "You are executing step {{index}} of {{total}} of this plan:\n{{plan}}\n\nDo only: {{step}}\n" +
325
+ "Verify it, then stop and report what you did.");
326
+ }
327
+ /** The system prompt a delegated sub-agent works from. */
328
+ export function loadSubAgentPrompt() {
329
+ return (readAsset(path.join(PKG_ROOT, "assets", "subagent.txt"), path.join(PKG_ROOT, "subagent.txt")) ??
330
+ "You are a sub-agent working on one task delegated by another agent. You cannot ask the user " +
331
+ "anything and you cannot delegate further. Do the task with the tools you have, verify it, and " +
332
+ "report what you did and what you found in your final message.");
333
+ }
334
+ /**
335
+ * Whether a turn is planned before it is executed. Unset means on: the plan is
336
+ * the default workflow, and only an explicit `false` turns it off.
337
+ *
338
+ * `NEUTRINO_PLAN=off` overrides both ways for a single run, the way the other
339
+ * environment variables override the saved configuration.
340
+ */
341
+ export function planFirstEnabled() {
342
+ const override = String(process.env.NEUTRINO_PLAN ?? "").trim().toLowerCase();
343
+ if (override === "off" || override === "0" || override === "false" || override === "no")
344
+ return false;
345
+ if (override === "on" || override === "1" || override === "true" || override === "yes")
346
+ return true;
347
+ return loadConfig().plan_first !== false;
348
+ }
305
349
  let cachedVersion = null;
306
350
  /**
307
351
  * Read the CLI version straight from the package's `package.json` so the
package/dist/header.js CHANGED
@@ -23,6 +23,11 @@ export function drawHeader(buf, state) {
23
23
  }
24
24
  buf.fillRect(0, 0, w, 1, { bg: C.highlightBg });
25
25
  let text = `${state.selectedProvider}/${state.selectedModelName} | ${state.selectedIntensity}`;
26
+ if (state.webUrl) {
27
+ // A short reminder that the browser console is live; the startup message
28
+ // prints the full addresses when more than one works.
29
+ text += ` · web ${state.webUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
30
+ }
26
31
  text = truncateByWidth(text, w);
27
32
  buf.centerText(0, text, { fg: C.accent, bg: C.highlightBg, bold: true });
28
33
  }
@@ -1,6 +1,9 @@
1
1
  import { INTENSITY_PARAMS } from "../config.js";
2
- /** How many extra times a request is retried when the connection drops. */
3
- const NETWORK_RETRIES = 2;
2
+ import { HttpStatusError, describeError, isRetriableFailure, retryDelayMs, retryPolicy, waitBeforeRetry, } from "./retry.js";
3
+ // Callers import `describeError` from this module; it lives next to the
4
+ // classification helpers that share the same "is this a dropped connection?"
5
+ // knowledge, so it is re-exported rather than duplicated.
6
+ export { describeError } from "./retry.js";
4
7
  /** Give up if the server has not started to respond within this window. */
5
8
  const CONNECT_TIMEOUT_MS = 120_000;
6
9
  /**
@@ -14,124 +17,34 @@ const SUMMARY_SYSTEM_PROMPT = "You compress the conversation history of an AI co
14
17
  "and their meaningful results, and any unresolved problems or next steps. Drop pleasantries, restated " +
15
18
  "instructions and redundant tool output. Output only the summary, with no preface.";
16
19
  /**
17
- * Terse transport messages that all mean "the connection dropped mid-flight".
18
- * The \b keeps "terminated" from matching unrelated text such as
19
- * "Unterminated string constant".
20
- */
21
- const DROP_HINT_RE = /\bterminated\b|other side closed|socket hang up|premature close/i;
22
- /**
23
- * Flatten an error and its `cause` chain into one readable line.
20
+ * One POST, bounded by a connect timeout.
24
21
  *
25
- * `fetch` reports almost every transport problem as a bare
26
- * `TypeError: fetch failed`; the actual reason (`ECONNRESET`, `EMFILE`,
27
- * `ENOTFOUND`, `UND_ERR_…`) is tucked away in `error.cause`. Without walking
28
- * that chain a failure is impossible to diagnose, so this always reports it.
22
+ * Retrying deliberately does *not* happen here: only the caller knows whether a
23
+ * token already reached the user, and nesting two retry loops would multiply
24
+ * both the request count and the total wait by the attempt budget.
29
25
  */
30
- export function describeError(e) {
31
- if (!(e instanceof Error))
32
- return String(e);
33
- const parts = [];
34
- const push = (msg, code) => {
35
- const text = code && !msg.includes(code) ? `${msg} (${code})` : msg;
36
- if (text && !parts.includes(text))
37
- parts.push(text);
38
- };
39
- let cur = e;
40
- const seen = new Set();
41
- while (cur != null && !seen.has(cur)) {
42
- if (typeof cur === "string") {
43
- // fetch/undici sometimes stash the real reason in a plain string
44
- // (`cause: "other side closed"`), which an object-only walk would miss.
45
- push(cur);
46
- break;
47
- }
48
- if (typeof cur !== "object")
49
- break;
50
- seen.add(cur);
51
- const err = cur;
52
- const message = typeof err.message === "string" && err.message ? err.message : String(err.name ?? "Error");
53
- const code = typeof err.code === "string" ? err.code : undefined;
54
- push(message, code);
55
- cur = err.cause;
26
+ async function postStream(url, init) {
27
+ const controller = new AbortController();
28
+ const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${CONNECT_TIMEOUT_MS / 1000}s`)), CONNECT_TIMEOUT_MS);
29
+ try {
30
+ return await fetch(url, { ...init, signal: controller.signal });
56
31
  }
57
- const text = parts.join(": ") || e.message;
58
- // A dropped mid-stream connection surfaces as a bare `TypeError: terminated`,
59
- // which on its own tells the user nothing worth reading.
60
- return DROP_HINT_RE.test(text) ? `${text} — the connection was closed before the response finished` : text;
61
- }
62
- /**
63
- * Whether a failed request is worth retrying: a transient connection problem
64
- * (reset / refused / DNS / timeout) rather than a real response from the API.
65
- */
66
- function isRetriableNetworkError(e) {
67
- const transient = new Set([
68
- "ECONNRESET",
69
- "ECONNREFUSED",
70
- "ECONNABORTED",
71
- "EPIPE",
72
- "ETIMEDOUT",
73
- "ENETUNREACH",
74
- "ENETDOWN",
75
- "EAI_AGAIN",
76
- "ERR_STREAM_PREMATURE_CLOSE",
77
- "UND_ERR_SOCKET",
78
- "UND_ERR_CONNECT_TIMEOUT",
79
- ]);
80
- let cur = e;
81
- const seen = new Set();
82
- while (cur != null && !seen.has(cur)) {
83
- if (typeof cur === "string") {
84
- if (DROP_HINT_RE.test(cur))
85
- return true;
86
- break;
87
- }
88
- if (typeof cur !== "object")
89
- break;
90
- seen.add(cur);
91
- const err = cur;
92
- if (typeof err.code === "string" && transient.has(err.code))
93
- return true;
94
- // Our own connect timeout aborts with a plain AbortError; undici wraps a
95
- // dropped socket in a SocketError.
96
- if (err.name === "AbortError" || err.name === "SocketError")
97
- return true;
98
- // A stream that dies mid-flight: "terminated", "socket hang up", …
99
- if (typeof err.message === "string" && DROP_HINT_RE.test(err.message))
100
- return true;
101
- cur = err.cause;
32
+ finally {
33
+ clearTimeout(timer);
102
34
  }
103
- // Undici's generic wrapper: assume the socket dropped and try again.
104
- return e instanceof Error && /fetch failed/i.test(e.message);
105
35
  }
106
- function sleep(ms) {
107
- return new Promise((resolve) => setTimeout(resolve, ms));
36
+ /** The policy a stream will actually use, with any per-call override applied. */
37
+ function resolvePolicy(options) {
38
+ return { ...retryPolicy(), ...options.retry };
108
39
  }
109
40
  /**
110
- * Issue a streaming request, retrying a few times on transient network errors
111
- * and giving up with a clear error if the server never starts responding.
112
- *
113
- * Retries are safe here because they only ever happen *before* a response is
114
- * returned; once bytes start flowing the caller is responsible for the stream.
41
+ * Wait before the next attempt, telling the caller why the stream went quiet.
42
+ * Returns false when the caller stopped caring during the wait.
115
43
  */
116
- async function requestStream(url, init) {
117
- let lastErr;
118
- for (let attempt = 0; attempt <= NETWORK_RETRIES; attempt++) {
119
- const controller = new AbortController();
120
- const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${CONNECT_TIMEOUT_MS / 1000}s`)), CONNECT_TIMEOUT_MS);
121
- try {
122
- const resp = await fetch(url, { ...init, signal: controller.signal });
123
- clearTimeout(timer);
124
- return resp;
125
- }
126
- catch (e) {
127
- clearTimeout(timer);
128
- lastErr = e;
129
- if (attempt === NETWORK_RETRIES || !isRetriableNetworkError(e))
130
- throw e;
131
- await sleep(300 * (attempt + 1));
132
- }
133
- }
134
- throw lastErr;
44
+ async function pauseForRetry(attempt, policy, error, options) {
45
+ const delayMs = retryDelayMs(attempt - 1, policy);
46
+ options.onRetry?.({ attempt: attempt + 1, attempts: policy.attempts, delayMs, reason: describeError(error) });
47
+ return waitBeforeRetry(attempt - 1, policy, options.cancelled);
135
48
  }
136
49
  export class LLMClient {
137
50
  model;
@@ -155,13 +68,13 @@ export class LLMClient {
155
68
  * the old messages. Uses a throw-away request (no tools) via the normal
156
69
  * streaming path, so provider differences are handled for free.
157
70
  */
158
- async summarize(transcript) {
71
+ async summarize(transcript, options = {}) {
159
72
  const messages = [
160
73
  { role: "system", content: SUMMARY_SYSTEM_PROMPT },
161
74
  { role: "user", content: transcript },
162
75
  ];
163
76
  let out = "";
164
- for await (const chunk of this.streamTurn(messages))
77
+ for await (const chunk of this.streamTurn(messages, undefined, options))
165
78
  out += chunk;
166
79
  const text = out.trim();
167
80
  if (!text || text.startsWith("[Error"))
@@ -172,41 +85,45 @@ export class LLMClient {
172
85
  * One-shot completion with its own system prompt. The approval reviewers use
173
86
  * this so they never share context with the conversation they are judging.
174
87
  */
175
- async complete(system, user) {
88
+ async complete(system, user, options = {}) {
176
89
  let out = "";
177
90
  for await (const chunk of this.streamTurn([
178
91
  { role: "system", content: system },
179
92
  { role: "user", content: user },
180
- ])) {
93
+ ], undefined, options)) {
181
94
  out += chunk;
182
95
  }
183
96
  return out.trim();
184
97
  }
185
- async *streamTurn(messages, tools) {
98
+ async *streamTurn(messages, tools, options = {}) {
186
99
  this.toolCalls = [];
187
100
  this.usage = null;
188
101
  try {
189
102
  if (this.provider === "Anthropic") {
190
- yield* this.streamAnthropic(messages);
103
+ yield* this.streamAnthropic(messages, options);
191
104
  return;
192
105
  }
193
- yield* this.streamOpenAI(messages, tools);
106
+ yield* this.streamOpenAI(messages, tools, options);
194
107
  }
195
108
  catch (e) {
196
109
  yield `\n[Error: ${describeError(e)}]`;
197
110
  }
198
111
  }
199
112
  /**
200
- * Drive one request, retrying if the stream dies before it produced anything.
113
+ * Drive one request, retrying while the failure is transient.
201
114
  *
202
115
  * A response can be cut off on the very first read — "terminated" when a
203
- * proxy or the server closes the connection mid-flight. Nothing has reached
204
- * the user yet, so re-issuing the request is invisible (and cannot duplicate
205
- * text). Once a token has been emitted a retry is unsafe, so the error is
206
- * surfaced instead.
116
+ * proxy or the server closes the connection mid-flight — or the request can
117
+ * never leave at all. Nothing has reached the user yet, so re-issuing is
118
+ * invisible and cannot duplicate text; the attempts and the 5s/10s/20s/…
119
+ * backoff live in `retry.ts`.
120
+ *
121
+ * Once a token has been emitted a retry would replay the reply from its
122
+ * beginning, so the error is surfaced instead and the partial text is kept.
207
123
  */
208
- async *streamOpenAI(messages, tools) {
209
- for (let attempt = 0;; attempt++) {
124
+ async *streamOpenAI(messages, tools, options) {
125
+ const policy = resolvePolicy(options);
126
+ for (let attempt = 1;; attempt++) {
210
127
  this.toolCalls = [];
211
128
  let produced = false;
212
129
  try {
@@ -217,9 +134,10 @@ export class LLMClient {
217
134
  return;
218
135
  }
219
136
  catch (e) {
220
- if (produced || attempt >= NETWORK_RETRIES || !isRetriableNetworkError(e))
137
+ if (produced || attempt >= policy.attempts || !isRetriableFailure(e))
221
138
  throw e;
222
- await sleep(400 * (attempt + 1));
139
+ if (!(await pauseForRetry(attempt, policy, e, options)))
140
+ return;
223
141
  }
224
142
  }
225
143
  }
@@ -240,15 +158,17 @@ export class LLMClient {
240
158
  };
241
159
  if (tools && tools.length)
242
160
  payload.tools = tools;
243
- const resp = await requestStream(url, {
161
+ const resp = await postStream(url, {
244
162
  method: "POST",
245
163
  headers,
246
164
  body: JSON.stringify(payload),
247
165
  });
248
166
  if (!resp.ok) {
249
- const text = await resp.text().catch(() => "");
250
- yield `\n[Error: HTTP ${resp.status}${text ? " - " + text.slice(0, 500) : ""}]`;
251
- return;
167
+ // Thrown rather than printed: a 502/503/504/429 is as transient as a
168
+ // reset and deserves the same retries, while a 400/401 must surface at
169
+ // once. `streamTurn` renders the final failure into the transcript.
170
+ const body = await resp.text().catch(() => "");
171
+ throw new HttpStatusError(resp.status, body.slice(0, 500));
252
172
  }
253
173
  if (!resp.body) {
254
174
  yield "\n[Error: empty response body]";
@@ -333,8 +253,9 @@ export class LLMClient {
333
253
  .map((k) => toolCallsByIndex[k]);
334
254
  }
335
255
  /** Anthropic counterpart of `streamOpenAI`, with the same retry policy. */
336
- async *streamAnthropic(messages) {
337
- for (let attempt = 0;; attempt++) {
256
+ async *streamAnthropic(messages, options) {
257
+ const policy = resolvePolicy(options);
258
+ for (let attempt = 1;; attempt++) {
338
259
  this.toolCalls = [];
339
260
  let produced = false;
340
261
  try {
@@ -345,9 +266,10 @@ export class LLMClient {
345
266
  return;
346
267
  }
347
268
  catch (e) {
348
- if (produced || attempt >= NETWORK_RETRIES || !isRetriableNetworkError(e))
269
+ if (produced || attempt >= policy.attempts || !isRetriableFailure(e))
349
270
  throw e;
350
- await sleep(400 * (attempt + 1));
271
+ if (!(await pauseForRetry(attempt, policy, e, options)))
272
+ return;
351
273
  }
352
274
  }
353
275
  }
@@ -376,15 +298,17 @@ export class LLMClient {
376
298
  };
377
299
  if (systemMsg)
378
300
  payload.system = systemMsg;
379
- const resp = await requestStream(url, {
301
+ const resp = await postStream(url, {
380
302
  method: "POST",
381
303
  headers,
382
304
  body: JSON.stringify(payload),
383
305
  });
384
306
  if (!resp.ok) {
385
- const text = await resp.text().catch(() => "");
386
- yield `\n[Error: HTTP ${resp.status}${text ? " - " + text.slice(0, 500) : ""}]`;
387
- return;
307
+ // Thrown rather than printed: a 502/503/504/429 is as transient as a
308
+ // reset and deserves the same retries, while a 400/401 must surface at
309
+ // once. `streamTurn` renders the final failure into the transcript.
310
+ const body = await resp.text().catch(() => "");
311
+ throw new HttpStatusError(resp.status, body.slice(0, 500));
388
312
  }
389
313
  if (!resp.body) {
390
314
  yield "\n[Error: empty response body]";