@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
@@ -11,9 +11,12 @@
11
11
  * (snapshot, stream, pause, resume, message) work unchanged.
12
12
  */
13
13
  import { randomUUID } from "node:crypto";
14
- import { findModel, contextCompressionThreshold, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadModels, loadPrompt, saveSession, } from "../config.js";
14
+ import { findModel, contextCompressionThreshold, intensityForModel, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadModels, loadPrompt, saveSession, } from "../config.js";
15
15
  import { COMPACT_KEEP, planCompaction, transcriptFor, modelHistory, normalizeContext, } from "../compact.js";
16
16
  import { LLMClient, describeError } from "../llm/client.js";
17
+ import { defaultProPreset, loadProModels, loadProPrompt, proMaxRecursionDepth, requireProPreset } from "../pro/config.js";
18
+ import { resolveProTeam, ProPreflightError } from "../pro/preflight.js";
19
+ import { ProAgentTeam, getProToolDefinitions } from "../pro/agents.js";
17
20
  import { advancePlan, buildPlannerInput, lastRequestText, needsPlan, parsePlan, planMessage, planTranscript, renderStepDirective, resumePlan, stopPlan, } from "../llm/plan.js";
18
21
  import { DELEGATE_TOOL, executeTool, getSubAgentToolDefinitions, getToolDefinitions } from "../llm/tools.js";
19
22
  import { buildSubAgentTask, formatSubAgentResult, runSubAgent as runSubAgentTask, } from "../llm/subagent.js";
@@ -44,7 +47,7 @@ export function newSessionId(now = new Date()) {
44
47
  * written to disk until the first message, so an abandoned new-chat dialog
45
48
  * cannot litter the history with empty transcripts.
46
49
  */
47
- export function createWebSession(cwd, selection, approvalMode) {
50
+ export function createWebSession(cwd, selection, approvalMode, mode = "standard", proPreset) {
48
51
  const now = new Date();
49
52
  const source = {
50
53
  id: newSessionId(now),
@@ -57,9 +60,23 @@ export function createWebSession(cwd, selection, approvalMode) {
57
60
  title: "",
58
61
  messages: [],
59
62
  tokens: { prompt: 0, completion: 0 },
60
- approval_mode: normalizeApprovalMode(approvalMode ?? loadConfig().approval_mode),
63
+ approval_mode: normalizeApprovalMode(approvalMode ?? loadConfig(mode).approval_mode),
64
+ ...(mode === "pro" ? { pro_preset: requireProPreset(proPreset ?? defaultProPreset()) } : {}),
65
+ };
66
+ return new WebSession(source, selection.model, mode);
67
+ }
68
+ /** A preset may need fallback; defer model credentials/availability to preflight. */
69
+ export function proInitialSelection(preset = defaultProPreset()) {
70
+ const tier = loadProModels().tier.find((entry) => entry.name === requireProPreset(preset));
71
+ const setting = tier["model-setting"].find((entry) => entry.type === "main_model");
72
+ const provider = setting?.provider ?? "";
73
+ const name = setting?.model ?? "";
74
+ const model = loadModels().providers.find((entry) => entry.name === provider)?.models.find((entry) => entry.name === name);
75
+ return {
76
+ provider,
77
+ model: model ? { ...model } : { name, url: "", api_key: "", supports_streaming: true },
78
+ intensity: setting?.intensity || "high",
61
79
  };
62
- return new WebSession(source, selection.model);
63
80
  }
64
81
  function parseArguments(raw) {
65
82
  const text = raw?.trim() ?? "";
@@ -93,15 +110,27 @@ export function resolveModel(modelName, providerName) {
93
110
  return { ok: true, model };
94
111
  }
95
112
  /** Build a web session from a saved transcript, or explain why we cannot. */
96
- export function resumeSession(source, selection) {
113
+ export function resumeSession(source, selection, mode = "standard") {
114
+ if (mode === "pro") {
115
+ try {
116
+ const initial = proInitialSelection(source.pro_preset ?? defaultProPreset());
117
+ return { ok: true, session: new WebSession({ ...source, provider: initial.provider,
118
+ model: initial.model.name, intensity: initial.intensity }, initial.model, mode) };
119
+ }
120
+ catch (error) {
121
+ return { ok: false, error: describeError(error) };
122
+ }
123
+ }
97
124
  const updated = selection ? { ...source, ...selection } : source;
98
125
  const resolved = resolveModel(updated.model, updated.provider);
99
126
  if (!resolved.ok)
100
127
  return resolved;
101
- return { ok: true, session: new WebSession(updated, resolved.model) };
128
+ return { ok: true, session: new WebSession(updated, resolved.model, mode) };
102
129
  }
103
130
  export class WebSession {
104
131
  key = null;
132
+ /** Workspace this conversation belongs to; keeps Pro and standard separate. */
133
+ mode;
105
134
  model;
106
135
  provider;
107
136
  intensity;
@@ -120,6 +149,10 @@ export class WebSession {
120
149
  plan = null;
121
150
  /** True while the planner agent is writing the steps. */
122
151
  planning = false;
152
+ proPreset = null;
153
+ proTeam = null;
154
+ proState;
155
+ proAbort = null;
123
156
  /** What the sub-agent is doing, or null when none is running. */
124
157
  subagent = null;
125
158
  saveError = null;
@@ -138,11 +171,16 @@ export class WebSession {
138
171
  pendingApproval = null;
139
172
  /** Reviewer count while an automated review runs, null otherwise. */
140
173
  reviewing = null;
141
- constructor(source, model) {
174
+ constructor(source, model, mode = "standard") {
175
+ this.mode = mode;
176
+ if (mode === "pro")
177
+ this.proPreset = requireProPreset(source.pro_preset ?? defaultProPreset());
142
178
  this.model = model;
143
- this.approvalMode = normalizeApprovalMode(source.approval_mode ?? loadConfig().approval_mode);
179
+ this.approvalMode = normalizeApprovalMode(source.approval_mode ?? loadConfig(this.mode).approval_mode);
144
180
  this.provider = source.provider;
145
- this.intensity = source.intensity;
181
+ // A transcript saved before the model's limits existed (or before the model
182
+ // itself was narrowed) must not replay an unsupported sampling level.
183
+ this.intensity = mode === "pro" ? source.intensity : intensityForModel(model, source.intensity, loadConfig(this.mode).default_intensity);
146
184
  this.dir = source.cwd;
147
185
  this.sessionId = source.id;
148
186
  this.messages = source.messages.slice();
@@ -167,6 +205,7 @@ export class WebSession {
167
205
  if (this.disposed)
168
206
  return;
169
207
  this.disposed = true;
208
+ this.proAbort?.abort();
170
209
  this.turnToken++;
171
210
  this.busy = false;
172
211
  if (this.pendingApproval) {
@@ -183,6 +222,8 @@ export class WebSession {
183
222
  return this.dir;
184
223
  }
185
224
  liveConfigure(selection) {
225
+ if (this.mode === "pro")
226
+ return { ok: false, error: "choose a Pro preset to change professional models" };
186
227
  if (this.disposed)
187
228
  return { ok: false, error: "this session has ended" };
188
229
  if (this.busy || this.pendingApproval) {
@@ -190,13 +231,37 @@ export class WebSession {
190
231
  }
191
232
  this.model = { ...selection.model };
192
233
  this.provider = selection.provider;
193
- this.intensity = selection.intensity;
234
+ this.intensity = intensityForModel(this.model, selection.intensity, loadConfig(this.mode).default_intensity);
194
235
  this.save();
195
236
  this.publishLive();
196
237
  return { ok: true };
197
238
  }
239
+ liveSetProPreset(preset) {
240
+ if (this.mode !== "pro" || this.disposed)
241
+ return { ok: false, error: "this is not an active Pro session" };
242
+ if (this.busy || this.pendingApproval)
243
+ return { ok: false, error: "pause the current turn before changing presets" };
244
+ try {
245
+ this.proPreset = requireProPreset(preset);
246
+ const initial = proInitialSelection(this.proPreset);
247
+ this.model = initial.model;
248
+ this.provider = initial.provider;
249
+ this.intensity = initial.intensity;
250
+ this.proTeam = null;
251
+ this.proState = undefined;
252
+ this.save();
253
+ this.publishLive();
254
+ return { ok: true };
255
+ }
256
+ catch (error) {
257
+ return { ok: false, error: describeError(error) };
258
+ }
259
+ }
198
260
  liveMeta() {
199
261
  return {
262
+ ...(this.mode === "pro" ? { pro: this.proState ?? {
263
+ preset: this.proPreset, checking: false, checkId: "", models: [], fallbacks: [],
264
+ } } : {}),
200
265
  sessionId: this.sessionId,
201
266
  title: this.title(),
202
267
  provider: this.provider,
@@ -251,6 +316,7 @@ export class WebSession {
251
316
  if (this.paused || (!this.busy && !this.queued))
252
317
  return;
253
318
  this.paused = true;
319
+ this.proAbort?.abort();
254
320
  this.turnToken++;
255
321
  // A queued decision would otherwise keep the promise (and the gate) alive.
256
322
  if (this.pendingApproval) {
@@ -261,6 +327,13 @@ export class WebSession {
261
327
  this.busy = false;
262
328
  this.retainStreamingContent();
263
329
  this.subagent = null;
330
+ if (this.proState)
331
+ this.proState.checking = false;
332
+ if (this.mode === "pro") {
333
+ this.reviewing = null;
334
+ this.retrying = null;
335
+ this.planning = false;
336
+ }
264
337
  stopPlan(this.plan);
265
338
  this.save();
266
339
  this.publishLive();
@@ -307,6 +380,9 @@ export class WebSession {
307
380
  return;
308
381
  if (!this.checkpoint.flush())
309
382
  return;
383
+ this.proAbort?.abort();
384
+ this.proTeam = null;
385
+ this.proState = undefined;
310
386
  this.turnToken++;
311
387
  if (this.pendingApproval) {
312
388
  const pending = this.pendingApproval;
@@ -374,6 +450,8 @@ export class WebSession {
374
450
  * pass, exactly as it did before planning existed.
375
451
  */
376
452
  async runTurn(token, turnMessages, approvalContext) {
453
+ if (this.mode === "pro")
454
+ return this.runProTurn(token, turnMessages, approvalContext);
377
455
  await this.autoCompress(token);
378
456
  if (token !== this.turnToken || this.paused)
379
457
  return;
@@ -405,6 +483,127 @@ export class WebSession {
405
483
  }
406
484
  this.onTurnFinished();
407
485
  }
486
+ /** Freeze one resolved team per task, plan first, then give the whole brief to main. */
487
+ async runProTurn(token, turnMessages, approvalContext) {
488
+ const cancelled = () => token !== this.turnToken || this.paused || this.disposed;
489
+ const controller = new AbortController();
490
+ this.proAbort = controller;
491
+ this.proTeam = null;
492
+ this.plan = null;
493
+ this.proState = { preset: this.proPreset, checking: true, checkId: randomUUID(), models: [], fallbacks: [] };
494
+ this.publishLive();
495
+ try {
496
+ const resolved = await resolveProTeam(this.proPreset, {
497
+ signal: controller.signal,
498
+ onUsage: (usage) => {
499
+ if (!cancelled()) {
500
+ this.tokens.prompt += usage.prompt;
501
+ this.tokens.completion += usage.completion;
502
+ }
503
+ },
504
+ });
505
+ if (cancelled())
506
+ return;
507
+ this.proState.models = Object.values(resolved.models).map((entry) => ({
508
+ role: entry.role, provider: entry.provider, model: entry.model.name, intensity: entry.intensity, tier: entry.tier,
509
+ }));
510
+ this.proState.fallbacks = resolved.fallbacks;
511
+ this.proState.checking = false;
512
+ const team = new ProAgentTeam({
513
+ models: resolved.models, maxDepth: proMaxRecursionDepth(), cwd: this.dir, cancelled,
514
+ approve: (name, args) => this.requestApproval(name, args, {
515
+ ...approvalContext, context: captureApprovalContext(this.turnView(turnMessages)).context,
516
+ }),
517
+ execute: (name, args) => executeTool(name, args, this.dir),
518
+ onProgress: (label) => { if (!cancelled()) {
519
+ this.subagent = label;
520
+ this.publishLive();
521
+ } },
522
+ onUsage: (usage) => {
523
+ if (!cancelled()) {
524
+ this.tokens.prompt += usage.prompt;
525
+ this.tokens.completion += usage.completion;
526
+ }
527
+ },
528
+ onRetry: (notice) => { if (!cancelled()) {
529
+ this.retrying = notice;
530
+ this.publishLive();
531
+ } },
532
+ });
533
+ this.proTeam = team;
534
+ const main = resolved.models.main_model;
535
+ this.model = main.model;
536
+ this.provider = main.provider;
537
+ this.intensity = main.intensity;
538
+ this.publishLive();
539
+ this.planning = true;
540
+ this.publishLive();
541
+ const planning = await team.call("plan_model", buildPlannerInput({
542
+ request: approvalContext.prompt, cwd: this.dir, transcript: planTranscript(turnMessages),
543
+ }), "Plan the task before execution. Do not modify files or execute commands.", 0, true);
544
+ if (cancelled())
545
+ return;
546
+ this.planning = false;
547
+ this.subagent = null;
548
+ this.retrying = null;
549
+ if (!planning.trim() || /\[Error:|\[Sub-agent .*without.*report/.test(planning)) {
550
+ throw new Error(`Pro planning failed: ${planning}`);
551
+ }
552
+ this.plan = parsePlan(planning);
553
+ if (!this.plan) {
554
+ // Standard planning skips single actions; Pro plans every task.
555
+ try {
556
+ const single = JSON.parse(planning.trim().replace(/^```(?:json)?\s*|\s*```$/g, ""));
557
+ if (single.steps?.length === 1 && typeof single.steps[0].title === "string" && single.steps[0].title.trim()) {
558
+ this.plan = { goal: String(single.goal ?? "").slice(0, 240), steps: [{
559
+ title: single.steps[0].title.slice(0, 160), detail: String(single.steps[0].detail ?? "").slice(0, 400), status: "pending",
560
+ }] };
561
+ }
562
+ }
563
+ catch { /* A useful prose plan can still guide the main agent. */ }
564
+ }
565
+ await this.autoCompress(token);
566
+ if (cancelled())
567
+ return;
568
+ this.messages.push({ role: "assistant", content: this.plan ? planMessage(this.plan) : planning });
569
+ this.save();
570
+ this.publishLive();
571
+ const directive = `The planning agent produced this plan for the current user request:\n${planning}\n\n` +
572
+ "Execute the complete task, using the appropriate professional agents for reasoning, frontend, backend and modeling work. Verify the result and report the outcome.";
573
+ if (this.plan?.steps.length)
574
+ this.setStep(0, "active");
575
+ const finished = await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages), directive), token, approvalContext);
576
+ if (cancelled())
577
+ return;
578
+ if (this.plan) {
579
+ if (finished)
580
+ for (let i = 0; i < this.plan.steps.length; i++)
581
+ this.setStep(i, "done");
582
+ else
583
+ this.setStep(0, "failed");
584
+ }
585
+ }
586
+ catch (error) {
587
+ if (cancelled())
588
+ return;
589
+ this.proState.checking = false;
590
+ this.proState.error = describeError(error);
591
+ if (error instanceof ProPreflightError)
592
+ this.proState.fallbacks = error.fallbacks;
593
+ throw error;
594
+ }
595
+ finally {
596
+ if (!cancelled()) {
597
+ this.planning = false;
598
+ this.subagent = null;
599
+ this.retrying = null;
600
+ this.proState.checking = false;
601
+ this.publishLive();
602
+ }
603
+ }
604
+ if (!cancelled())
605
+ this.onTurnFinished();
606
+ }
408
607
  /**
409
608
  * The transcript this turn may see: the messages it started with plus what it
410
609
  * added since. A user message queued while the turn runs belongs to the next
@@ -430,7 +629,7 @@ export class WebSession {
430
629
  * a plan must improve a turn, never be a new way for one to fail.
431
630
  */
432
631
  async makePlan(token) {
433
- if (!planFirstEnabled())
632
+ if (!planFirstEnabled(this.mode))
434
633
  return null;
435
634
  const request = lastRequestText(this.messages);
436
635
  if (!request.trim())
@@ -488,8 +687,9 @@ export class WebSession {
488
687
  buildApiMessages(messages = this.messages, directive = "") {
489
688
  // A step directive extends the system prompt: the plan is an instruction, not
490
689
  // something the user just said.
690
+ const prompt = this.mode === "pro" ? loadProPrompt("main_model") : loadPrompt();
491
691
  const api = [
492
- { role: "system", content: directive ? `${loadPrompt()}\n\n${directive}` : loadPrompt() },
692
+ { role: "system", content: directive ? `${prompt}\n\n${directive}` : prompt },
493
693
  ];
494
694
  for (const m of modelHistory(messages, this.context)) {
495
695
  if (m.role === "user" || m.role === "assistant") {
@@ -507,12 +707,13 @@ export class WebSession {
507
707
  * the caller owns the turn's end because a planned turn runs this per step.
508
708
  */
509
709
  async agentLoop(apiMessages, token, approvalContext) {
510
- const tools = getToolDefinitions();
710
+ const tools = this.mode === "pro" ? getProToolDefinitions() : getToolDefinitions();
711
+ const team = this.proTeam;
511
712
  try {
512
713
  for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
513
714
  if (token !== this.turnToken || this.paused)
514
715
  return false;
515
- const client = new LLMClient(this.model, this.provider, this.intensity);
716
+ const client = team ? team.client("main_model") : new LLMClient(this.model, this.provider, this.intensity);
516
717
  let text = "";
517
718
  this.streamingContent = "";
518
719
  this.publishLive();
@@ -552,6 +753,7 @@ export class WebSession {
552
753
  role: "assistant",
553
754
  content: text,
554
755
  reasoning_content: client.reasoningContent ?? "",
756
+ ...(client.anthropicContent.length ? { anthropic_content: client.anthropicContent } : {}),
555
757
  ...(toolCalls.length > 0
556
758
  ? {
557
759
  tool_calls: toolCalls.map((call) => ({
@@ -565,8 +767,13 @@ export class WebSession {
565
767
  }
566
768
  this.save();
567
769
  this.publishLive();
568
- if (toolCalls.length === 0)
569
- break;
770
+ if (toolCalls.length === 0) {
771
+ if (this.mode === "pro" && !text.trim()) {
772
+ this.messages.push({ role: "assistant", content: "[Error: Pro main model returned an empty completion.]" });
773
+ return false;
774
+ }
775
+ return !/\[Error:/.test(text);
776
+ }
570
777
  for (const call of toolCalls) {
571
778
  if (token !== this.turnToken || this.paused)
572
779
  return false;
@@ -618,7 +825,9 @@ export class WebSession {
618
825
  }
619
826
  this.save();
620
827
  }
621
- return true;
828
+ if (this.mode === "pro")
829
+ this.messages.push({ role: "assistant", content: "[Error: Pro main agent reached its tool round limit before completion.]" });
830
+ return false;
622
831
  }
623
832
  catch (err) {
624
833
  if (token !== this.turnToken)
@@ -652,15 +861,28 @@ export class WebSession {
652
861
  const risk = assessToolCall(tool, args, this.dir);
653
862
  if (!risk)
654
863
  return { approved: true, source: "prompt", reason: "routine call" };
864
+ const approvalToken = this.turnToken;
865
+ const team = this.proTeam;
655
866
  const gate = new ApprovalGate({
656
867
  askHuman: () => new Promise((resolve) => {
868
+ if (this.mode === "pro" && (approvalToken !== this.turnToken || this.paused || this.disposed)) {
869
+ resolve(false);
870
+ return;
871
+ }
657
872
  this.pendingApproval = { tool, args, risk, resolve };
658
873
  this.publishLive();
659
874
  }),
660
875
  reviewer: () => this.reviewerModel(),
876
+ ...(this.mode === "pro" && team ? {
877
+ createReviewerClient: () => team.client("confirm_model"),
878
+ reviewerSystemPrompt: () => loadProPrompt("confirm_model"),
879
+ cancelled: () => approvalToken !== this.turnToken || this.paused || this.disposed,
880
+ } : {}),
661
881
  autoApprovalEnabled: () => this.approvalMode === "auto",
662
- extremeAutoApprovalEnabled: () => loadConfig().auto_approve_extreme_risk === true,
882
+ extremeAutoApprovalEnabled: () => loadConfig(this.mode).auto_approve_extreme_risk === true,
663
883
  onReview: (info) => {
884
+ if (this.mode === "pro" && (approvalToken !== this.turnToken || this.paused || this.disposed))
885
+ return;
664
886
  this.reviewing = info
665
887
  ? {
666
888
  total: info.total, round: info.round ?? 1, rounds: info.rounds ?? 1,
@@ -674,7 +896,7 @@ export class WebSession {
674
896
  }
675
897
  /** Reviewer model: `approval_model` when set, otherwise the chat model. */
676
898
  reviewerModel() {
677
- const name = String(loadConfig().approval_model ?? "").trim();
899
+ const name = String(loadConfig(this.mode).approval_model ?? "").trim();
678
900
  const found = name ? findModel(name) : null;
679
901
  if (found) {
680
902
  const usable = resolveModel(found.model.name, found.provider);
@@ -691,6 +913,13 @@ export class WebSession {
691
913
  * approval gate and abort signal live.
692
914
  */
693
915
  async runTool(name, args, toolMsg, token, apiMessages, approvalContext) {
916
+ if (this.mode === "pro" && (name === "call_pro_agent" || name === DELEGATE_TOOL)) {
917
+ const team = this.proTeam;
918
+ if (!team)
919
+ return "[Error: Pro models have not passed preflight]";
920
+ return team.call(String(args.role ?? "backend_output_model"), String(args.task ?? ""), `${typeof args.context === "string" ? args.context : ""}\n\nCurrent user request: ${approvalContext.prompt}\n` +
921
+ captureApprovalContext(apiMessages).context, 1);
922
+ }
694
923
  if (name !== DELEGATE_TOOL)
695
924
  return executeTool(name, args, this.dir);
696
925
  return this.delegate(args, toolMsg, token, apiMessages, approvalContext);
@@ -738,7 +967,14 @@ export class WebSession {
738
967
  }
739
968
  // ---- compression -----------------------------------------------------
740
969
  compressThreshold() {
741
- return contextCompressionThreshold(this.provider, this.model);
970
+ if (this.mode === "pro") {
971
+ const setting = this.proState?.models.find((entry) => entry.role === "main_model");
972
+ const tier = loadProModels().tier.find((entry) => entry.name === setting?.tier);
973
+ const capacity = tier?.["model-setting"].find((entry) => entry.type === "main_model")?.context_window;
974
+ if (capacity)
975
+ return Math.max(1, Math.floor(capacity * 0.8));
976
+ }
977
+ return contextCompressionThreshold(this.provider, this.model, this.mode);
742
978
  }
743
979
  async autoCompress(token) {
744
980
  const threshold = this.compressThreshold();
@@ -760,7 +996,7 @@ export class WebSession {
760
996
  this.publishLive();
761
997
  let summary = "";
762
998
  try {
763
- summary = await new LLMClient(this.model, this.provider, this.intensity).summarize(transcript, {
999
+ summary = await (this.proTeam?.client("main_model") ?? new LLMClient(this.model, this.provider, this.intensity)).summarize(transcript, {
764
1000
  cancelled: () => token !== this.turnToken || this.paused,
765
1001
  });
766
1002
  }
@@ -813,7 +1049,7 @@ export class WebSession {
813
1049
  if (chat.length === 0)
814
1050
  return true;
815
1051
  try {
816
- this.sessionId = saveSession(chat, this.model.name, this.provider, this.intensity, this.dir, this.sessionId, this.tokens, this.context, this.approvalMode, this.plan);
1052
+ this.sessionId = saveSession(chat, this.model.name, this.provider, this.intensity, this.dir, this.sessionId, this.tokens, this.context, this.approvalMode, this.plan, this.mode, this.proPreset ?? undefined);
817
1053
  this.saveError = null;
818
1054
  return true;
819
1055
  }
@@ -1,28 +1,58 @@
1
- import { DEFAULT_CONFIG, INTENSITY_LEVELS, WEB_LANGUAGES, contextWindow, isContextWindow, loadConfig, loadModels, normalizeApprovalMode, normalizeWebLanguage, resolveModelSelection, saveConfig, saveModels, } from "../config.js";
1
+ import { DEFAULT_CONFIG, INTENSITY_LEVELS, WEB_LANGUAGES, contextWindow, intensityForModel, isContextWindow, loadConfig, loadModels, modelContextWindow, modelContextWindowKey, modelIntensities, normalizeApprovalMode, normalizeIntensity, normalizeWebLanguage, resolveModelSelection, saveConfig, saveModels, } from "../config.js";
2
2
  import { hashWebPassword, validateWebPassword } from "./auth.js";
3
- /** Public settings use a whitelist: API keys and cached credentials stay on disk. */
4
- export function getWebSettings() {
5
- const config = { ...DEFAULT_CONFIG, ...loadConfig() };
6
- const providers = loadModels().providers.map((provider) => ({
3
+ import { defaultProPreset, loadProModels, proMaxRecursionDepth, requireProPreset } from "../pro/config.js";
4
+ /**
5
+ * Public settings use a whitelist: API keys and cached credentials stay on disk.
6
+ *
7
+ * `mode` selects the workspace. The Pro workspace has its own defaults,
8
+ * language, plan and reviewer settings; the login password and the approval
9
+ * policy are layered in from the shared config by loadConfig(), so the two
10
+ * pages cannot drift apart on those.
11
+ */
12
+ export function getWebSettings(mode = "standard") {
13
+ const config = { ...DEFAULT_CONFIG, ...loadConfig(mode) };
14
+ const catalog = loadModels();
15
+ const providers = catalog.providers.map((provider) => ({
7
16
  name: provider.name,
8
- models: provider.models.map((model) => ({ name: model.name, configured: Boolean(model.api_key?.trim()), context_window: contextWindow(model.context_window) })),
17
+ models: provider.models.map((model) => ({
18
+ name: model.name,
19
+ configured: Boolean(model.api_key?.trim()),
20
+ context_window: modelContextWindow(provider.name, model, mode),
21
+ // The browser only offers these, so it cannot submit a level the model
22
+ // does not declare. Missing field = every intensity.
23
+ intensities: modelIntensities(model),
24
+ })),
9
25
  }));
10
26
  const preferred = providers.find((p) => p.name === config.selected_provider);
11
27
  const provider = preferred ?? providers.find((p) => p.models.some((m) => m.configured)) ?? providers[0];
12
28
  const model = provider?.models.find((m) => m.name === config.selected_model) ??
13
29
  provider?.models.find((m) => m.configured) ?? provider?.models[0];
14
- const intensity = String(config.selected_intensity ?? config.default_intensity);
30
+ const configuredModel = provider && model
31
+ ? catalog.providers.find((p) => p.name === provider.name)?.models.find((m) => m.name === model.name) ?? null
32
+ : null;
33
+ const intensity = intensityForModel(configuredModel, config.selected_intensity ?? config.default_intensity, config.default_intensity);
15
34
  return {
35
+ ...(mode === "pro" ? { pro: {
36
+ presets: loadProModels().tier.map((preset) => ({
37
+ name: preset.name, chinese_name: preset.chinese_name,
38
+ "model-setting": preset["model-setting"].map((setting) => ({
39
+ type: setting.type, provider: setting.provider, model: setting.model,
40
+ intensity: setting.intensity, context_window: setting.context_window,
41
+ })),
42
+ })),
43
+ preset: defaultProPreset(),
44
+ max_recursion_depth: proMaxRecursionDepth(),
45
+ } } : {}),
16
46
  providers,
17
47
  intensities: INTENSITY_LEVELS,
18
48
  languages: WEB_LANGUAGES.map((value) => ({ value, label: value === "zh" ? "简体中文" : "English" })),
19
49
  selection: {
20
50
  provider: provider?.name ?? "",
21
51
  model: model?.name ?? "",
22
- intensity: INTENSITY_LEVELS.includes(intensity) ? intensity : "High",
52
+ intensity,
23
53
  },
24
54
  settings: {
25
- default_intensity: String(config.default_intensity),
55
+ default_intensity: intensityForModel(configuredModel, config.default_intensity, "High"),
26
56
  model_context_window: model?.context_window ?? contextWindow(undefined),
27
57
  approval_model: String(config.approval_model ?? ""),
28
58
  auto_approve_extreme_risk: config.auto_approve_extreme_risk === true,
@@ -31,27 +61,49 @@ export function getWebSettings() {
31
61
  plan_first: config.plan_first !== false,
32
62
  approval_mode: normalizeApprovalMode(config.approval_mode),
33
63
  web_console: config.web_console !== false,
64
+ web_lan: config.web_lan === true,
34
65
  },
35
66
  };
36
67
  }
37
68
  /** Validate the complete patch before writing anything, preserving unrelated settings. */
38
- export function updateWebSettings(value) {
69
+ export function updateWebSettings(value, mode = "standard") {
39
70
  if (!value || typeof value !== "object" || Array.isArray(value))
40
71
  throw new Error("invalid settings body");
41
72
  const body = value;
42
73
  if (Object.keys(body).some((key) => key !== "selection" && key !== "settings"))
43
74
  throw new Error("unknown settings field");
44
- const config = loadConfig();
75
+ const config = loadConfig(mode);
76
+ // Resolve the requested model first: its declared intensities decide which
77
+ // default level can be saved with this patch. Settings that do not touch a
78
+ // model stay editable even when the catalogue is empty.
79
+ const chosen = body.selection !== undefined ? resolveModelSelection(body.selection, false) : undefined;
80
+ let target = chosen ?? null;
81
+ const settingsTarget = () => {
82
+ target ??= resolveModelSelection(getWebSettings(mode).selection, false);
83
+ return target;
84
+ };
45
85
  if (body.settings !== undefined) {
46
86
  if (!body.settings || typeof body.settings !== "object" || Array.isArray(body.settings))
47
87
  throw new Error("invalid settings");
48
88
  const patch = body.settings;
49
89
  for (const [key, val] of Object.entries(patch)) {
50
- if (key === "default_intensity") {
51
- if (typeof val !== "string" || !INTENSITY_LEVELS.includes(val))
90
+ if (key === "pro_preset" && mode === "pro") {
91
+ config.pro_preset = requireProPreset(val);
92
+ }
93
+ else if (key === "pro_max_recursion_depth" && mode === "pro") {
94
+ if (typeof val !== "number" || !Number.isSafeInteger(val) || val < 1 || val > 100) {
95
+ throw new Error("Pro recursion depth must be a whole number from 1 to 100");
96
+ }
97
+ config.pro_max_recursion_depth = val;
98
+ }
99
+ else if (key === "default_intensity") {
100
+ const level = normalizeIntensity(val);
101
+ if (!level)
52
102
  throw new Error("invalid default intensity");
53
- config.default_intensity = val;
54
- config.selected_intensity = val;
103
+ if (!modelIntensities(settingsTarget().model).includes(level))
104
+ throw new Error("this model does not support that thinking intensity");
105
+ config.default_intensity = level;
106
+ config.selected_intensity = level;
55
107
  }
56
108
  else if (key === "model_context_window") {
57
109
  if (!isContextWindow(val))
@@ -93,6 +145,11 @@ export function updateWebSettings(value) {
93
145
  throw new Error("web console must be a boolean");
94
146
  config.web_console = val;
95
147
  }
148
+ else if (key === "web_lan") {
149
+ if (typeof val !== "boolean")
150
+ throw new Error("LAN access must be a boolean");
151
+ config.web_lan = val;
152
+ }
96
153
  else if (key === "web_password") {
97
154
  validateWebPassword(val);
98
155
  }
@@ -101,8 +158,7 @@ export function updateWebSettings(value) {
101
158
  }
102
159
  }
103
160
  }
104
- if (body.selection !== undefined) {
105
- const chosen = resolveModelSelection(body.selection, false);
161
+ if (chosen) {
106
162
  config.selected_provider = chosen.provider;
107
163
  config.selected_model = chosen.model.name;
108
164
  config.selected_model_url = chosen.model.url;
@@ -116,12 +172,23 @@ export function updateWebSettings(value) {
116
172
  config.web_password_hash = hashWebPassword(password);
117
173
  const window = body.settings?.model_context_window;
118
174
  if (window !== undefined) {
119
- const selection = body.selection ?? getWebSettings().selection;
120
- const chosen = resolveModelSelection(selection, false);
121
- const models = loadModels();
122
- const model = models.providers.find((p) => p.name === chosen.provider).models.find((m) => m.name === chosen.model.name);
123
- model.context_window = window;
124
- saveModels(models);
175
+ const selected = settingsTarget();
176
+ if (mode === "pro") {
177
+ // Context capacity is part of a workspace's compression settings. Pro
178
+ // stores its own copy instead of editing the shared model catalog.
179
+ const current = config.model_context_windows;
180
+ const overrides = current && typeof current === "object" && !Array.isArray(current)
181
+ ? { ...current }
182
+ : {};
183
+ overrides[modelContextWindowKey(selected.provider, selected.model.name)] = window;
184
+ config.model_context_windows = overrides;
185
+ }
186
+ else {
187
+ const models = loadModels();
188
+ const model = models.providers.find((p) => p.name === selected.provider).models.find((m) => m.name === selected.model.name);
189
+ model.context_window = window;
190
+ saveModels(models);
191
+ }
125
192
  }
126
- saveConfig(config);
193
+ saveConfig(config, mode);
127
194
  }