mocode-ai 1.7.4 → 1.7.6

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
@@ -259,7 +259,7 @@ The tables below are selected capabilities, not an exhaustive tool inventory. Cu
259
259
 
260
260
  | Tool | Purpose |
261
261
  | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
262
- | `read_file` | Read a file: text with line numbers (`offset` / `limit`), images (PNG/JPEG/GIF/WebP detected by magic bytes) as visual model input; other binaries are rejected instead of dumped as garbled text |
262
+ | `read_file` | Read a file: text with line numbers (`offset` / `limit`, output capped at ~32k chars per call — size ranges by content density, not line count), images (PNG/JPEG/GIF/WebP detected by magic bytes) as visual model input; other binaries are rejected instead of dumped as garbled text |
263
263
  | `write_file` | Create/overwrite a file, auto-creating parent directories; `append=true` adds to the end without re-sending the whole file |
264
264
  | `edit_file` | Precise string replacement (`old_string` must match uniquely) |
265
265
  | `run_command` | Run a foreground shell command, merging stdout+stderr, 120s default timeout; `shell=cmd\|powershell\|bash` picks the interpreter |
package/README.zh-CN.md CHANGED
@@ -258,7 +258,7 @@ agent 工作在**启动时所在的工作目录**——想让它操作某个项
258
258
 
259
259
  | 工具 | 作用 |
260
260
  | ------------- | ---------------------------------------------------------------------------------------------------------------------- |
261
- | `read_file` | 读文件:文本带行号(`offset` / `limit`);PNG/JPEG/GIF/WebP 按**魔数**识别(扩展名会说谎)并作为视觉输入回灌(4 MiB 内联上限,超限 PNG 自动降采样,`detail=low\|high` 控分辨率);其余二进制明确拒绝而非灌乱码 |
261
+ | `read_file` | 读文件:文本带行号(`offset` / `limit`,单次输出上限约 32k 字符——按内容密度规划区间而非行数);PNG/JPEG/GIF/WebP 按**魔数**识别(扩展名会说谎)并作为视觉输入回灌(4 MiB 内联上限,超限 PNG 自动降采样,`detail=low\|high` 控分辨率);其余二进制明确拒绝而非灌乱码 |
262
262
  | `screenshot` | 经用户确认后截取主显示器或整个桌面,保存 PNG 并立即交给视觉模型分析 |
263
263
  | `write_file` | 创建/覆盖文件,自动建父目录;`append=true` 在文件末尾追加,无需重发全文(分段写长文件/记日志的正确姿势) |
264
264
  | `edit_file` | 精确字符串替换(`old_string` 须唯一匹配) |
@@ -11,7 +11,7 @@ import { defaultAgentRuntimeContext } from './runtime-context.js';
11
11
  import { runModelTurn } from './model-turn.js';
12
12
  import { runToolTurn } from './tool-turn.js';
13
13
  import { createTurnLifecycle } from './turn-lifecycle.js';
14
- import { parseArgs, argumentErrorHint, isParallelTool, isParallelOrchestrationCall, isResourceLockedCall, deniedOutcome, readDiffContext, pushToolResult, } from './tool-helpers.js';
14
+ import { parseArgs, argumentErrorHint, isParallelTool, isParallelOrchestrationCall, isResourceLockedCall, deniedOutcome, readDiffContext, pushToolResult, unregisteredToolOutcome, } from './tool-helpers.js';
15
15
  import { contextState, summarizeToolArguments } from '../session/index.js';
16
16
  import { createReadDedup } from '../tools/read-dedup.js';
17
17
  import { createBudgetScheduler } from '../session/scheduler.js';
@@ -424,10 +424,14 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
424
424
  const tc = calls[index];
425
425
  const outcome = {
426
426
  status: 'denied',
427
- code: 'TOOL_DISABLED',
427
+ code: isToolDeniedForStep(tc.name) && !ctx.toolRuntime.findTool(tc.name)
428
+ ? 'UNKNOWN_TOOL'
429
+ : 'TOOL_DISABLED',
428
430
  retryable: false,
429
431
  output: isToolDeniedForStep(tc.name)
430
- ? `错误:当前 tool policy snapshot 不允许调用 ${tc.name}。`
432
+ ? !ctx.toolRuntime.findTool(tc.name)
433
+ ? unregisteredToolOutcome(tc.name, ctx.toolRuntime).output
434
+ : `错误:当前 tool policy snapshot 不允许调用 ${tc.name}。`
431
435
  : `错误:add_tool_groups 不能与写/执行工具 ${tc.name} 同批执行,本 step 已跳过该调用(其余只读调用与扩容已正常处理);请在下一 step 重试 ${tc.name}。`,
432
436
  changedFiles: [],
433
437
  durationMs: 0,
@@ -526,10 +530,14 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
526
530
  const currentCall = calls[i];
527
531
  if (isToolDeniedForStep(currentCall.name)) {
528
532
  hooks.onToolHeader?.(currentCall);
529
- const error = t('task.disabled');
533
+ // registry 中根本不存在的名字(模型幻觉/拼写错误)给专门纠错提示,
534
+ // 不再复用 task.disabled 的 /subagent 误导文案;已注册但本步不允许才用 gate 文案。
535
+ const error = !ctx.toolRuntime.findTool(currentCall.name)
536
+ ? unregisteredToolOutcome(currentCall.name, ctx.toolRuntime).output
537
+ : t('task.disabled');
530
538
  const outcome = {
531
539
  status: 'denied',
532
- code: 'TOOL_DISABLED',
540
+ code: !ctx.toolRuntime.findTool(currentCall.name) ? 'UNKNOWN_TOOL' : 'TOOL_DISABLED',
533
541
  retryable: false,
534
542
  output: error,
535
543
  changedFiles: [],
@@ -5,11 +5,19 @@ function replaceMessages(backing, messages) {
5
5
  backing.push(...replacement);
6
6
  }
7
7
  function assistantMessage(turn) {
8
+ const hasToolCalls = turn.toolCalls.length > 0;
8
9
  const message = {
9
10
  role: 'assistant',
10
- content: turn.content,
11
+ // 无 tool_calls 时 content 不能是 null:那是 OpenAI 自己吐 tool_call 时的规范形状,
12
+ // 纯文本轮次写 null 会被严格 OpenAI 兼容网关整请求 400(实测 speedbear:
13
+ // "messages[N].content 不能为 null … 无文本请传空字符串"),且消息一旦落进 history
14
+ // 就每轮重发 → 整个会话永久中毒,换个会话同模型却正常。来源是 SSE 流中途结束
15
+ // (网关掐断)时 SDK 不抛错、content 停在 null 而 tool_calls 为空,框架仍当
16
+ // 「agent 完成了这一轮」写盘。构造点在此收敛,transport 边界另有兜底
17
+ // (src/llm/message-shape.ts)负责让存量中毒会话自愈。
18
+ content: !hasToolCalls && turn.content === null ? '' : turn.content,
11
19
  };
12
- if (turn.toolCalls.length > 0) {
20
+ if (hasToolCalls) {
13
21
  // thoughtSignature 不是 OpenAI 字段,是 mocode 给 Gemini/Vertex 透传用的;
14
22
  // 挂在 tool_call 上随 history 落盘/回灌,Vertex encoder 会读回它。
15
23
  message.tool_calls = turn.toolCalls.map((call) => ({
@@ -7,7 +7,7 @@ import { summarizeToolArguments } from '../../session/index.js';
7
7
  import { getPlanDisabledTools } from '../../tools/constants.js';
8
8
  import { defaultToolRuntime } from '../../tools/registry.js';
9
9
  import { validateToolArguments } from '../../tools/validation.js';
10
- import { deniedOutcome, isParallelTool, isParallelOrchestrationCall, isResourceLockedCall, parseArgs, readDiffContext, } from '../tool-helpers.js';
10
+ import { deniedOutcome, isParallelTool, isParallelOrchestrationCall, isResourceLockedCall, parseArgs, readDiffContext, unregisteredToolOutcome, } from '../tool-helpers.js';
11
11
  const DEFAULT_DEPENDENCIES = {
12
12
  toolRuntime: defaultToolRuntime,
13
13
  checkPermission: defaultCheckPermission,
@@ -119,10 +119,12 @@ class LegacyCompatibleToolDispatcher {
119
119
  const call = calls[index];
120
120
  const outcome = {
121
121
  status: 'denied',
122
- code: 'TOOL_DISABLED',
122
+ code: request.isDenied(call.name) && !toolRuntime.findTool(call.name) ? 'UNKNOWN_TOOL' : 'TOOL_DISABLED',
123
123
  retryable: false,
124
124
  output: request.isDenied(call.name)
125
- ? `错误:当前 tool policy snapshot 不允许调用 ${call.name}。`
125
+ ? !toolRuntime.findTool(call.name)
126
+ ? unregisteredToolOutcome(call.name, toolRuntime).output
127
+ : `错误:当前 tool policy snapshot 不允许调用 ${call.name}。`
126
128
  : `错误:add_tool_groups 不能与写/执行工具 ${call.name} 同批执行,本 step 已跳过该调用(其余只读调用与扩容已正常处理);请在下一 step 重试 ${call.name}。`,
127
129
  changedFiles: [],
128
130
  durationMs: 0,
@@ -213,10 +215,12 @@ class LegacyCompatibleToolDispatcher {
213
215
  const current = calls[index];
214
216
  if (request.isDenied(current.name)) {
215
217
  request.onEvent({ type: 'header', call: current });
216
- const output = t('task.disabled');
218
+ // registry 中不存在的名字(幻觉/拼写错误)给纠错提示,不误导用户去开 /subagent。
219
+ const unregistered = !toolRuntime.findTool(current.name);
220
+ const output = unregistered ? unregisteredToolOutcome(current.name, toolRuntime).output : t('task.disabled');
217
221
  const outcome = {
218
222
  status: 'denied',
219
- code: 'TOOL_DISABLED',
223
+ code: unregistered ? 'UNKNOWN_TOOL' : 'TOOL_DISABLED',
220
224
  retryable: false,
221
225
  output,
222
226
  changedFiles: [],
@@ -16,6 +16,7 @@ import { capToolResultForHistory } from '../session/compact.js';
16
16
  import { recordArtifact, knownEditTargets } from '../context/index.js';
17
17
  import { isToolResultSuccess } from '../context/utils.js';
18
18
  import { appendGuiAction } from '../session/gui-actions.js';
19
+ import { t } from '../i18n/index.js';
19
20
  /** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
20
21
  export function parseArgs(raw) {
21
22
  try {
@@ -76,6 +77,52 @@ export function deniedOutcome(name) {
76
77
  output: `错误:用户拒绝了工具 ${name} 的执行。`,
77
78
  };
78
79
  }
80
+ /** 编辑距离(Levenshtein);名字很短时用上限截断避免 O(n²) 浪费。 */
81
+ function editDistance(a, b, max = 4) {
82
+ if (Math.abs(a.length - b.length) > max)
83
+ return max + 1;
84
+ const prev = new Array(b.length + 1);
85
+ const curr = new Array(b.length + 1);
86
+ for (let j = 0; j <= b.length; j++)
87
+ prev[j] = j;
88
+ for (let i = 1; i <= a.length; i++) {
89
+ curr[0] = i;
90
+ let rowMin = curr[0];
91
+ for (let j = 1; j <= b.length; j++) {
92
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
93
+ if (curr[j] < rowMin)
94
+ rowMin = curr[j];
95
+ }
96
+ if (rowMin > max)
97
+ return max + 1;
98
+ for (let j = 0; j <= b.length; j++)
99
+ prev[j] = curr[j];
100
+ }
101
+ return prev[b.length];
102
+ }
103
+ /**
104
+ * 未注册工具名(模型幻觉/拼写错误)的专门 denied 结果。
105
+ * isToolDeniedForStep 对不在 allowedToolNames 里的一切名字(含 registry 中不存在的)都返回
106
+ * true,旧路径一律复用 task.disabled 文案,误导用户去开 /subagent。此 helper 先查 registry:
107
+ * 名字根本不存在时给出「未注册 + 相近候选」的纠错提示;存在但本步不允许时才回落 gate 文案。
108
+ */
109
+ export function unregisteredToolOutcome(name, toolRuntime) {
110
+ const registered = toolRuntime.tools.map((tool) => tool.name);
111
+ const ranked = registered
112
+ .map((candidate) => ({
113
+ candidate,
114
+ distance: editDistance(name.toLowerCase(), candidate.toLowerCase()),
115
+ }))
116
+ .sort((x, y) => x.distance - y.distance);
117
+ const close = ranked.filter((entry) => entry.distance <= 3).slice(0, 3);
118
+ const suggestions = close.length > 0 ? close.map((entry) => entry.candidate).join(', ') : t('toolError.noCloseMatch');
119
+ return {
120
+ status: 'denied',
121
+ code: 'UNKNOWN_TOOL',
122
+ retryable: false,
123
+ output: t('toolError.unregistered', { name, suggestions }),
124
+ };
125
+ }
79
126
  /** mutation 执行前读旧内容供 diff:write_file 取整文件旧内容(不存在→null=新建),
80
127
  * edit_file 取 old_string 起始行号(供 diff 显示真实文件行号)。读不到则 diff 退化为相对行号。
81
128
  * 非 mutation 或参数非法返 { preWriteOld: null, editStartLine: 1 }。失败不阻断。 */
@@ -241,6 +241,8 @@ const zhCN = {
241
241
  'toolSummary.noOutput': '(无输出)',
242
242
  'toolSummary.imageAttached': '图片已作为视觉输入附加',
243
243
  'toolError.unknown': '错误:未知工具 "{name}"',
244
+ 'toolError.unregistered': '错误:工具 "{name}" 未注册(不在本会话可用工具列表中,可能是拼写错误或模型幻觉出的名称)。相近的已注册工具:{suggestions}。请只调用 tools 列表中存在的名称,不要自造工具名。',
245
+ 'toolError.noCloseMatch': '无相近的已注册工具名',
244
246
  'toolError.invalidJson': '错误:工具 {name} 的 arguments 不是合法 JSON: {arguments}',
245
247
  'toolError.execution': '错误:工具 {name} 执行失败: {message}',
246
248
  'command.outputTruncated': '...(输出已截断 {count} 字符,保留开头与结尾)...',
@@ -624,6 +626,8 @@ const en = {
624
626
  'toolSummary.noOutput': '(no output)',
625
627
  'toolSummary.imageAttached': 'Image attached as visual input',
626
628
  'toolError.unknown': 'Error: unknown tool "{name}"',
629
+ 'toolError.unregistered': 'Error: tool "{name}" is not registered (it is not in this session\'s available tool list — likely a misspelling or a model-hallucinated name). Closest registered tools: {suggestions}. Only call names that appear in the tools list; never invent tool names.',
630
+ 'toolError.noCloseMatch': 'no close match among registered tools',
627
631
  'toolError.invalidJson': 'Error: arguments for tool {name} are not valid JSON: {arguments}',
628
632
  'toolError.execution': 'Error: tool {name} failed: {message}',
629
633
  'command.outputTruncated': '...(output truncated by {count} characters; keeping the beginning and end)...',
package/dist/llm/index.js CHANGED
@@ -5,6 +5,7 @@ import { getPlanDisabledTools, getProfileDisabledTools } from '../tools/constant
5
5
  import { imageSizeFromDataUrl } from '../attachments/image.js';
6
6
  import { ThinkTagFilter } from './think-filter.js';
7
7
  import { sanitizeToolSchemas } from './tool-schema.js';
8
+ import { sanitizeAssistantContent } from './message-shape.js';
8
9
  import { isMarkedStreamInterrupted } from './stream-interrupt.js';
9
10
  import { defaultAnthropicFetch, anthropicChatOnce } from './providers/anthropic.js';
10
11
  import { vertexChatOnce } from './providers/vertex.js';
@@ -676,7 +677,7 @@ async function chatOnce(messages, handlers, signal, toolsOverride, runtime = def
676
677
  });
677
678
  const stream = await create({
678
679
  model: runtime.getModel(),
679
- messages: normalizeImageDetail(messages),
680
+ messages: sanitizeAssistantContent(normalizeImageDetail(messages)),
680
681
  stream: true,
681
682
  // 空工具表(如摘要请求)不发 tools 字段——部分网关拒绝空数组。
682
683
  ...(activeTools.length > 0
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 把「无文本、无 tool_calls」的 assistant 消息的 `content: null` 改写成空字符串。
3
+ *
4
+ * 背景:`content: null` 在 assistant 消息上**只在携带 tool_calls 时合法**(那是 OpenAI 自己
5
+ * 吐 tool_call 时的规范形状)。纯文本轮次写成 null 会被严格 OpenAI 兼容网关整请求拒绝。
6
+ * speedbear(https://speedbear.redbearai.com/api/v1)实测:
7
+ * `{"error_code":"REQUEST_VALIDATION_FAILED",
8
+ * "detail":"messages[12].content 不能为 null(assistant 消息仅在携带 tool_calls/
9
+ * function_call 时允许省略 content;无文本请传空字符串 \"\")"}`
10
+ * 且形状探针逐条确认:`null`+tool_calls → 200;`content:[]` → 200;缺 content 字段 → 200;
11
+ * `content:""` → 200;连续 user 消息 → 200。**只有「null 且无 tool_calls」这一种被拒。**
12
+ *
13
+ * 在 agent 侧的表现极具迷惑性——「某个会话从某一轮起每一步都 400、换个会话同模型却正常」:
14
+ * 毒消息一旦写进 history 就会每轮重发,而它是**合法 JSON、合法 OpenAI 类型**,肉眼 review
15
+ * 抓不到;`/resume` 也救不回来(毒在落盘文件里)。更隐蔽的是它的来源:SSE 流中途结束
16
+ * (网关超时/上游掐断)时 SDK 不抛错,`content` 停在 null、tool_calls 为空,
17
+ * 框架把它当「agent 完成了这一轮」照写 history——于是一步正常的历史被永久污染。
18
+ *
19
+ * 与 `sanitizeToolSchemas`(剔 uniqueItems)同源同策:**放 transport 边界而非只修构造点**——
20
+ * 已经被旧版本写进 session.json 的中毒会话、外部注入的历史、resume 续接的会话都能自愈,
21
+ * 只修构造点救不了存量。构造点(`agent/stages/history-manager.ts`)同步收敛,保证新历史
22
+ * 不再生产这种形状,两层各司其职。
23
+ *
24
+ * 无需改写时返回**原数组引用**——history 参与前缀缓存,逐字节稳定才能命中;
25
+ * 每次新建对象会击穿缓存(同 `sanitizeToolSchemas` 的返回值约定)。
26
+ */
27
+ export function sanitizeAssistantContent(messages) {
28
+ let changed = false;
29
+ const next = messages.map((message) => {
30
+ if (message.role !== 'assistant')
31
+ return message;
32
+ const raw = message;
33
+ // 带 tool_calls 时 null 是规范形状(OpenAI 自己就这么吐),原样保留。
34
+ if (Array.isArray(raw.tool_calls) && raw.tool_calls.length > 0)
35
+ return message;
36
+ if (raw.content !== null)
37
+ return message;
38
+ changed = true;
39
+ return { ...message, content: '' };
40
+ });
41
+ return changed ? next : messages;
42
+ }
@@ -693,7 +693,7 @@ export async function compactHistory(history, opts) {
693
693
  }
694
694
  let oldGroups = groups.slice(0, groups.length - kept.length);
695
695
  // 非空 user 兜底:保留区从尾部累积,长回合里最近的若干组常常全是 assistant+tool,
696
- // 一个 8000 字符的工具结果就能吃满小窗口的 keepBudget → user 全落进旧区被摘要掉 →
696
+ // 一个 MAX_HISTORY_RESULT 量级的工具结果就能吃满小窗口的 keepBudget → user 全落进旧区被摘要掉 →
697
697
  // 重建后 history 无 user → 下一步 chat() 被 hasNonEmptyUser 守卫直接拒(整轮中断)。
698
698
  // 把紧邻保留区之前的那条 user 移进保留区:它是当前任务的原始请求,且 user 消息本身极小。
699
699
  // 放这里(而非重建后补 stub)是因为:原文保留,不造新消息形状,也不必进摘要器。
@@ -1,7 +1,7 @@
1
1
  import { readFile, stat } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
3
  import { contentHash } from '../../changeset/index.js';
4
- import { IMAGE_READ_MARKER, MAX_FILE_LINES } from '../constants.js';
4
+ import { IMAGE_READ_MARKER, MAX_FILE_LINES, MAX_HISTORY_RESULT } from '../constants.js';
5
5
  import { MAX_INLINE_BYTES_DEFAULT, isProbablyBinary, loadImageAttachmentWithDownscale, sniffImageMime, } from '../../attachments/image.js';
6
6
  import { config } from '../../config/index.js';
7
7
  /** 默认单次 read_file 拉取的行数。刻意压低,逼 LLM 分块读大文件,
@@ -47,7 +47,7 @@ export const readFileTool = {
47
47
  offset: { type: 'integer', description: 'Start line, 1-based (default 1). Text files only; ignored for images.' },
48
48
  limit: {
49
49
  type: 'integer',
50
- description: 'Max lines to read (default 300, hard cap 2000). Keep ranges modest (e.g. 80-300); for files ≤500 lines you may read the whole file in one call. Text only; ignored for images.',
50
+ description: 'Max lines to read (default 300, hard cap 2000). Size by OUTPUT, not lines: outputs are capped at ~32k chars per call, so a dense (CJK) file fits ~200-400 lines in one call while sparse code fits 1000+. If a range exceeds that, split it; the output tail always reports how many lines remain. Text only; ignored for images.',
51
51
  },
52
52
  detail: {
53
53
  type: 'string',
@@ -142,10 +142,61 @@ export const readFileTool = {
142
142
  const output = end < lines.length
143
143
  ? `${artifactHeader}\n${body}\n\n... (${lines.length - end} 行未显示,共 ${lines.length} 行)`
144
144
  : `${artifactHeader}\n${body}`;
145
- dedup?.record(path, reqStart, reqEnd, contentHash(data));
145
+ // dedup 只登记「确实会进 context 的区间」。pushToolResult 会把超长输出经
146
+ // capToolResultForHistory 中截到 MAX_HISTORY_RESULT(丢中段)——若此处仍登记
147
+ // 完整 [reqStart, reqEnd],后续对「被截掉的中段」的重读会被 findUnchanged 命中,
148
+ // 模型拿到 unchanged 短指针却找不到内容,只能反复缩小区间撞墙、最终用 shell 绕过
149
+ // (实测事故:会话 20261012-033757)。故输出超 cap 时只登记头/尾两段实际保留的行。
150
+ if (dedup && config.readDedup && output.length > MAX_HISTORY_RESULT) {
151
+ const fileHash = contentHash(data);
152
+ const truncated = truncatedOutputRanges(lines, start, end, output.length, MAX_HISTORY_RESULT);
153
+ for (const r of truncated)
154
+ dedup.record(path, r.start, r.end, fileHash);
155
+ }
156
+ else {
157
+ dedup?.record(path, reqStart, reqEnd, contentHash(data));
158
+ }
146
159
  return { status: 'success', code: 'OK', retryable: false, output };
147
160
  },
148
161
  };
162
+ /**
163
+ * 输出将被中截(头 60% / 尾 40%)时,反推真正保留进 context 的行区间。
164
+ * 输出长度按行聚合估算:行号前缀(≤7 字符 + tab)与换行在各行间均摊,误差 ≤1 行,
165
+ * 对 dedup 区间判定足够保守(宁可少记、不可多记——漏记只会多一次全量返回,多记会造成幽灵命中)。
166
+ */
167
+ function truncatedOutputRanges(lines, start, end, outputLen, cap) {
168
+ const marker = `…[已截断 ${outputLen - cap} 字符]…`;
169
+ const remain = cap - marker.length;
170
+ const headChars = Math.ceil(remain * 0.6);
171
+ const tailChars = remain - headChars;
172
+ // 头段:从 start 起累加行,直至耗尽 headChars(含 artifact header 的固定开销,按保守处理:header 不占行预算)。
173
+ const lineLen = (i) => `${String(i + 1).padStart(6, ' ')}\t${lines[i]}`.length + 1;
174
+ const ranges = [];
175
+ let acc = 0;
176
+ let headEnd = start - 1;
177
+ for (let i = start; i < end; i++) {
178
+ const l = lineLen(i);
179
+ if (acc + l > headChars)
180
+ break;
181
+ acc += l;
182
+ headEnd = i;
183
+ }
184
+ if (headEnd >= start)
185
+ ranges.push({ start: start + 1, end: headEnd + 1 });
186
+ // 尾段:从 end-1 反向累加,直至耗尽 tailChars。区间下限不得超过头段结尾(头尾重叠说明整体保留)。
187
+ acc = 0;
188
+ let tailStart = end;
189
+ for (let i = end - 1; i >= start; i--) {
190
+ const l = lineLen(i);
191
+ if (acc + l > tailChars)
192
+ break;
193
+ acc += l;
194
+ tailStart = i;
195
+ }
196
+ if (tailStart < end && tailStart > headEnd)
197
+ ranges.push({ start: tailStart + 1, end });
198
+ return ranges;
199
+ }
149
200
  /**
150
201
  * 图片分支:走视觉通道(modelAttachments),文本 output 只留一句可读摘要。
151
202
  *
@@ -12,8 +12,13 @@ export const MAX_RESULTS = 100;
12
12
  * 从「N 行」改成「图片已附加」,而 render.ts → builtins → registry 会绕出模块循环。
13
13
  */
14
14
  export const IMAGE_READ_MARKER = '[image]';
15
- /** 进 history 的单条工具结果上限(字符)。push-time 第一层裁剪,保 head + 标记 + tail。 */
16
- export const MAX_HISTORY_RESULT = 8000;
15
+ /**
16
+ * 进 history 的单条工具结果上限(字符)。push-time 第一层裁剪,保 head + 标记 + tail。
17
+ * 32000 ≈ 9k~19k token(视内容密度),对 256k 窗口 <8%:足以让「一次读一个完整大文件/大段代码」
18
+ * 完整进上下文(旧值 8000 只覆盖 25~30% 窗口占比 1~2%,曾迫使模型分批小读 + 引发幽灵区间事故)。
19
+ * 防爆责任后移给分层清理:微压缩存根 → 60% 低压(REFETCHABLE)→ 80% 高压 LLM 摘要。
20
+ */
21
+ export const MAX_HISTORY_RESULT = 32000;
17
22
  /** use_skill 结果(SKILL.md 正文)的放宽上限:指令须完整,中截会破坏语义。 */
18
23
  export const MAX_SKILL_RESULT = 64000;
19
24
  /** 微压缩时旧工具结果截到的存根长度(字符)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.7.4",
3
+ "version": "1.7.6",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 27 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {