@raolin2025/claude-code-node 2.8.14 → 2.8.16

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
@@ -69,6 +69,7 @@ cc-node --resume session-1747000000000-abc123
69
69
  | `--no-stream` | | 禁用流式响应 | `false` |
70
70
  | `--max-messages` | | 消息条数上限,超过则折叠早期历史为摘要(解决本地小模型"条数过多变傻") | `0`(关闭) |
71
71
  | `--small-model` | | 小模型适配模式(强制工具调用 + 敷衍重试 + 意图引导 + 工具精简) | `false` |
72
+ | `--max-output-tokens` | | 覆盖单次响应输出上限(默认根据上下文窗口动态计算) | 窗口×1/16 |
72
73
  | `--stdio` | | **JSON-RPC 服务器模式**(供桥接层/外部客户端接入,见下) | |
73
74
  | `--help` | `-h` | 显示帮助 | |
74
75
 
@@ -177,6 +178,24 @@ cc-node 会自动感知当前所用模型的**上下文窗口长度**,并在
177
178
 
178
179
  > 切换模型(`/model`)后会自动重新探测窗口。`/budget` 也会显示当前窗口与 80% 触发阈值。
179
180
 
181
+ ### 📤 单次输出上限(max_tokens)动态计算
182
+
183
+ cc-node 的**单次响应输出上限**(`max_tokens`)不再写死 4096,而是**根据上下文窗口大小动态计算**:
184
+
185
+ ```
186
+ max_tokens = max(4096, 窗口大小 × 1/16),且不超过窗口的一半
187
+ ```
188
+
189
+ | 窗口 | 动态输出上限 |
190
+ |------|-------------|
191
+ | 65536 (64K) | 4096 |
192
+ | 131072 (128K) | **8192** |
193
+ | 200000 | 12500 |
194
+
195
+ 这样窗口越大,单次输出空间越大,避免小模型 Write 大文件时被截断;同时输出**绝不超过窗口一半**,保证输入有足够空间、永不超窗。
196
+
197
+ > 可用 `--max-output-tokens N` 或 `config.maxOutputTokens` 覆盖(作为输出下限)。
198
+
180
199
  ### 🤖 小模型适配模式(--small-model)
181
200
 
182
201
  > 专为 **本地小模型**(如 27B Q3 量化)设计,让编程工具在弱模型下也能可靠工作。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raolin2025/claude-code-node",
3
- "version": "2.8.14",
3
+ "version": "2.8.16",
4
4
  "description": "Node.js AI Code Agent CLI - Zero dependencies, pure JavaScript, security hardened, multi-channel notifications, Telegram & QQ Bot remote programming, rich media upload, multi-account management",
5
5
  "type": "module",
6
6
  "main": "src/core/index.js",
@@ -18,6 +18,8 @@ import {
18
18
  buildSmallModelSystemPrompt,
19
19
  SMALL_MODEL_SYSTEM_PROMPT,
20
20
  isSmallModelEnabled,
21
+ buildMultiStepPlan,
22
+ buildCombinedGuidance,
21
23
  } from '../core/small-model.js'
22
24
 
23
25
  // ---- 敷衍输出检测 ----
@@ -145,3 +147,82 @@ test('小模型开关判断', () => {
145
147
  assert.equal(isSmallModelEnabled({ smallModel: false }), false)
146
148
  assert.equal(isSmallModelEnabled(undefined), false)
147
149
  })
150
+
151
+ // ---- system prompt 注入 cwd ----
152
+ test('system prompt 强化:注入当前工作目录', () => {
153
+ const base = 'You are cc-node.'
154
+ const enhanced = buildSmallModelSystemPrompt(base, { cwd: 'D:\\workspace\\miniQMT-trader' })
155
+ assert.ok(enhanced.includes('当前工作目录'), '应包含工作目录说明')
156
+ assert.ok(enhanced.includes('miniQMT-trader'), '应包含实际 cwd 路径')
157
+ assert.ok(enhanced.includes('不要猜测其它绝对路径'), '应提示不要瞎猜路径')
158
+ })
159
+
160
+ // ---- 多步计划拆解 ----
161
+ test('多步计划:识别"按计划实现"任务', () => {
162
+ const r = buildMultiStepPlan('按计划实现第一阶段', { cwd: 'D:\\x' })
163
+ assert.ok(r && r.isMultiStep, '应识别为多步任务')
164
+ assert.ok(r.plan.includes('步骤 1'), '应有步骤 1')
165
+ assert.ok(r.plan.includes('Read'), '步骤 1 应用 Read')
166
+ assert.ok(r.plan.includes('DEVELOPMENT_PLAN.md'), '应引用计划文件')
167
+ })
168
+
169
+ test('多步计划:提取指令里的计划文件名', () => {
170
+ const r = buildMultiStepPlan('实现DEV_PLAN.md里的第二阶段', { cwd: 'D:\\x' })
171
+ assert.ok(r && r.plan.includes('DEV_PLAN.md'), '应提取指令中的计划文件名')
172
+ })
173
+
174
+ test('多步计划:非实现任务返回 null', () => {
175
+ assert.equal(buildMultiStepPlan('阅读DEVELOPMENT_PLAN.md', { enable: true }), null)
176
+ assert.equal(buildMultiStepPlan('你好', { enable: true }), null)
177
+ assert.equal(buildMultiStepPlan('按计划实现第一阶段', { enable: false }), null)
178
+ })
179
+
180
+ test('多步计划:combined guidance 优先走多步拆解', () => {
181
+ const g = buildCombinedGuidance('按计划实现第一阶段', { cwd: 'D:\\x', enable: true })
182
+ assert.ok(g, '应生成引导')
183
+ assert.ok(g.includes('多步执行计划'), '应走多步计划')
184
+ assert.ok(g.includes('步骤 1'), '应包含步骤引导')
185
+ })
186
+
187
+ test('多步计划:普通任务走简单意图引导', () => {
188
+ const g = buildCombinedGuidance('阅读DEVELOPMENT_PLAN.md文档', { cwd: 'D:\\x', enable: true })
189
+ assert.ok(g, '应生成引导')
190
+ assert.ok(g.includes('任务引导'), '应走简单意图引导')
191
+ })
192
+
193
+ // ---- 动态 max_tokens 计算 ----
194
+ test('max_tokens:根据窗口大小动态计算', async () => {
195
+ const { QueryEngine } = await import('../core/query-engine.js')
196
+ const { TokenBudget } = await import('../core/token-budget.js')
197
+ // 131072 窗口 → 8192(窗口的 1/16)
198
+ const qe1 = new QueryEngine({ tokenBudget: new TokenBudget({ maxTokens: 131072 }) })
199
+ assert.equal(qe1._computeMaxOutputTokens(), 8192)
200
+ // 65536 → 4096
201
+ const qe2 = new QueryEngine({ tokenBudget: new TokenBudget({ maxTokens: 65536 }) })
202
+ assert.equal(qe2._computeMaxOutputTokens(), 4096)
203
+ // 无窗口 → 兜底 4096
204
+ const qe3 = new QueryEngine({})
205
+ assert.equal(qe3._computeMaxOutputTokens(), 4096)
206
+ })
207
+
208
+ test('max_tokens:可配置输出比例与下限覆盖', async () => {
209
+ const { QueryEngine } = await import('../core/query-engine.js')
210
+ const { TokenBudget } = await import('../core/token-budget.js')
211
+ // outputRatio=0.1 → 131072*0.1=13107
212
+ const qe1 = new QueryEngine({ tokenBudget: new TokenBudget({ maxTokens: 131072 }), outputRatio: 0.1 })
213
+ assert.equal(qe1._computeMaxOutputTokens(), 13107)
214
+ // maxOutputTokens=8192 作为下限
215
+ const qe2 = new QueryEngine({ tokenBudget: new TokenBudget({ maxTokens: 131072 }), maxOutputTokens: 8192 })
216
+ assert.equal(qe2._computeMaxOutputTokens(), 8192)
217
+ })
218
+
219
+ test('max_tokens:绝不超过窗口一半(防超窗)', async () => {
220
+ const { QueryEngine } = await import('../core/query-engine.js')
221
+ const { TokenBudget } = await import('../core/token-budget.js')
222
+ // 小窗口 8192:窗口一半 = 4096,1/16 = 512,取 max(4096,512)=4096 ≤ 4096
223
+ const qe1 = new QueryEngine({ tokenBudget: new TokenBudget({ maxTokens: 8192 }) })
224
+ assert.ok(qe1._computeMaxOutputTokens() <= 4096, `max_tokens=${qe1._computeMaxOutputTokens()} 不应超过窗口一半 4096`)
225
+ // 极端 outputRatio=1.0 也不应超过窗口一半
226
+ const qe2 = new QueryEngine({ tokenBudget: new TokenBudget({ maxTokens: 10000 }), outputRatio: 1.0 })
227
+ assert.ok(qe2._computeMaxOutputTokens() <= 5000, `max_tokens=${qe2._computeMaxOutputTokens()} 不应超过窗口一半 5000`)
228
+ })
package/src/core/cli.js CHANGED
@@ -332,6 +332,7 @@ function parseArgs(argv) {
332
332
  noStream: false,
333
333
  maxMessages: 0,
334
334
  smallModel: false,
335
+ maxOutputTokens: 0,
335
336
  }
336
337
 
337
338
  let i = 2
@@ -349,6 +350,7 @@ function parseArgs(argv) {
349
350
  case '--no-stream': args.noStream = true; break
350
351
  case '--max-messages': args.maxMessages = parseInt(argv[++i], 10); break
351
352
  case '--small-model': args.smallModel = true; break
353
+ case '--max-output-tokens': args.maxOutputTokens = parseInt(argv[++i], 10); break
352
354
  case '--stdio': args.stdio = true; break
353
355
  case '--with-notify': args.withNotify = true; break
354
356
  case '--version':
@@ -371,6 +373,7 @@ Options:
371
373
  --no-stream Disable streaming
372
374
  --max-messages N Fold history when message count exceeds N (default: 0 = off)
373
375
  --small-model Enable small-model adaptation (tool-call enforcement, filler retry, intent guidance)
376
+ --max-output-tokens N Override max single-response output tokens (default: computed from window size)
374
377
  --with-notify Start built-in channel listener (Telegram)
375
378
  (replaces cc-notify daemon — no external script needed)
376
379
  -h, --help Show this help
@@ -535,6 +538,8 @@ const systemPrompt = cliArgs.systemPrompt || DEFAULT_SYSTEM_PROMPT
535
538
  maxMessages: cliArgs.maxMessages || config.get('maxMessages') || 0,
536
539
  // 小模型适配模式(强制工具调用 + 敷衍重试 + 意图引导 + 工具精简)
537
540
  smallModel: cliArgs.smallModel || config.get('smallModel') || false,
541
+ // 单次输出上限覆盖(默认根据窗口动态计算)
542
+ maxOutputTokens: cliArgs.maxOutputTokens || config.get('maxOutputTokens') || 0,
538
543
  })
539
544
  const engine = new QueryEngine(engineConfig)
540
545
 
@@ -19,7 +19,7 @@ import {
19
19
  isSmallModelEnabled,
20
20
  buildSmallModelSystemPrompt,
21
21
  isFillerResponse,
22
- buildIntentGuidance,
22
+ buildCombinedGuidance,
23
23
  selectRelevantTools,
24
24
  RETRY_GUIDANCE,
25
25
  } from './small-model.js'
@@ -53,6 +53,11 @@ export class QueryEngineConfig {
53
53
  // - 工具数量精简 + 意图引导
54
54
  // 默认关闭,通过 config.smallModel=true 或 --small-model 开启
55
55
  this.smallModel = options.smallModel || false
56
+ // 单次输出上限动态计算:
57
+ // outputRatio — 输出占窗口比例(默认 1/16),窗口越大输出越大
58
+ // maxOutputTokens — 输出下限(默认 4096),也可作为硬性覆盖
59
+ this.outputRatio = options.outputRatio != null ? options.outputRatio : 1 / 16
60
+ this.maxOutputTokens = options.maxOutputTokens || 0
56
61
  this.permissionMode = options.permissionMode || 'ask'
57
62
  this.verbose = options.verbose || false
58
63
  // API 配置 — 通用 OpenAI 兼容协议
@@ -228,11 +233,14 @@ export class QueryEngine {
228
233
  // 发送前硬校验:工具结果可能已使上下文超窗,确保 ≤ 窗口(摘要优先 + 滑动窗口裁剪兜底)
229
234
  this._ensureFitWindow()
230
235
 
231
- // 小模型模式:首轮若识别到明确意图,把引导【追加到首条 system 消息】。
236
+ // 小模型模式:首轮若识别到意图/多步计划,把引导【追加到首条 system 消息】。
232
237
  // 注意:不能 push 一条新的 system 消息到中间——llama.cpp/Jinja 严格要求
233
238
  // system 消息必须在对话开头,中间插 system 会报 "System message must be at the beginning" (500)。
234
239
  if (smallModel && !intentGuided && this.state.messages.length > 0) {
235
- const guidance = buildIntentGuidance(userMessage.content, { enable: true })
240
+ const guidance = buildCombinedGuidance(userMessage.content, {
241
+ enable: true,
242
+ cwd: this.config.cwd,
243
+ })
236
244
  if (guidance) {
237
245
  // 找到第一条 system 消息,把引导拼接进去(保持 system 全在开头)
238
246
  const sysIdx = this.state.messages.findIndex(m => m.role === 'system')
@@ -246,7 +254,7 @@ export class QueryEngine {
246
254
  // 没有 system 消息 → 作为 user 引导插入(跟随在最新 user 之后作为新 user)
247
255
  this.state.messages.push({ role: 'user', content: guidance })
248
256
  }
249
- if (this.config.verbose) console.error('[small-model] 已注入意图引导(并入首条 system):' + guidance.slice(0, 60) + '...')
257
+ if (this.config.verbose) console.error('[small-model] 已注入引导(并入首条 system):' + guidance.slice(0, 60) + '...')
250
258
  }
251
259
  intentGuided = true
252
260
  }
@@ -309,16 +317,42 @@ export class QueryEngine {
309
317
  }
310
318
  }
311
319
 
320
+ /**
321
+ * 动态计算单次输出上限 max_tokens
322
+ *
323
+ * 不再写死 4096,而是根据当前上下文窗口(/window 设置、tokenBudget.maxTokens)
324
+ * 按比例动态得出——窗口越大,允许的单次输出越大,避免小模型写大文件时被截断。
325
+ *
326
+ * 公式:max_tokens = max(基础下限, 窗口 × 输出比例)
327
+ * - 输出比例默认 1/16(6.25%):131072 窗口 → 8192;65536 → 4096
328
+ * - 下限 4096:窗口很小时也有足够输出空间
329
+ * - 上限不超过窗口的一半(留足输入空间,绝不超窗)
330
+ *
331
+ * @returns {number}
332
+ */
333
+ _computeMaxOutputTokens() {
334
+ const windowSize = this.tokenBudget?.maxTokens
335
+ // 可配置:默认输出比例 1/16,下限 4096
336
+ const ratio = this.config.outputRatio != null ? this.config.outputRatio : 1 / 16
337
+ const min = this.config.maxOutputTokens || 4096
338
+ if (!windowSize || !Number.isFinite(windowSize) || windowSize <= 0) return min
339
+ const byWindow = Math.floor(windowSize * ratio)
340
+ // 上限 = 窗口一半,保证输入有足够空间,绝不超窗
341
+ const cap = Math.floor(windowSize / 2)
342
+ const maxOut = Math.min(cap, Math.max(min, byWindow))
343
+ return maxOut
344
+ }
345
+
312
346
  /**
313
347
  * 构建 LLM 请求消息列表 — 统一 OpenAI 兼容格式
314
348
  */
315
349
  _buildRequest(messages) {
316
350
  const request = []
317
351
 
318
- // 系统提示(小模型模式:追加强制工具调用引导)
352
+ // 系统提示(小模型模式:追加强制工具调用引导 + 当前工作目录)
319
353
  if (this.config.systemPrompt) {
320
354
  const sysContent = isSmallModelEnabled(this.config)
321
- ? buildSmallModelSystemPrompt(this.config.systemPrompt)
355
+ ? buildSmallModelSystemPrompt(this.config.systemPrompt, { cwd: this.config.cwd })
322
356
  : this.config.systemPrompt
323
357
  request.push({ role: 'system', content: sysContent })
324
358
  }
@@ -472,7 +506,8 @@ export class QueryEngine {
472
506
  const body = {
473
507
  model: this.config.model,
474
508
  messages,
475
- max_tokens: 4096,
509
+ // 根据当前上下文窗口动态计算输出上限(不再写死 4096
510
+ max_tokens: this._computeMaxOutputTokens(),
476
511
  ...(tools.length && { tools }),
477
512
  ...(useStream && { stream: true }),
478
513
  }
@@ -97,10 +97,18 @@ export const SMALL_MODEL_SYSTEM_PROMPT = `
97
97
  /**
98
98
  * 生成小模型模式下的完整 system prompt(基础 prompt + 强化引导)
99
99
  * @param {string} basePrompt — 基础 system prompt
100
+ * @param {object} [opts]
101
+ * @param {string} [opts.cwd] — 当前工作目录(注入给模型,避免它瞎猜文件路径)
100
102
  * @returns {string}
101
103
  */
102
- export function buildSmallModelSystemPrompt(basePrompt) {
103
- return basePrompt + SMALL_MODEL_SYSTEM_PROMPT
104
+ export function buildSmallModelSystemPrompt(basePrompt, opts = {}) {
105
+ let prompt = basePrompt + SMALL_MODEL_SYSTEM_PROMPT
106
+ // 注入当前工作目录:小模型没有上下文意识,不知道用户在哪个项目目录,
107
+ // 不给 cwd 它会瞎猜绝对路径(如 /home/user/repos/...),导致 File not found。
108
+ if (opts.cwd) {
109
+ prompt += `\n\n## 当前工作目录\n用户当前的工作目录是:\`${opts.cwd}\`\n查找/读取/写入文件时,优先在此目录(或此目录的子目录)中定位文件,不要猜测其它绝对路径。\n`
110
+ }
111
+ return prompt
104
112
  }
105
113
 
106
114
  // ---- 重试追加强引导(层 A)----
@@ -118,6 +126,21 @@ export const RETRY_GUIDANCE = `[系统提示] 你刚才没有调用任何工具
118
126
  // note: 给模型的动作说明
119
127
 
120
128
  const INTENT_RULES = [
129
+ {
130
+ // 实现/开发任务:按计划实现、开始写代码、实现模块等 —— 多步复杂任务
131
+ // 这类任务 detectIntent 只给工具提示,真正的分步拆解由 buildMultiStepPlan 完成
132
+ pattern: /(按计划|依据计划|根据计划).*(实现|开发|完成|写代码|编码|落地)/i,
133
+ plan: ['Read(读计划)', 'Glob/Grep(看现状)', 'Write/Edit(实现)', 'Bash(验证)'],
134
+ note: '这是按计划实现代码的多步任务,需要先读计划再逐步实现。',
135
+ toolHint: 'Bash, Read, Edit, Write, Glob, Grep',
136
+ },
137
+ {
138
+ // 实现/开发任务(不带"按计划"):实现模块/功能/代码
139
+ pattern: /(实现|开发|编写|写出|完成).*(模块|功能|代码|接口|函数|类|系统|第一阶段|阶段)/i,
140
+ plan: ['Read(读相关文件)', 'Glob/Grep(看现状)', 'Write/Edit(实现)', 'Bash(验证)'],
141
+ note: '这是实现代码的多步任务,先了解现状再实现并验证。',
142
+ toolHint: 'Bash, Read, Edit, Write, Glob, Grep',
143
+ },
121
144
  {
122
145
  // 读/查看/阅读文档或文件 → 读取任务(优先于写规则,避免"阅读XX文档"被误判为写)
123
146
  pattern: /(读|阅读|查看|打开|展示|显示|看看|浏览).*(文档|文件|内容|md|readme|代码|计划)/i,
@@ -210,6 +233,96 @@ export function buildIntentGuidance(userInput, opts = {}) {
210
233
  return `[任务引导] 根据用户指令,建议按此思路用工具推进:${intent.intent}。\n说明:${intent.note}\n可用工具:${intent.toolHint}`
211
234
  }
212
235
 
236
+ // 常见计划文档文件名(供多步拆解时定位计划文件)
237
+ const PLAN_FILE_CANDIDATES = [
238
+ 'DEVELOPMENT_PLAN.md',
239
+ 'development_plan.md',
240
+ 'DEV_PLAN.md',
241
+ 'PLAN.md',
242
+ 'plan.md',
243
+ 'PROJECT_PLAN.md',
244
+ 'ROADMAP.md',
245
+ ]
246
+
247
+ /**
248
+ * 框架深度拆解:为"按计划实现"类多步任务生成分步执行计划(层 B 深化)
249
+ *
250
+ * 背景:小模型(27B Q3)撑不住"按计划实现第一阶段"这类多步复杂任务——
251
+ * 它常常不读计划就瞎写,或干脆敷衍。框架必须【接管规划】,把任务拆成
252
+ * 明确、可执行的步骤注入给模型。
253
+ *
254
+ * 针对"按计划实现/开发"任务,生成强制分步引导,核心是:
255
+ * 第 1 步必须 Read 计划文件(定位要实现的模块/函数);
256
+ * 后续步骤按"看现状 → 实现 → 验证"推进。
257
+ *
258
+ * @param {string} userInput — 用户指令
259
+ * @param {object} [opts]
260
+ * @param {string} [opts.cwd] — 当前工作目录(用于给计划文件相对路径)
261
+ * @param {boolean} [opts.enable=true] — 是否启用
262
+ * @returns {{ isMultiStep: boolean, plan: string } | null}
263
+ * isMultiStep=true 表示识别为多步实现任务;plan 为注入给模型的分步引导
264
+ */
265
+ export function buildMultiStepPlan(userInput, opts = {}) {
266
+ if (opts.enable === false) return null
267
+ if (!userInput) return null
268
+ const s = userInput
269
+
270
+ // 识别"按计划实现/开发"类任务
271
+ const isPlanImplement = /(按计划|依据计划|根据计划|按开发计划|按照计划).*(实现|开发|完成|写|落地|推进)/i.test(s)
272
+ || /实现.*(计划|第一阶段|阶段|模块)/i.test(s)
273
+
274
+ if (!isPlanImplement) return null
275
+
276
+ // 定位计划文件:从指令里提取文件名,否则用常见候选
277
+ let planFile = ''
278
+ const m = s.match(/([\w./-]+\.md)/i)
279
+ if (m) {
280
+ planFile = m[1]
281
+ } else {
282
+ planFile = PLAN_FILE_CANDIDATES[0] // 默认 DEVELOPMENT_PLAN.md
283
+ }
284
+ // 若非绝对路径且给了 cwd,拼成相对提示
285
+ const planRef = planFile
286
+
287
+ const plan = `[多步执行计划] 这是一个按计划实现代码的任务,请【严格按以下步骤】用工具推进,不要跳过,不要一次做太多:
288
+
289
+ 步骤 1(必做):用 Read 读取计划文件 \`${planRef}\`,从中找出用户要求实现的阶段/模块/函数(明确要做什么、涉及哪些文件、有哪些要求)。
290
+ 步骤 2:用 Glob 查看项目当前文件结构(cwd 下),用 Grep 搜索计划里提到的模块/函数是否已有代码。
291
+ 步骤 3:对每个需要实现的目标,先 Read 相关现有文件了解现状,再用 Write 创建新文件 / 用 Edit 修改已有文件来落地实现。
292
+ 步骤 4:实现完成后,用 Bash 运行测试或编译命令验证(如有),并总结实现了什么。
293
+
294
+ 严格遵守:第 1 步必须先 Read 计划文件;每一步完成后再进行下一步;不要在没有读计划的情况下直接写代码。`
295
+
296
+ return { isMultiStep: true, plan }
297
+ }
298
+
299
+ /**
300
+ * 生成注入到 system 的完整小模型引导(意图引导 + 多步计划 + cwd)
301
+ *
302
+ * 把多个引导源合并成一段,追加到首条 system 消息末尾。
303
+ *
304
+ * @param {string} userInput — 用户指令
305
+ * @param {object} [opts]
306
+ * @param {string} [opts.cwd] — 当前工作目录
307
+ * @param {boolean} [opts.enable=true]
308
+ * @returns {string|null} 合并后的引导文本
309
+ */
310
+ export function buildCombinedGuidance(userInput, opts = {}) {
311
+ if (opts.enable === false) return null
312
+ const parts = []
313
+ // 多步计划优先(复杂实现任务走深度拆解)
314
+ const multi = buildMultiStepPlan(userInput, opts)
315
+ if (multi) {
316
+ parts.push(multi.plan)
317
+ } else {
318
+ // 否则用简单意图引导
319
+ const g = buildIntentGuidance(userInput, opts)
320
+ if (g) parts.push(g)
321
+ }
322
+ if (parts.length === 0) return null
323
+ return parts.join('\n\n')
324
+ }
325
+
213
326
  /**
214
327
  * 精简工具列表(层 A)— 小模型一次看太多工具会混乱
215
328
  *