@xingjisoft/neutrino 0.3.0 → 0.4.1

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 (56) hide show
  1. package/README.md +33 -549
  2. package/assets/neutrino.ico +0 -0
  3. package/assets/neutrino.svg +7 -0
  4. package/assets/pro/backend_output_model.txt +5 -0
  5. package/assets/pro/claude-module-prompt.txt +65 -0
  6. package/assets/pro/common.txt +9 -0
  7. package/assets/pro/confirm_model.txt +5 -0
  8. package/assets/pro/front_output_model.txt +9 -0
  9. package/assets/pro/genimage_model.txt +1 -0
  10. package/assets/pro/main_model.txt +5 -0
  11. package/assets/pro/model-template.json +376 -0
  12. package/assets/pro/modeling_model.txt +5 -0
  13. package/assets/pro/plan_model.txt +8 -0
  14. package/assets/pro/thinking_model.txt +3 -0
  15. package/dist/agents.js +22 -3
  16. package/dist/app.js +199 -26
  17. package/dist/background.js +358 -0
  18. package/dist/bin.js +184 -44
  19. package/dist/browser.js +57 -0
  20. package/dist/config.js +174 -26
  21. package/dist/header.js +2 -1
  22. package/dist/index.js +1 -1
  23. package/dist/llm/approval.js +6 -2
  24. package/dist/llm/client.js +197 -28
  25. package/dist/llm/subagent.js +37 -1
  26. package/dist/llm/tools.js +3 -1
  27. package/dist/migration.js +33 -0
  28. package/dist/pro/agents.js +104 -0
  29. package/dist/pro/config.js +88 -0
  30. package/dist/pro/preflight.js +172 -0
  31. package/dist/screens/agentConversation.js +48 -5
  32. package/dist/screens/agentHistory.js +4 -2
  33. package/dist/screens/chat.js +14 -8
  34. package/dist/screens/modelSelect.js +15 -4
  35. package/dist/screens/settings.js +52 -9
  36. package/dist/screens/start.js +48 -15
  37. package/dist/state.js +1 -0
  38. package/dist/terminal/restore.js +3 -12
  39. package/dist/tray.js +138 -0
  40. package/dist/ui.js +58 -0
  41. package/dist/web/hub.js +26 -4
  42. package/dist/web/server.js +495 -74
  43. package/dist/web/service.js +135 -0
  44. package/dist/web/session.js +278 -25
  45. package/dist/web/settings.js +97 -24
  46. package/dist/web/terminalBridge.js +250 -0
  47. package/package.json +37 -37
  48. package/web/README.md +150 -16
  49. package/web/app.js +490 -25
  50. package/web/i18n.js +396 -345
  51. package/web/index.html +120 -15
  52. package/web/neutrino-pro.gif +0 -0
  53. package/web/neutrino.png +0 -0
  54. package/web/pro.js +184 -0
  55. package/web/settings.js +373 -247
  56. package/web/style.css +398 -0
@@ -1,4 +1,4 @@
1
- import { INTENSITY_PARAMS, modelUsesReasoning } from "../config.js";
1
+ import { INTENSITY_PARAMS, intensityForModel, modelUsesReasoning } from "../config.js";
2
2
  import { HttpStatusError, describeError, isRetriableFailure, retryDelayMs, retryPolicy, waitBeforeRetry, } from "./retry.js";
3
3
  // Callers import `describeError` from this module; it lives next to the
4
4
  // classification helpers that share the same "is this a dropped connection?"
@@ -25,12 +25,21 @@ const SUMMARY_SYSTEM_PROMPT = "You compress the conversation history of an AI co
25
25
  */
26
26
  async function postStream(url, init) {
27
27
  const controller = new AbortController();
28
+ const abort = () => controller.abort(init.signal?.reason);
29
+ if (init.signal?.aborted)
30
+ abort();
31
+ else
32
+ init.signal?.addEventListener("abort", abort, { once: true });
28
33
  const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${CONNECT_TIMEOUT_MS / 1000}s`)), CONNECT_TIMEOUT_MS);
29
34
  try {
30
35
  return await fetch(url, { ...init, signal: controller.signal });
31
36
  }
32
37
  finally {
33
38
  clearTimeout(timer);
39
+ // The caller's stream controller is aborted in streamTurn's finally, so
40
+ // this listener also covers cancellation after the response headers.
41
+ if (controller.signal.aborted)
42
+ init.signal?.removeEventListener("abort", abort);
34
43
  }
35
44
  }
36
45
  /** The policy a stream will actually use, with any per-call override applied. */
@@ -47,6 +56,7 @@ async function pauseForRetry(attempt, policy, error, options) {
47
56
  return waitBeforeRetry(attempt - 1, policy, options.cancelled);
48
57
  }
49
58
  export class LLMClient {
59
+ professional;
50
60
  model;
51
61
  provider;
52
62
  intensity;
@@ -60,11 +70,18 @@ export class LLMClient {
60
70
  * call, so the agent loops attach it to that message.
61
71
  */
62
72
  reasoningContent = "";
63
- constructor(model, provider, intensity) {
73
+ anthropicContent = [];
74
+ constructor(model, provider, intensity, options = {}) {
64
75
  this.model = model;
65
76
  this.provider = provider;
66
- this.intensity = intensity;
67
- const params = INTENSITY_PARAMS[intensity] ?? INTENSITY_PARAMS["High"];
77
+ this.professional = options.professional === true;
78
+ // Final safety net: even a stale UI, a saved session or a hand-edited
79
+ // selection can never send a level this model does not declare.
80
+ this.intensity = this.professional ? intensity.trim().toLowerCase() || "high" : intensityForModel(model, intensity);
81
+ const sampling = this.professional
82
+ ? this.intensity.charAt(0).toUpperCase() + this.intensity.slice(1)
83
+ : this.intensity;
84
+ const params = INTENSITY_PARAMS[sampling] ?? INTENSITY_PARAMS["High"];
68
85
  this.temperature = params.temperature;
69
86
  this.top_p = params.top_p;
70
87
  }
@@ -105,15 +122,37 @@ export class LLMClient {
105
122
  this.toolCalls = [];
106
123
  this.usage = null;
107
124
  this.reasoningContent = "";
125
+ this.anthropicContent = [];
126
+ const controller = new AbortController();
127
+ const abort = () => controller.abort(options.signal?.reason);
128
+ if (options.signal?.aborted)
129
+ abort();
130
+ else
131
+ options.signal?.addEventListener("abort", abort, { once: true });
132
+ const cancelled = () => controller.signal.aborted || options.cancelled?.() === true;
133
+ const timer = options.cancelled ? setInterval(() => {
134
+ if (options.cancelled?.())
135
+ controller.abort();
136
+ }, 100) : null;
137
+ const requestOptions = { ...options, signal: controller.signal, cancelled };
108
138
  try {
139
+ if (controller.signal.aborted)
140
+ return;
109
141
  if (this.provider === "Anthropic") {
110
- yield* this.streamAnthropic(messages, options);
142
+ yield* this.streamAnthropic(messages, tools, requestOptions);
111
143
  return;
112
144
  }
113
- yield* this.streamOpenAI(messages, tools, options);
145
+ yield* this.streamOpenAI(messages, tools, requestOptions);
114
146
  }
115
147
  catch (e) {
116
- yield `\n[Error: ${describeError(e)}]`;
148
+ if (!cancelled())
149
+ yield `\n[Error: ${describeError(e)}]`;
150
+ }
151
+ finally {
152
+ if (timer)
153
+ clearInterval(timer);
154
+ options.signal?.removeEventListener("abort", abort);
155
+ controller.abort();
117
156
  }
118
157
  }
119
158
  /**
@@ -135,7 +174,7 @@ export class LLMClient {
135
174
  this.reasoningContent = "";
136
175
  let produced = false;
137
176
  try {
138
- for await (const token of this.openAIAttempt(messages, tools)) {
177
+ for await (const token of this.openAIAttempt(messages, tools, options)) {
139
178
  produced = true;
140
179
  yield token;
141
180
  }
@@ -172,7 +211,7 @@ export class LLMClient {
172
211
  : message);
173
212
  }
174
213
  /** A single streaming attempt; see `streamOpenAI` for the retry policy. */
175
- async *openAIAttempt(messages, tools) {
214
+ async *openAIAttempt(messages, tools, options) {
176
215
  const url = this.model.url.replace(/\/+$/, "") + "/chat/completions";
177
216
  const headers = {
178
217
  "Content-Type": "application/json",
@@ -181,10 +220,12 @@ export class LLMClient {
181
220
  const payload = {
182
221
  model: this.model.name,
183
222
  messages: this.wireMessages(messages),
184
- temperature: this.temperature,
185
- top_p: this.top_p,
186
- stream: true,
187
- stream_options: { include_usage: true },
223
+ ...(this.professional ? { reasoning_effort: this.intensity } : {
224
+ temperature: this.temperature,
225
+ top_p: this.top_p,
226
+ }),
227
+ stream: this.model.supports_streaming !== false,
228
+ ...(this.model.supports_streaming !== false ? { stream_options: { include_usage: true } } : {}),
188
229
  };
189
230
  if (tools && tools.length)
190
231
  payload.tools = tools;
@@ -192,6 +233,7 @@ export class LLMClient {
192
233
  method: "POST",
193
234
  headers,
194
235
  body: JSON.stringify(payload),
236
+ signal: options.signal,
195
237
  });
196
238
  if (!resp.ok) {
197
239
  // Thrown rather than printed: a 502/503/504/429 is as transient as a
@@ -200,6 +242,24 @@ export class LLMClient {
200
242
  const body = await resp.text().catch(() => "");
201
243
  throw new HttpStatusError(resp.status, body.slice(0, 500));
202
244
  }
245
+ if (this.model.supports_streaming === false || resp.headers.get("content-type")?.includes("application/json")) {
246
+ const body = await resp.json();
247
+ const message = body.choices?.[0]?.message;
248
+ if (!message)
249
+ throw new Error("endpoint did not return a valid model completion");
250
+ this.reasoningContent = message.reasoning_content ?? message.reasoning ?? "";
251
+ this.toolCalls = (message.tool_calls ?? []).map((call) => ({
252
+ id: call.id, name: call.function.name, arguments: call.function.arguments,
253
+ }));
254
+ if (body.usage) {
255
+ const prompt = body.usage.prompt_tokens ?? 0;
256
+ const completion = body.usage.completion_tokens ?? 0;
257
+ this.usage = { prompt, completion, total: body.usage.total_tokens ?? prompt + completion };
258
+ }
259
+ if (message.content)
260
+ yield message.content;
261
+ return;
262
+ }
203
263
  if (!resp.body) {
204
264
  yield "\n[Error: empty response body]";
205
265
  return;
@@ -288,13 +348,14 @@ export class LLMClient {
288
348
  .map((k) => toolCallsByIndex[k]);
289
349
  }
290
350
  /** Anthropic counterpart of `streamOpenAI`, with the same retry policy. */
291
- async *streamAnthropic(messages, options) {
351
+ async *streamAnthropic(messages, tools, options) {
292
352
  const policy = resolvePolicy(options);
293
353
  for (let attempt = 1;; attempt++) {
294
354
  this.toolCalls = [];
355
+ this.anthropicContent = [];
295
356
  let produced = false;
296
357
  try {
297
- for await (const token of this.anthropicAttempt(messages)) {
358
+ for await (const token of this.anthropicAttempt(messages, tools, options)) {
298
359
  produced = true;
299
360
  yield token;
300
361
  }
@@ -309,34 +370,71 @@ export class LLMClient {
309
370
  }
310
371
  }
311
372
  /** A single streaming attempt; see `streamAnthropic` for the retry policy. */
312
- async *anthropicAttempt(messages) {
373
+ async *anthropicAttempt(messages, tools, options) {
313
374
  const url = this.model.url.replace(/\/+$/, "") + "/messages";
314
375
  const headers = {
315
376
  "Content-Type": "application/json",
316
377
  "x-api-key": this.model.api_key,
317
378
  "anthropic-version": "2023-06-01",
318
379
  };
319
- let systemMsg;
380
+ const systemMessages = [];
320
381
  const chatMessages = [];
321
382
  for (const m of messages) {
322
- if (m.role === "system")
323
- systemMsg = m.content ?? "";
383
+ if (m.role === "system") {
384
+ if (m.content)
385
+ systemMessages.push(m.content);
386
+ continue;
387
+ }
388
+ let blocks;
389
+ const role = m.role === "assistant" ? "assistant" : "user";
390
+ if (m.role === "tool") {
391
+ blocks = [{ type: "tool_result", tool_use_id: m.tool_call_id, content: m.content ?? "" }];
392
+ }
393
+ else if (m.role === "assistant" && m.anthropic_content?.length) {
394
+ blocks = m.anthropic_content;
395
+ }
396
+ else {
397
+ blocks = m.content ? [{ type: "text", text: m.content }] : [];
398
+ for (const raw of m.tool_calls ?? []) {
399
+ const call = raw;
400
+ let input;
401
+ try {
402
+ input = JSON.parse(call.function.arguments);
403
+ }
404
+ catch {
405
+ input = { value: call.function.arguments };
406
+ }
407
+ blocks.push({ type: "tool_use", id: call.id, name: call.function.name, input });
408
+ }
409
+ }
410
+ if (!blocks.length)
411
+ continue;
412
+ const previous = chatMessages.at(-1);
413
+ if (previous?.role === role)
414
+ previous.content.push(...blocks);
324
415
  else
325
- chatMessages.push({ role: m.role, content: m.content ?? "" });
416
+ chatMessages.push({ role, content: [...blocks] });
326
417
  }
327
418
  const payload = {
328
419
  model: this.model.name,
329
420
  messages: chatMessages,
330
421
  max_tokens: 4096,
331
- temperature: this.temperature,
332
- stream: true,
422
+ ...(this.professional ? { output_config: { effort: this.intensity } } : { temperature: this.temperature }),
423
+ stream: this.model.supports_streaming !== false,
333
424
  };
334
- if (systemMsg)
335
- payload.system = systemMsg;
425
+ if (systemMessages.length)
426
+ payload.system = systemMessages.join("\n\n");
427
+ if (tools?.length)
428
+ payload.tools = tools.map((tool) => ({
429
+ name: tool.function.name,
430
+ description: tool.function.description,
431
+ input_schema: tool.function.parameters,
432
+ }));
336
433
  const resp = await postStream(url, {
337
434
  method: "POST",
338
435
  headers,
339
436
  body: JSON.stringify(payload),
437
+ signal: options.signal,
340
438
  });
341
439
  if (!resp.ok) {
342
440
  // Thrown rather than printed: a 502/503/504/429 is as transient as a
@@ -345,6 +443,21 @@ export class LLMClient {
345
443
  const body = await resp.text().catch(() => "");
346
444
  throw new HttpStatusError(resp.status, body.slice(0, 500));
347
445
  }
446
+ if (this.model.supports_streaming === false || resp.headers.get("content-type")?.includes("application/json")) {
447
+ const body = await resp.json();
448
+ if (!Array.isArray(body.content))
449
+ throw new Error("endpoint did not return a valid model completion");
450
+ this.captureAnthropicContent(body.content);
451
+ if (body.usage) {
452
+ const prompt = body.usage.input_tokens ?? 0;
453
+ const completion = body.usage.output_tokens ?? 0;
454
+ this.usage = { prompt, completion, total: prompt + completion };
455
+ }
456
+ for (const block of body.content)
457
+ if (block.type === "text" && typeof block.text === "string")
458
+ yield block.text;
459
+ return;
460
+ }
348
461
  if (!resp.body) {
349
462
  yield "\n[Error: empty response body]";
350
463
  return;
@@ -353,6 +466,8 @@ export class LLMClient {
353
466
  const decoder = new TextDecoder();
354
467
  let buf = "";
355
468
  let stopped = false;
469
+ const blocks = {};
470
+ const toolArguments = {};
356
471
  try {
357
472
  while (!stopped) {
358
473
  const { done, value } = await reader.read();
@@ -378,11 +493,42 @@ export class LLMClient {
378
493
  catch {
379
494
  continue;
380
495
  }
381
- const type = chunk.type;
496
+ const event = chunk;
497
+ const type = event.type;
498
+ if (type === "error")
499
+ throw new Error(event.error?.message ?? "Anthropic stream failed");
500
+ if (type === "message_start") {
501
+ const input = event.message?.usage?.input_tokens ?? 0;
502
+ const output = event.message?.usage?.output_tokens ?? 0;
503
+ this.usage = { prompt: input, completion: output, total: input + output };
504
+ }
505
+ else if (type === "message_delta" && event.usage) {
506
+ const prompt = this.usage?.prompt ?? 0;
507
+ const completion = event.usage.output_tokens ?? this.usage?.completion ?? 0;
508
+ this.usage = { prompt, completion, total: prompt + completion };
509
+ }
510
+ else if (type === "content_block_start") {
511
+ blocks[event.index ?? 0] = { ...event.content_block };
512
+ if (event.content_block?.type === "tool_use")
513
+ toolArguments[event.index ?? 0] = "";
514
+ if (typeof event.content_block?.text === "string" && event.content_block.text)
515
+ yield event.content_block.text;
516
+ }
382
517
  if (type === "content_block_delta") {
383
- const text = chunk.delta?.text;
384
- if (text)
385
- yield text;
518
+ const index = event.index ?? 0;
519
+ const block = blocks[index] ??= { type: "text", text: "" };
520
+ if (event.delta?.text) {
521
+ block.text = String(block.text ?? "") + event.delta.text;
522
+ yield event.delta.text;
523
+ }
524
+ if (event.delta?.partial_json)
525
+ toolArguments[index] = (toolArguments[index] ?? "") + event.delta.partial_json;
526
+ if (event.delta?.thinking) {
527
+ block.thinking = String(block.thinking ?? "") + event.delta.thinking;
528
+ this.reasoningContent += event.delta.thinking;
529
+ }
530
+ if (event.delta?.signature)
531
+ block.signature = String(block.signature ?? "") + event.delta.signature;
386
532
  }
387
533
  else if (type === "message_stop") {
388
534
  stopped = true;
@@ -399,5 +545,28 @@ export class LLMClient {
399
545
  // Already closed / errored; nothing left to release.
400
546
  }
401
547
  }
548
+ const content = Object.keys(blocks).map(Number).sort((a, b) => a - b).map((index) => {
549
+ const block = blocks[index];
550
+ if (block.type === "tool_use") {
551
+ const raw = toolArguments[index];
552
+ if (raw) {
553
+ try {
554
+ block.input = JSON.parse(raw);
555
+ }
556
+ catch {
557
+ block.input = { value: raw };
558
+ }
559
+ }
560
+ }
561
+ return block;
562
+ });
563
+ this.captureAnthropicContent(content);
564
+ }
565
+ captureAnthropicContent(blocks) {
566
+ this.anthropicContent = blocks;
567
+ this.reasoningContent = blocks.filter((block) => block.type === "thinking").map((block) => String(block.thinking ?? "")).join("");
568
+ this.toolCalls = blocks.filter((block) => block.type === "tool_use").map((block) => ({
569
+ id: String(block.id ?? ""), name: String(block.name ?? ""), arguments: JSON.stringify(block.input ?? {}),
570
+ }));
402
571
  }
403
572
  }
@@ -44,10 +44,43 @@ export async function runSubAgent(options) {
44
44
  ];
45
45
  let report = "";
46
46
  let calls = 0;
47
+ let lastPromptTokens = 0;
47
48
  const aborted = (rounds) => ({ report, aborted: true, exhausted: false, rounds, calls });
48
49
  for (let round = 1; round <= maxRounds; round++) {
49
50
  if (options.cancelled?.())
50
51
  return aborted(round - 1);
52
+ const capacity = options.contextWindow;
53
+ if (capacity && messages.length > 2 &&
54
+ Math.max(lastPromptTokens, Math.ceil(JSON.stringify(messages).length / 3)) >= capacity * 0.8) {
55
+ // Only split at an assistant boundary: every retained tool result must
56
+ // still have the assistant/tool_calls message that produced it.
57
+ const starts = messages.flatMap((message, index) => index >= 2 && message.role === "assistant" ? [index] : []);
58
+ let keep = starts.length > 1 ? starts.at(-1) : messages.length;
59
+ if (JSON.stringify(messages.slice(keep)).length > capacity)
60
+ keep = messages.length;
61
+ const head = messages.slice(2, keep);
62
+ if (head.length) {
63
+ options.onProgress?.("compressing context");
64
+ const compressor = options.createClient();
65
+ const transcript = JSON.stringify(head);
66
+ // A single tool can produce more text than the role can hold. Bound
67
+ // the compression request itself, retaining its beginning and end.
68
+ const budget = Math.max(256, Math.floor(capacity * 0.6));
69
+ const input = transcript.length > budget
70
+ ? `${transcript.slice(0, Math.floor(budget / 2))}\n[Earlier activity truncated for compression]\n${transcript.slice(-Math.floor(budget / 2))}`
71
+ : transcript;
72
+ const summary = await compressor.summarize(input, { cancelled: options.cancelled, onRetry: options.onRetry });
73
+ if (compressor.usage)
74
+ options.onUsage?.(compressor.usage);
75
+ if (options.cancelled?.())
76
+ return aborted(round - 1);
77
+ messages.splice(2, keep - 2, {
78
+ role: "assistant",
79
+ content: summary || "Earlier specialist activity was compressed. Inspect the working directory for any evidence needed to continue.",
80
+ });
81
+ lastPromptTokens = 0;
82
+ }
83
+ }
51
84
  options.onProgress?.("thinking");
52
85
  const client = options.createClient();
53
86
  let content = "";
@@ -59,8 +92,10 @@ export async function runSubAgent(options) {
59
92
  return aborted(round - 1);
60
93
  content += chunk;
61
94
  }
62
- if (client.usage)
95
+ if (client.usage) {
63
96
  options.onUsage?.(client.usage);
97
+ lastPromptTokens = client.usage.prompt;
98
+ }
64
99
  const toolCalls = client.toolCalls;
65
100
  if (content.trim())
66
101
  report = content.trim();
@@ -71,6 +106,7 @@ export async function runSubAgent(options) {
71
106
  // A tool-call turn must carry the thinking that produced it back to a
72
107
  // thinking-mode endpoint, or its next request is rejected.
73
108
  ...(client.reasoningContent ? { reasoning_content: client.reasoningContent } : {}),
109
+ ...(client.anthropicContent?.length ? { anthropic_content: client.anthropicContent } : {}),
74
110
  ...(toolCalls.length > 0
75
111
  ? {
76
112
  tool_calls: toolCalls.map((call) => ({
package/dist/llm/tools.js CHANGED
@@ -157,7 +157,9 @@ function runCommand(command, cwd) {
157
157
  resolve("[Error: empty command]");
158
158
  return;
159
159
  }
160
- exec(command, { cwd, timeout: 120000, maxBuffer: MAX_OUTPUT * 4 }, (error, stdout, stderr) => {
160
+ exec(command,
161
+ // A detached Windows service has no console for its tool shells to inherit.
162
+ { cwd, timeout: 120000, maxBuffer: MAX_OUTPUT * 4, windowsHide: true }, (error, stdout, stderr) => {
161
163
  const parts = [];
162
164
  if (stdout)
163
165
  parts.push(stdout);
@@ -0,0 +1,33 @@
1
+ import { loadAgentSession, safeAgentSessionId } from "./agents.js";
2
+ import { loadConfig, loadSession, normalizeApprovalMode, saveSession, } from "./config.js";
3
+ export class SessionMigrationError extends Error {
4
+ status;
5
+ constructor(status, message) {
6
+ super(message);
7
+ this.status = status;
8
+ }
9
+ }
10
+ /** Copy the readable conversation into a fresh native transcript, ready to resume. */
11
+ export function migrateAgentSession(agent, sourceId, cwd, selection, mode = "standard", proPreset) {
12
+ if (!safeAgentSessionId(sourceId))
13
+ throw new SessionMigrationError(400, "invalid session id");
14
+ const source = loadAgentSession(agent, sourceId, cwd);
15
+ if (!source)
16
+ throw new SessionMigrationError(404, "session not found in this working directory");
17
+ // Preserve the roles and text exposed by the history viewer. Resumed native
18
+ // sessions supply their own system prompt and model/tool configuration.
19
+ const messages = source.messages.map(({ role, content }) => ({ role, content }));
20
+ if (!messages.length)
21
+ throw new SessionMigrationError(400, "this session has no messages to migrate");
22
+ let id;
23
+ try {
24
+ id = saveSession(messages, selection.model, selection.provider, selection.intensity, source.cwd, undefined, { prompt: 0, completion: 0 }, undefined, normalizeApprovalMode(loadConfig(mode).approval_mode), undefined, mode, proPreset);
25
+ }
26
+ catch (error) {
27
+ throw new SessionMigrationError(500, error instanceof Error ? error.message : String(error));
28
+ }
29
+ const saved = loadSession(id, mode);
30
+ if (!saved)
31
+ throw new SessionMigrationError(500, "could not read the migrated conversation");
32
+ return saved;
33
+ }
@@ -0,0 +1,104 @@
1
+ import { LLMClient } from "../llm/client.js";
2
+ import { buildSubAgentTask, formatSubAgentResult, runSubAgent } from "../llm/subagent.js";
3
+ import { DELEGATE_TOOL, getSubAgentToolDefinitions } from "../llm/tools.js";
4
+ import { ACTIVE_PRO_ROLES, PRO_ROLES, loadProPrompt } from "./config.js";
5
+ export const PRO_AGENT_TOOL = "call_pro_agent";
6
+ const CALL_AGENT = {
7
+ type: "function",
8
+ function: {
9
+ name: PRO_AGENT_TOOL,
10
+ description: "Ask a specialist agent to perform a self-contained task in its own context, using the same working directory and approval gate. " +
11
+ "Use thinking_model for reasoning, front_output_model for UI, backend_output_model for other code, modeling_model for modeling, " +
12
+ "plan_model for planning, confirm_model for read-only review, or main_model for coordination. Specialists can call other specialists within the configured recursion depth.",
13
+ parameters: {
14
+ type: "object",
15
+ properties: {
16
+ role: { type: "string", enum: [...ACTIVE_PRO_ROLES] },
17
+ task: { type: "string", description: "Complete task, expected deliverables and acceptance criteria." },
18
+ context: { type: "string", description: "Relevant evidence, constraints and decisions; the agent cannot see the parent's conversation." },
19
+ reason: { type: "string", description: "Why this specialist is needed for the user's request." },
20
+ },
21
+ required: ["role", "task", "reason"],
22
+ },
23
+ },
24
+ };
25
+ /** All Pro agents can call the same specialists; the runtime enforces depth. */
26
+ export function getProToolDefinitions() {
27
+ return [...getSubAgentToolDefinitions(), CALL_AGENT];
28
+ }
29
+ /** A resolved model team belongs to one task; substitutions never touch disk. */
30
+ export class ProAgentTeam {
31
+ options;
32
+ maxDepth;
33
+ constructor(options) {
34
+ this.options = options;
35
+ this.maxDepth = Number.isSafeInteger(options.maxDepth) && options.maxDepth > 0 ? options.maxDepth : 10;
36
+ }
37
+ client(role) {
38
+ const selection = this.options.models[role];
39
+ if (!selection)
40
+ throw new Error(`Pro agent ${role} is unavailable`);
41
+ return new LLMClient(selection.model, selection.provider, selection.intensity, { professional: true });
42
+ }
43
+ async call(role, task, context = "", depth = 1, readOnly = false) {
44
+ if (!PRO_ROLES.includes(role))
45
+ return "[Error: unknown Pro agent role]";
46
+ if (role === "genimage_model")
47
+ return "[Error: image generation is reserved and is not enabled]";
48
+ if (this.options.cancelled())
49
+ return "[Pro agent stopped: the turn was paused or replaced.]";
50
+ if (!Number.isSafeInteger(depth) || depth < 0 || depth > this.maxDepth) {
51
+ return `[Error: Pro agent recursion limit (${this.maxDepth}) reached. Complete the work without further delegation.]`;
52
+ }
53
+ if (typeof task !== "string" || !task.trim())
54
+ return "[Error: a Pro agent needs a nonempty task]";
55
+ const selection = this.options.models[role];
56
+ if (!selection)
57
+ return `[Error: Pro agent ${role} is unavailable]`;
58
+ // Reviewers are always read-only, including when consulted as specialists.
59
+ const onlyRead = readOnly || role === "confirm_model";
60
+ const tools = getProToolDefinitions().filter((tool) => !onlyRead ||
61
+ ["read_file", "list_files", PRO_AGENT_TOOL].includes(tool.function.name));
62
+ const names = new Set(tools.map((tool) => tool.function.name));
63
+ const label = `${role} · ${selection.model.name} · ${depth}/${this.maxDepth}`;
64
+ try {
65
+ const result = await runSubAgent({
66
+ createClient: () => this.client(role),
67
+ system: `${loadProPrompt(role)}\n\nDelegation depth: ${depth}/${this.maxDepth}. ` +
68
+ (onlyRead ? "This consultation is read-only. Do not modify files or execute commands, including through another agent." :
69
+ "Use the shared approval gate for every tool call. Return a self-contained report with changes, evidence and unresolved work."),
70
+ task: buildSubAgentTask({ task, context, cwd: this.options.cwd }),
71
+ tools,
72
+ contextWindow: selection.context_window,
73
+ approve: (name, args) => {
74
+ if (!names.has(name) && name !== DELEGATE_TOOL)
75
+ return Promise.resolve({
76
+ approved: false, source: "input", reason: "This tool is not available to this Pro agent",
77
+ });
78
+ return this.options.approve(name, args);
79
+ },
80
+ execute: async (name, args) => {
81
+ if (name === PRO_AGENT_TOOL || name === DELEGATE_TOOL) {
82
+ const nextRole = typeof args.role === "string" ? args.role : "backend_output_model";
83
+ const nextTask = typeof args.task === "string" ? args.task : "";
84
+ const nextContext = typeof args.context === "string" ? args.context : "";
85
+ const report = await this.call(nextRole, nextTask, nextContext, depth + 1, onlyRead);
86
+ this.options.onProgress?.(label);
87
+ return report;
88
+ }
89
+ if (!names.has(name))
90
+ return "[Error: tool is unavailable to this Pro agent]";
91
+ return this.options.execute(name, args);
92
+ },
93
+ cancelled: this.options.cancelled,
94
+ onUsage: this.options.onUsage,
95
+ onRetry: this.options.onRetry,
96
+ onProgress: (activity) => this.options.onProgress?.(`${label} · ${activity}`),
97
+ });
98
+ return formatSubAgentResult(result);
99
+ }
100
+ finally {
101
+ this.options.onProgress?.(null);
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,88 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { PRO_DIR, loadConfig } from "../config.js";
5
+ /** Provider URLs and credentials stay in the shared, ordinary model catalogue. */
6
+ export const PRO_MODEL_FILE = path.join(PRO_DIR, "model.json");
7
+ const ASSET_DIR = fileURLToPath(new URL("../../assets/pro/", import.meta.url));
8
+ export const PRO_ROLES = [
9
+ "main_model", "thinking_model", "front_output_model", "backend_output_model",
10
+ "confirm_model", "genimage_model", "plan_model", "modeling_model",
11
+ ];
12
+ export const ACTIVE_PRO_ROLES = PRO_ROLES.filter((role) => role !== "genimage_model");
13
+ export const PRO_ROLE_LABELS = {
14
+ main_model: "Main", thinking_model: "Reasoning", front_output_model: "Frontend",
15
+ backend_output_model: "Backend", confirm_model: "Approval", genimage_model: "Image generation",
16
+ plan_model: "Planning", modeling_model: "Modeling",
17
+ };
18
+ /** Copy the bundled template byte-for-byte, exclusively on first use. */
19
+ export function ensureProModels() {
20
+ fs.mkdirSync(PRO_DIR, { recursive: true });
21
+ try {
22
+ fs.copyFileSync(path.join(ASSET_DIR, "model-template.json"), PRO_MODEL_FILE, fs.constants.COPYFILE_EXCL);
23
+ }
24
+ catch (error) {
25
+ if (error.code !== "EEXIST")
26
+ throw error;
27
+ }
28
+ }
29
+ export function loadProModels() {
30
+ ensureProModels();
31
+ let value;
32
+ try {
33
+ value = JSON.parse(fs.readFileSync(PRO_MODEL_FILE, "utf8"));
34
+ }
35
+ catch {
36
+ throw new Error("Pro model.json is not valid JSON; fix ~/.neutrino/pro/model.json");
37
+ }
38
+ if (!value || typeof value !== "object" || !Array.isArray(value.tier) ||
39
+ value.tier.length === 0) {
40
+ throw new Error("Pro model.json must contain a nonempty tier array");
41
+ }
42
+ const names = new Set();
43
+ for (const preset of value.tier) {
44
+ if (!preset || typeof preset !== "object" || typeof preset.name !== "string" || !preset.name.trim() ||
45
+ names.has(preset.name) || typeof preset.chinese_name !== "string" || !Array.isArray(preset["model-setting"])) {
46
+ throw new Error("Each Pro preset needs a unique name, chinese_name and model-setting array");
47
+ }
48
+ names.add(preset.name);
49
+ const roles = new Set();
50
+ for (const setting of preset["model-setting"]) {
51
+ if (!setting || typeof setting !== "object" || !PRO_ROLES.includes(setting.type) || roles.has(setting.type) ||
52
+ typeof setting.provider !== "string" || (setting.model !== null && typeof setting.model !== "string") ||
53
+ typeof setting.intensity !== "string" ||
54
+ !Number.isSafeInteger(setting.context_window) || setting.context_window < 1) {
55
+ throw new Error(`Invalid model-setting in Pro preset ${preset.name}`);
56
+ }
57
+ roles.add(setting.type);
58
+ }
59
+ }
60
+ return value;
61
+ }
62
+ export function requireProPreset(value) {
63
+ if (typeof value !== "string" || !loadProModels().tier.some((preset) => preset.name === value)) {
64
+ throw new Error("Unknown Pro preset");
65
+ }
66
+ return value;
67
+ }
68
+ export function defaultProPreset() {
69
+ const presets = loadProModels().tier;
70
+ const preferred = loadConfig("pro").pro_preset;
71
+ return presets.find((preset) => preset.name === preferred)?.name ??
72
+ presets.find((preset) => preset.name === "medium")?.name ??
73
+ presets.find((preset) => preset.name !== "custom")?.name ?? presets[0].name;
74
+ }
75
+ export const DEFAULT_PRO_RECURSION_DEPTH = 10;
76
+ export const MAX_PRO_RECURSION_DEPTH = 100;
77
+ export function proMaxRecursionDepth() {
78
+ const configured = loadConfig("pro").pro_max_recursion_depth;
79
+ return typeof configured === "number" && Number.isSafeInteger(configured) &&
80
+ configured >= 1 && configured <= MAX_PRO_RECURSION_DEPTH ? configured : DEFAULT_PRO_RECURSION_DEPTH;
81
+ }
82
+ /** Every agent prompt lives in the requested assets/pro directory. */
83
+ export function loadProPrompt(role) {
84
+ if (!PRO_ROLES.includes(role))
85
+ throw new Error("Unknown Pro role");
86
+ return fs.readFileSync(path.join(ASSET_DIR, "common.txt"), "utf8") + "\n\n" +
87
+ fs.readFileSync(path.join(ASSET_DIR, `${role}.txt`), "utf8");
88
+ }