@xingjisoft/neutrino 0.2.0 → 0.3.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/README.md CHANGED
@@ -17,12 +17,12 @@ A CLI coding agent that lives in your terminal. Neutrino gives you an interactiv
17
17
  - **Session history** — messages are saved before a turn starts, streamed replies are checkpointed every second, and tool calls/results are saved immediately. The history screen lists conversations started in the current working directory. Failed saves are shown in the terminal and browser UI.
18
18
  - **Auto-folding turns** — once a turn finishes, the work behind the reply (the thinking and the tool calls) folds into one summary line in both the terminal and the browser, while the answer itself always stays on screen. Click the line to read the whole round again; a turn with little to hide, or a long answer on its own, stays open. A turn stopped with `ESC ESC` is paused, not finished, so it stays open. Nothing is dropped: the transcript, the saved session and the model's context keep every message.
19
19
  - **Complete conversation history** — context compression keeps every original message and tool result on disk. The earlier portion starts collapsed in the terminal and browser; click **Earlier conversation** to expand it, including in reopened history. Model requests use a separate summary plus recent messages.
20
- - **Plan-first turns** — before a request is answered, a planner agent splits it into ordered, independent steps ("model the case from box.png", "animate the camera: push in, orbit, pull back"), and the worker then executes those steps one at a time, top to bottom. Every step is a concrete deliverable, so later work is explicit instead of one vague instruction. See [Plan-first turns](#plan-first-turns).
21
- - **The step card** — click `ⓘ` in the chat header (or press `Ctrl+P`) to open the info card in the top-right corner. It lists the plan as a vertical timeline of nodes: the step being worked on is highlighted, steps still to come are lit, and finished steps are drawn dark with a tick. The same card shows prompt/completion/total token counts when no plan is running.
20
+ - **Plan-first turns** — before a request is answered, a planner agent splits it into ordered, independent steps ("model the case from box.png", "animate the camera: push in, orbit, pull back"), and the worker then executes those steps one at a time, top to bottom. Every step is a concrete deliverable, so later work is explicit instead of one vague instruction. See [Plan-first turns](#plan-first-turns).
21
+ - **The step card** — click `ⓘ` in the chat header (or press `Ctrl+P`) to open the info card in the top-right corner. It lists the plan as a vertical timeline of nodes: the step being worked on is highlighted, steps still to come are lit, and finished steps are drawn dark with a tick. The same card shows prompt/completion/total token counts when no plan is running.
22
22
  - **Sub-agents** — independent work can be handed to a sub-agent: a second agent loop with its own context, its own tool calls and the same approval gate. Only its report comes back, so a survey that reads thirty files does not fill the conversation with thirty files. See [Sub-agents](#sub-agents).
23
23
  - **Agent history browser** — view past sessions from other CLI agents (Claude Code, Codex, OpenCode) directly from the history screen.
24
24
  - **Approval gate** — risky commands and paths outside the working directory need your sign-off. The header turns orange and reads **Request Approval**; press `Y` or `N`. Delete-class commands always ask, unless you explicitly granted full trust, and there is an opt-in reviewer agent for automatic decisions.
25
- - **Browser UI on by default** — every `neutrino` launch also mounts a local website at <http://127.0.0.1:8765> that lists every saved conversation grouped by working directory, streams the running one in real time, and lets you pause/resume it, send messages, switch provider/model/intensity, and edit settings from the browser. Use `--port`/`--host` to move it, `--lan` to share it, or `--no-web` to keep the terminal only.
25
+ - **Browser UI on by default** — every `neutrino` launch also mounts a local website at <http://127.0.0.1:8765> that lists every saved conversation grouped by working directory, streams the running one in real time, and lets you pause/resume it, send messages, start new conversations, open or create working directories, switch provider/model/intensity, edit every terminal setting, and switch the interface between English and 简体中文. Use `--port`/`--host` to move it, `--lan` to share it, or `--no-web` to keep the terminal only.
26
26
 
27
27
  ## Requirements
28
28
 
@@ -70,7 +70,7 @@ The browser UI is mounted at <http://127.0.0.1:8765> at the same time; see
70
70
  | `Ctrl+C` | Do nothing while text is selected; otherwise pause the running conversation or show the exit hint at the top |
71
71
  | `Ctrl+C` × 3 | Quit after three consecutive presses, no more than 1.5 seconds apart (another key, paste, click or scroll resets the count) |
72
72
  | `Ctrl+Shift+C` | Quit immediately (requires the terminal to forward the shortcut) |
73
- | `Ctrl+A` | Toggle Request Approval / Auto Approval in a conversation |
73
+ | `Ctrl+A` | Toggle Request Approval / Auto Approval in a conversation |
74
74
  | `Ctrl+P` | Toggle the plan / token info card in the top-right corner |
75
75
  | `Y` | Approve the pending request (while the approval card is up) |
76
76
  | `N` / `Esc` | Deny the pending request |
@@ -101,78 +101,97 @@ highlight stops at the log's edge instead of spilling into the header or the
101
101
  input box, and the copy includes the lines that scrolled out of view. Scroll back
102
102
  and the selection shrinks again; press `Esc` to drop it.
103
103
 
104
- ## Plan-first turns
105
-
106
- A request is answered in two passes. A **planner agent** first turns the message
107
- into an ordered list of independent steps; a **worker agent** then executes
108
- exactly one step per pass, from the top down. Each step names one deliverable, so
109
- a long request stops being a single vague instruction and becomes a sequence of
110
- concrete, verifiable ones. The plan is written into the conversation as a
111
- message and saved with the session, so the steps of a reopened conversation are
112
- still there.
113
-
114
- The steps are also listed in the `ⓘ` card in the top-right corner of the chat —
115
- click it, or press `Ctrl+P`:
116
-
117
- - nodes run top to bottom in execution order, joined by a connector,
118
- - the step being worked on is the highlighted row,
119
- - steps still to come are lit, and
120
- - finished steps are drawn dark with a tick; a failed one is marked in red.
121
-
122
- The card opens by itself as soon as a plan is written, and closing it loses
123
- nothing: `Ctrl+P` brings it back. On a short terminal the card shows a window
124
- around the running step and counts the steps above and below it.
125
-
126
- A step that fails stops the plan: the remaining steps stay pending and the next
127
- message can re-plan them. A turn paused with `Esc Esc` stops the plan and marks
128
- it **stopped**, so no step keeps looking live. When no usable plan comes back —
129
- a single-action request, a provider error, an answer that is not a plan — the
130
- turn runs in one pass exactly as before, so planning is never a new way to fail.
131
-
132
- The prompts are files, not code: [`assets/planner.txt`](./assets/planner.txt)
133
- says how steps are written, [`assets/step.txt`](./assets/step.txt) is the
134
- instruction for each step, and [`assets/prompt.txt`](./assets/prompt.txt) is the
135
- worker's base system prompt. Turn planning off for one run with
136
- `NEUTRINO_PLAN=off`, or permanently with `"plan_first": false` in `config.json`.
137
-
138
- ## Sub-agents
139
-
140
- The agent can hand one self-contained sub-task to a **sub-agent**: a second agent
141
- loop with its own transcript, its own tool calls and the same working directory.
142
- Only its final report comes back — the files it read and the commands it ran
143
- never enter the main conversation. That is the point: surveying a large codebase
144
- or probing how a command behaves costs many messages, and the parent needs the
145
- conclusion, not the transcript. The child's tokens still count in the
146
- conversation's usage.
147
-
148
- - The parent calls the `delegate` tool with a complete task and, optionally, a
149
- little context. The sub-agent cannot see the conversation and cannot ask the
150
- user, so a task that refers to "the file above" fails; the system prompt tells
151
- the model to write a brief that stands alone.
152
- - Its tool calls go through the **same approval gate**: a risky call parks the
153
- turn and shows the usual card (the header turns orange, the browser shows its
154
- banner). Denying it tells the sub-agent not to retry unchanged, and the parent
155
- receives the refusal in the report.
156
- - Delegation is one level deep — `delegate` is not in a sub-agent's toolset — so
157
- the work always has one owner and one pause button.
158
- - While it runs, the terminal status line and the browser badge name the current
159
- phase (`Sub-agent — read_file`), and the `delegate` tool block shows the task
160
- it was given. The report replaces that marker when it arrives, as the result of
161
- the tool call.
162
- - The round limit (12) bounds a child that never finishes; the parent then gets
163
- the partial report, clearly marked as partial. A paused turn aborts the
164
- sub-agent and says so in the tool result.
165
-
166
- The sub-agent's prompt is a file too: [`assets/subagent.txt`](./assets/subagent.txt).
167
-
104
+ ## Plan-first turns
105
+
106
+ A request is answered in two passes. A **planner agent** first turns the message
107
+ into an ordered list of independent steps; a **worker agent** then executes
108
+ exactly one step per pass, from the top down. Each step names one deliverable, so
109
+ a long request stops being a single vague instruction and becomes a sequence of
110
+ concrete, verifiable ones. The plan is written into the conversation as a
111
+ message and saved with the session, so the steps of a reopened conversation are
112
+ still there.
113
+
114
+ The steps are also listed in the `ⓘ` card in the top-right corner of the chat —
115
+ click it, or press `Ctrl+P`:
116
+
117
+ - nodes run top to bottom in execution order, joined by a connector,
118
+ - the step being worked on is the highlighted row,
119
+ - steps still to come are lit, and
120
+ - finished steps are drawn dark with a tick; a failed one is marked in red.
121
+
122
+ The card opens by itself as soon as a plan is written, and closing it loses
123
+ nothing: `Ctrl+P` brings it back. On a short terminal the card shows a window
124
+ around the running step and counts the steps above and below it.
125
+
126
+ A step that fails stops the plan: the remaining steps stay pending and the next
127
+ message can re-plan them. A turn paused with `Esc Esc` stops the plan and marks
128
+ it **stopped**, so no step keeps looking live. When no usable plan comes back —
129
+ a single-action request, a provider error, an answer that is not a plan — the
130
+ turn runs in one pass exactly as before, so planning is never a new way to fail.
131
+
132
+ **Simple requests are not planned at all.** Planning costs a model round-trip
133
+ before any work starts, and for "把这个变量改名" that round-trip is pure friction.
134
+ A request is planned only when it shows a reason to be split:
135
+
136
+ | Signal | Example |
137
+ | --- | --- |
138
+ | More than one thing to do | `先改配置,再更新文档`, `运行测试,并且把报错修掉` |
139
+ | Its own structure | a numbered or bulleted list |
140
+ | It reads as a brief | longer than about 180 columns |
141
+ | It asks for a plan | `列个计划`, `plan`, `steps` |
142
+
143
+ Everything else runs in one pass: no planner call, no plan card, and the agent
144
+ still names the steps it is taking inside its own answer. The check lives in
145
+ `needsPlan()` in `src/llm/plan.ts`.
146
+
147
+ The prompts are files, not code: [`assets/planner.txt`](./assets/planner.txt)
148
+ says how steps are written, [`assets/step.txt`](./assets/step.txt) is the
149
+ instruction for each step, and [`assets/prompt.txt`](./assets/prompt.txt) is the
150
+ worker's base system prompt. Turn planning off for one run with
151
+ `NEUTRINO_PLAN=off`, or permanently with `"plan_first": false` in `config.json`.
152
+
153
+ ## Sub-agents
154
+
155
+ The agent can hand one self-contained sub-task to a **sub-agent**: a second agent
156
+ loop with its own transcript, its own tool calls and the same working directory.
157
+ Only its final report comes back — the files it read and the commands it ran
158
+ never enter the main conversation. That is the point: surveying a large codebase
159
+ or probing how a command behaves costs many messages, and the parent needs the
160
+ conclusion, not the transcript. The child's tokens still count in the
161
+ conversation's usage.
162
+
163
+ - The parent calls the `delegate` tool with a complete task and, optionally, a
164
+ little context. The sub-agent cannot see the conversation and cannot ask the
165
+ user, so a task that refers to "the file above" fails; the system prompt tells
166
+ the model to write a brief that stands alone.
167
+ - Its tool calls go through the **same approval gate**: a risky call parks the
168
+ turn and shows the usual card (the header turns orange, the browser shows its
169
+ banner). Denying it tells the sub-agent not to retry unchanged, and the parent
170
+ receives the refusal in the report.
171
+ - Delegation is one level deep — `delegate` is not in a sub-agent's toolset — so
172
+ the work always has one owner and one pause button.
173
+ - While it runs, the terminal status line and the browser badge name the current
174
+ phase (`Sub-agent — read_file`), and the `delegate` tool block shows the task
175
+ it was given. The report replaces that marker when it arrives, as the result of
176
+ the tool call.
177
+ - The round limit (12) bounds a child that never finishes; the parent then gets
178
+ the partial report, clearly marked as partial. A paused turn aborts the
179
+ sub-agent and says so in the tool result.
180
+
181
+ The sub-agent's prompt is a file too: [`assets/subagent.txt`](./assets/subagent.txt).
182
+
168
183
  ## Configuration
169
184
 
170
185
  Settings are stored under `~/.neutrino/`:
171
186
 
172
187
  - `model.json` — provider and model definitions (edit to add your own endpoints).
188
+ DeepSeek-style thinking endpoints get `reasoning_content` sent back on their
189
+ assistant messages automatically; a differently named endpoint can opt in
190
+ with `"reasoning": true` (or opt out with `false`).
173
191
  - `config.json` — app configuration (selected provider/model/intensity, theme,
174
192
  language, approval mode, `auto_approve_extreme_risk`, `sound_alerts`,
175
- `web_console`).
193
+ `web_console`, `web_workspaces`). `language` (`en` or `zh`) is the browser
194
+ console's interface language; the terminal UI itself stays English.
176
195
  - `session/` — saved conversation sessions, one JSON file per session.
177
196
 
178
197
  Set your API key in the model select screen or directly in `model.json`.
@@ -184,7 +203,7 @@ A few environment variables override flags:
184
203
  | `NEUTRINO_WEB_PORT` | Web UI port: like `--port` in either mode, and it mounts the UI even when `web_console` is off. |
185
204
  | `NEUTRINO_WEB_HOST` | Interface to bind, like `--host`; also mounts the UI when `web_console` is off. |
186
205
  | `NEUTRINO_NETWORK_ATTEMPTS` | Total tries for a model request that keeps dropping (default 7). |
187
- | `NEUTRINO_RETRY_DELAY_MS` | Wait before the first retry in ms; each retry doubles it (default 5000). |
206
+ | `NEUTRINO_RETRY_DELAY_MS` | Wait before the first retry in ms; each retry doubles it (default 5000). |
188
207
  | `NEUTRINO_PLAN` | `off` answers a request in one pass without planning it (overrides `plan_first`); `on` forces planning. |
189
208
 
190
209
  ### Approvals
@@ -288,20 +307,44 @@ terminal UI starts. It groups every saved conversation by working directory
288
307
  (showing the five newest per path with the rest collapsed), folds the work of
289
308
  every finished turn into one summary line that opens on a click while the reply
290
309
  stays on screen, streams a running conversation live, and lets you pause or
291
- resume it and send new messages from the browser.
310
+ resume it and send new messages from the browser. Conversation text is rendered
311
+ as markdown — headings, lists (including tasks and nesting), tables, block
312
+ quotes, fenced code, emphasis, links and images — while raw HTML stays inert and
313
+ only safe link/image schemes become clickable.
314
+
315
+ **New conversation** in the sidebar starts a chat that has no transcript yet.
316
+ Its dialog chooses the working directory and the provider, model and thinking
317
+ level for the first turn. **Browse…** opens the server's folder picker, where you
318
+ can walk the filesystem, create a folder, and use it as the conversation's
319
+ working directory. Opened folders are remembered under **Working directories**
320
+ (the folder button above the list), which scopes the sidebar to that project and
321
+ lets you start a new chat there; you can always return to **All directories**.
292
322
 
293
323
  A saved transcript opens read-only, because nothing is running for it. Press
294
324
  **Continue here** (or simply type in the composer) and the server resumes it
295
325
  in-process — same model, working directory and token counters — so you can keep
296
326
  chatting from the browser with no terminal attached. Turns are written back to
297
- the same session file.
327
+ the same session file. **Clear** starts a fresh conversation in the same folder
328
+ (the previous transcript stays saved), **Delete** removes a saved transcript,
329
+ and **Other agents** lists read-only sessions that Claude Code, Codex or
330
+ OpenCode recorded in that folder.
298
331
 
299
332
  Use the selectors above a conversation to change its provider, model or
300
333
  intensity, then press **Apply to conversation** (pause an active turn first).
301
334
  The **Settings** (gear) button in the sidebar edits defaults for new conversations,
302
- context compression and approval settings. Conversation choices are saved with
303
- the transcript and do not overwrite the defaults. Configured models and keys
304
- are shared with the terminal; keys stay on the server.
335
+ the interface language, plan-first turns, the default approval mode, context
336
+ compression and approval settings. Conversation choices are saved with the
337
+ transcript and do not overwrite the defaults. Configured models and keys are
338
+ shared with the terminal; keys stay on the server.
339
+
340
+ The interface language can be switched in the sidebar footer or in
341
+ **Settings → Interface**: English and 简体中文 are supported. The change applies
342
+ immediately without a reload, survives restarts in `config.json`, and is shared
343
+ by every browser. The terminal UI itself is not translated.
344
+
345
+ The `ⓘ` button in a conversation header opens the same plan/token card as
346
+ `Ctrl+P` in the terminal: the steps of a planned turn with their live statuses,
347
+ and the prompt/completion/total token counts when no plan is running.
305
348
 
306
349
  Context auto-compression starts at 80% of the current model's context window.
307
350
  Models default to 1,000,000 tokens (compression at 800,000). Adjust the model
@@ -389,7 +432,7 @@ The agent can call the following tools during a conversation:
389
432
  - `run_command` — execute a shell command (120s timeout, output capped).
390
433
  - `read_file` — read a text file (capped at 200KB).
391
434
  - `write_file` — write content to a file.
392
- - `list_files` — list directory contents.
435
+ - `list_files` — list directory contents.
393
436
  - `delegate` — hand one self-contained sub-task to a sub-agent and return its report.
394
437
 
395
438
  Every tool takes an additional `reason` argument: one sentence saying what the
@@ -425,13 +468,16 @@ node scripts/test-compact.mjs # verify context-compression planning
425
468
  node scripts/test-history-fold.mjs # verify full-history retention, folding and resume
426
469
  node scripts/test-web-history.mjs # verify browser history disclosures and streaming state
427
470
  node scripts/test-web-default.mjs # verify the web console starts and toggles by default
428
- node scripts/test-turn-fold.mjs # verify folded turns keep their reply and every message
429
- node scripts/test-plan.mjs # verify plan-first turns, step order, the step card and the fallback
471
+ node scripts/test-turn-fold.mjs # verify folded turns keep their reply and every message
472
+ node scripts/test-plan.mjs # verify plan-first turns, step order, the step card and the fallback
430
473
  node scripts/test-subagent.mjs # verify delegation: isolation, approvals, abort, round limits
431
474
  node scripts/test-tools.mjs # verify tool-output folding
432
475
  node scripts/test-web.mjs # verify the web server, API, SSE stream and path-traversal guards
433
476
  node scripts/test-web-settings.mjs # verify model switching, persistence, browser controls and defaults
434
477
  node scripts/test-web-settings-http.mjs # verify authenticated settings and model changes over HTTP
478
+ node scripts/test-web-markdown.mjs # verify the browser markdown renderer and its XSS boundaries
479
+ node scripts/test-web-i18n.mjs # verify every console label has a Chinese translation and placeholders survive
480
+ node scripts/test-web-workspaces.mjs # verify folder browsing/creation, new conversations, clear/delete and other-agent history
435
481
  ```
436
482
 
437
483
  ### Rendering model
package/assets/prompt.txt CHANGED
@@ -7,8 +7,9 @@ is described.
7
7
  - Your work is planned before it starts. When a turn carries a plan you are given
8
8
  exactly one step of it: do that step, verify it, and stop. Never start a later
9
9
  step, and never redo an earlier one.
10
- - Without a plan, state the numbered steps you are about to take in your first
11
- message, then take them in that order.
10
+ - Without a plan, just do the work. A single action goes straight to the answer —
11
+ do not decorate it with a plan. When you are taking several steps, name them in
12
+ one short line each and then take them in that order.
12
13
  - Look before you change: read the files you are about to edit, list the
13
14
  directory, run the command that shows the current state. Anything about the
14
15
  code you have not read is a guess, and guesses are the main source of bad
package/dist/config.js CHANGED
@@ -11,6 +11,17 @@ export const NEUTRINO_DIR = path.join(os.homedir(), ".neutrino");
11
11
  export const MODEL_FILE = path.join(NEUTRINO_DIR, "model.json");
12
12
  export const SESSION_DIR = path.join(NEUTRINO_DIR, "session");
13
13
  export const CONFIG_FILE = path.join(NEUTRINO_DIR, "config.json");
14
+ /**
15
+ * Whether assistant messages must be sent back with a `reasoning_content`
16
+ * field. DeepSeek's thinking mode requires one on *every* assistant message of
17
+ * a tools-bearing request (an empty string is accepted), and the plan message
18
+ * written by the planner is otherwise an assistant message without it.
19
+ */
20
+ export function modelUsesReasoning(provider, model) {
21
+ if (typeof model.reasoning === "boolean")
22
+ return model.reasoning;
23
+ return provider === "DeepSeek" || /deepseek|reasoner|thinking/i.test(model.name);
24
+ }
14
25
  export const DEFAULT_CONTEXT_WINDOW = 1_000_000;
15
26
  export const CONTEXT_COMPRESSION_RATIO = 0.8;
16
27
  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];
@@ -52,12 +63,20 @@ export const DEFAULT_MODELS = {
52
63
  },
53
64
  ],
54
65
  };
66
+ /** Languages the browser console can render its own interface in. */
67
+ export const WEB_LANGUAGES = ["en", "zh"];
68
+ /** Unknown or missing values fall back to English rather than breaking the UI. */
69
+ export function normalizeWebLanguage(value) {
70
+ return typeof value === "string" && value.trim().toLowerCase().startsWith("zh") ? "zh" : "en";
71
+ }
55
72
  export const DEFAULT_CONFIG = {
56
73
  language: "en",
57
74
  theme: "default",
58
75
  default_intensity: "High",
59
76
  // 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.
77
+ // plan, the worker executes one step per turn. Requests that look like a
78
+ // single action are never planned (see needsPlan), and false turns planning
79
+ // off for every request.
61
80
  plan_first: true,
62
81
  // Default for new conversations; each conversation can override it.
63
82
  approval_mode: "request",
@@ -70,6 +89,9 @@ export const DEFAULT_CONFIG = {
70
89
  // Mount the browser UI alongside the terminal UI unless it is turned off
71
90
  // here or on the command line.
72
91
  web_console: true,
92
+ // Working directories opened from the browser, newest first. They remain
93
+ // listed even when they hold no conversations yet.
94
+ web_workspaces: [],
73
95
  // Initialized as a salted hash of the default Web password on first use.
74
96
  web_password_hash: "",
75
97
  };
@@ -1,4 +1,4 @@
1
- import { INTENSITY_PARAMS } from "../config.js";
1
+ import { INTENSITY_PARAMS, 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?"
@@ -54,6 +54,12 @@ export class LLMClient {
54
54
  top_p;
55
55
  toolCalls = [];
56
56
  usage = null;
57
+ /**
58
+ * The thinking text streamed alongside the reply. DeepSeek's thinking mode
59
+ * requires it to be echoed back on the assistant message that carries a tool
60
+ * call, so the agent loops attach it to that message.
61
+ */
62
+ reasoningContent = "";
57
63
  constructor(model, provider, intensity) {
58
64
  this.model = model;
59
65
  this.provider = provider;
@@ -98,6 +104,7 @@ export class LLMClient {
98
104
  async *streamTurn(messages, tools, options = {}) {
99
105
  this.toolCalls = [];
100
106
  this.usage = null;
107
+ this.reasoningContent = "";
101
108
  try {
102
109
  if (this.provider === "Anthropic") {
103
110
  yield* this.streamAnthropic(messages, options);
@@ -125,6 +132,7 @@ export class LLMClient {
125
132
  const policy = resolvePolicy(options);
126
133
  for (let attempt = 1;; attempt++) {
127
134
  this.toolCalls = [];
135
+ this.reasoningContent = "";
128
136
  let produced = false;
129
137
  try {
130
138
  for await (const token of this.openAIAttempt(messages, tools)) {
@@ -141,6 +149,28 @@ export class LLMClient {
141
149
  }
142
150
  }
143
151
  }
152
+ /**
153
+ * DeepSeek's thinking mode rejects a tools-bearing request when any assistant
154
+ * message in its history carries no `reasoning_content` ("The
155
+ * `reasoning_content` in the thinking mode must be passed back to the API").
156
+ * An empty string is accepted, which is the honest value for a message that
157
+ * predates this turn's thinking: a compressed summary, the planner's plan, an
158
+ * error note or a session saved before this rule existed. Endpoints outside
159
+ * thinking mode must not receive the field at all.
160
+ */
161
+ wireMessages(messages) {
162
+ if (!modelUsesReasoning(this.provider, this.model)) {
163
+ return messages.map((message) => {
164
+ if (message.role !== "assistant" || message.reasoning_content === undefined)
165
+ return message;
166
+ const { reasoning_content: _ignored, ...rest } = message;
167
+ return rest;
168
+ });
169
+ }
170
+ return messages.map((message) => message.role === "assistant" && typeof message.reasoning_content !== "string"
171
+ ? { ...message, reasoning_content: "" }
172
+ : message);
173
+ }
144
174
  /** A single streaming attempt; see `streamOpenAI` for the retry policy. */
145
175
  async *openAIAttempt(messages, tools) {
146
176
  const url = this.model.url.replace(/\/+$/, "") + "/chat/completions";
@@ -150,7 +180,7 @@ export class LLMClient {
150
180
  };
151
181
  const payload = {
152
182
  model: this.model.name,
153
- messages,
183
+ messages: this.wireMessages(messages),
154
184
  temperature: this.temperature,
155
185
  top_p: this.top_p,
156
186
  stream: true,
@@ -216,6 +246,11 @@ export class LLMClient {
216
246
  }
217
247
  const choice = chunk.choices?.[0];
218
248
  const delta = choice?.delta ?? {};
249
+ // DeepSeek calls it reasoning_content; other compatible endpoints use
250
+ // the shorter `reasoning`. Neither is part of the visible reply.
251
+ const reasoning = delta.reasoning_content ?? delta.reasoning;
252
+ if (typeof reasoning === "string" && reasoning)
253
+ this.reasoningContent += reasoning;
219
254
  const content = delta.content;
220
255
  if (typeof content === "string" && content)
221
256
  yield content;
package/dist/llm/plan.js CHANGED
@@ -20,6 +20,55 @@ export const PLAN_MIN_STEPS = 2;
20
20
  export const PLAN_MAX_STEPS = 10;
21
21
  export const PLAN_MAX_TITLE = 160;
22
22
  export const PLAN_MAX_DETAIL = 400;
23
+ // -------------------------------------------------------------- when to plan
24
+ /**
25
+ * A request longer than this many columns is treated as a brief and planned.
26
+ * In practice that is roughly eighty Chinese characters or one hundred and
27
+ * eighty ASCII ones — past that, "one action" is already unlikely.
28
+ */
29
+ export const PLAN_LENGTH_LIMIT = 180;
30
+ /** The user brought their own structure: numbered steps or bullets. */
31
+ const LIST_LINE = /^\s*(?:[-*•]|\d+[.)、])\s+/m;
32
+ /**
33
+ * Wording that means "more than one thing to do".
34
+ *
35
+ * Chinese has no spaces, so the weak conjunctions only count after punctuation
36
+ * (",并且…") or as a clear pair ("先…再…"): otherwise 合并, 并发 and 再运行一次
37
+ * would all read as sequencing.
38
+ */
39
+ const SEQUENCING = /然后|接着|随后|之后再|并且|以及|[,,;;]\s*并|先[^。!?\n]{0,40}再|\b(?:then|after that|afterwards|finally|also|next|followed by)\b/iu;
40
+ /** An explicit plan request: the user wants steps, however small the task. */
41
+ const PLAN_REQUEST = /计划|规划|步骤|拆(?:分|解)|\bplan\b|\bsteps?\b/iu;
42
+ /** Sentence terminators; an English full stop only counts before whitespace. */
43
+ function sentenceMarks(text) {
44
+ const cjk = text.match(/[。!?!?]/gu)?.length ?? 0;
45
+ const dots = text.match(/\.(?=\s|$)/gu)?.length ?? 0;
46
+ return cjk + dots;
47
+ }
48
+ /**
49
+ * Does this request need a plan at all?
50
+ *
51
+ * Planning costs a model round-trip before any work starts, and for "rename
52
+ * this variable" that round-trip is pure friction. A request is planned only
53
+ * when it shows a reason to be split into steps: it asks for more than one
54
+ * thing, brings its own structure (a list), reads as a brief (long), or asks
55
+ * for a plan. Everything else runs in one pass, where the system prompt still
56
+ * has the agent state the steps it is taking in its own answer.
57
+ */
58
+ export function needsPlan(request) {
59
+ const text = request.trim();
60
+ if (!text)
61
+ return false;
62
+ if (PLAN_REQUEST.test(text))
63
+ return true;
64
+ if (LIST_LINE.test(text))
65
+ return true;
66
+ if (SEQUENCING.test(text))
67
+ return true;
68
+ if (sentenceMarks(text) >= 2)
69
+ return true;
70
+ return displayWidth(text) > PLAN_LENGTH_LIMIT;
71
+ }
23
72
  // ------------------------------------------------------------------ parsing
24
73
  function cleanText(value, max) {
25
74
  if (typeof value !== "string")
@@ -68,6 +68,9 @@ export async function runSubAgent(options) {
68
68
  messages.push({
69
69
  role: "assistant",
70
70
  content,
71
+ // A tool-call turn must carry the thinking that produced it back to a
72
+ // thinking-mode endpoint, or its next request is rejected.
73
+ ...(client.reasoningContent ? { reasoning_content: client.reasoningContent } : {}),
71
74
  ...(toolCalls.length > 0
72
75
  ? {
73
76
  tool_calls: toolCalls.map((call) => ({
@@ -1,6 +1,6 @@
1
1
  import { Screen } from "../terminal/screen.js";
2
2
  import { C } from "../colors.js";
3
- import { findModel, contextCompressionThreshold, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadPrompt, saveSession, } from "../config.js";
3
+ import { findModel, contextCompressionThreshold, loadConfig, loadModels, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadPrompt, saveSession, } from "../config.js";
4
4
  import { drawHeader } from "../header.js";
5
5
  import { computeInputWindow, cursorFromColumn, drawInputBox, drawScrollbar, wrapText, approvalToggle, drawApprovalToggle, inRect } from "../ui.js";
6
6
  import { drawSpans, renderMarkdownText } from "../markdown.js";
@@ -8,7 +8,7 @@ import { ellipsize, displayWidth } from "../width.js";
8
8
  import { sanitizeText } from "../sanitize.js";
9
9
  import { LLMClient, describeError } from "../llm/client.js";
10
10
  import { formatDelay } from "../llm/retry.js";
11
- import { advancePlan, buildPlannerInput, lastRequestText, parsePlan, planCardRows, planMessage, planRowsWidth, planTranscript, renderStepDirective, resumePlan, stopPlan, } from "../llm/plan.js";
11
+ import { advancePlan, buildPlannerInput, lastRequestText, needsPlan, parsePlan, planCardRows, planMessage, planRowsWidth, planTranscript, renderStepDirective, resumePlan, stopPlan, } from "../llm/plan.js";
12
12
  import { DELEGATE_TOOL, executeTool, getSubAgentToolDefinitions, getToolDefinitions } from "../llm/tools.js";
13
13
  import { buildSubAgentTask, formatSubAgentResult, runSubAgent as runSubAgentTask, } from "../llm/subagent.js";
14
14
  import { assessToolCall } from "../llm/risk.js";
@@ -173,6 +173,13 @@ export class ChatScreen extends Screen {
173
173
  api_key: st.selectedModelApiKey,
174
174
  supports_streaming: st.selectedModelStreaming,
175
175
  };
176
+ // The selected-state snapshot has no room for provider options; re-read the
177
+ // thinking-mode flag from model.json so a hand-configured endpoint keeps it.
178
+ const configured = loadModels().providers
179
+ .find((provider) => provider.name === this.modelProvider)?.models
180
+ .find((model) => model.name === this.model.name);
181
+ if (configured && typeof configured.reasoning === "boolean")
182
+ this.model.reasoning = configured.reasoning;
176
183
  this.intensity = this.loadedIntensity ?? st.selectedIntensity;
177
184
  if (this.loadedTokens) {
178
185
  this.tokens = { prompt: this.loadedTokens.prompt, completion: this.loadedTokens.completion };
@@ -320,6 +327,45 @@ export class ChatScreen extends Screen {
320
327
  this.app.render();
321
328
  this.publishLive();
322
329
  }
330
+ /**
331
+ * Start a fresh conversation in the same working directory. Used by Ctrl+L
332
+ * and by the browser's Clear button, so both screens behave identically. The
333
+ * previous transcript is saved first and is never overwritten.
334
+ */
335
+ liveClear() {
336
+ this.save();
337
+ if (this.saveError) {
338
+ this.app.render();
339
+ return;
340
+ }
341
+ this.turnToken++;
342
+ if (this.pendingApproval)
343
+ this.settleApproval(false);
344
+ this.busy = false;
345
+ this.paused = false;
346
+ this.queued = null;
347
+ this.compressing = false;
348
+ this.retryNotice = null;
349
+ this.app.state.isThinking = false;
350
+ this.lastPromptTokens = 0;
351
+ this.sessionId = null;
352
+ this.context = null;
353
+ this.expandedContext = false;
354
+ this.plan = null;
355
+ this.planning = false;
356
+ this.subagent = null;
357
+ const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
358
+ this.messages = [{ role: "system", content: sysText }];
359
+ this.expandedTools.clear();
360
+ this.finishedTurns.clear();
361
+ this.expandedTurns.clear();
362
+ this.activeTurnStart = null;
363
+ this.streamingContent = null;
364
+ this.scrollTop = 0;
365
+ this.followBottom = true;
366
+ this.publishLive();
367
+ this.app.render();
368
+ }
323
369
  liveSend(text) {
324
370
  if (!text.trim())
325
371
  return;
@@ -487,36 +533,7 @@ export class ChatScreen extends Screen {
487
533
  return;
488
534
  }
489
535
  if (key.ctrl && key.name === "l") {
490
- // Clearing starts a fresh conversation; do not overwrite the old history.
491
- this.save();
492
- if (this.saveError) {
493
- this.app.render();
494
- return;
495
- }
496
- this.turnToken++;
497
- this.busy = false;
498
- this.paused = false;
499
- this.queued = null;
500
- this.compressing = false;
501
- this.app.state.isThinking = false;
502
- this.lastPromptTokens = 0;
503
- this.sessionId = null;
504
- this.context = null;
505
- this.expandedContext = false;
506
- this.plan = null;
507
- this.planning = false;
508
- this.subagent = null;
509
- const sysText = this.messages[0] && this.messages[0].role === "system" ? this.messages[0].content : "";
510
- this.messages = [{ role: "system", content: sysText }];
511
- this.expandedTools.clear();
512
- this.finishedTurns.clear();
513
- this.expandedTurns.clear();
514
- this.activeTurnStart = null;
515
- this.streamingContent = null;
516
- this.scrollTop = 0;
517
- this.followBottom = true;
518
- this.publishLive();
519
- this.app.render();
536
+ this.liveClear();
520
537
  return;
521
538
  }
522
539
  if (key.ctrl && key.name === "u") {
@@ -668,6 +685,11 @@ export class ChatScreen extends Screen {
668
685
  const request = lastRequestText(this.messages);
669
686
  if (!request.trim())
670
687
  return null;
688
+ // A request that is one action is not planned: the planner round-trip would
689
+ // cost more than the steps are worth, and the worker states its steps in its
690
+ // own answer. The card then shows the token counts, not a one-line plan.
691
+ if (!needsPlan(request))
692
+ return null;
671
693
  this.planning = true;
672
694
  this.app.render();
673
695
  this.publishLive();
@@ -954,6 +976,9 @@ export class ChatScreen extends Screen {
954
976
  apiMessages.push({
955
977
  role: "assistant",
956
978
  content,
979
+ // DeepSeek's thinking mode needs the reasoning behind a tool call
980
+ // returned with it; without it the next request is a 400.
981
+ reasoning_content: client.reasoningContent ?? "",
957
982
  tool_calls: toolCalls.map((tc) => ({
958
983
  id: tc.id,
959
984
  type: "function",