mocode-ai 1.7.10 → 1.8.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/dist/agent/index.js +50 -14
- package/dist/agent/run-coordinator.js +10 -4
- package/dist/agent/stages/tool-dispatcher.js +1 -0
- package/dist/agent/tool-helpers.js +55 -10
- package/dist/config/file.js +11 -3
- package/dist/config/index.js +16 -3
- package/dist/config/presets.js +9 -0
- package/dist/headless.js +8 -1
- package/dist/index.js +1 -1
- package/dist/llm/index.js +10 -1
- package/dist/llm/providers/anthropic.js +9 -1
- package/dist/llm/providers/vertex.js +7 -1
- package/dist/memory/graph.js +262 -1
- package/dist/memory/index.js +3 -1
- package/dist/memory/persona.js +142 -0
- package/dist/memory/recall-stats.js +61 -0
- package/dist/memory/recall.js +47 -0
- package/dist/memory/reflect.js +112 -17
- package/dist/memory/relation-vocab.js +84 -0
- package/dist/models/map-preset.js +4 -0
- package/dist/repl/commands/model-actions.js +3 -0
- package/dist/repl/commands/model.js +39 -3
- package/dist/repl/runtime.js +13 -4
- package/dist/runtime/runtime.js +1 -1
- package/dist/session/compact-archive.js +232 -0
- package/dist/session/compact.js +19 -2
- package/dist/session/persist.js +4 -3
- package/dist/session/retention.js +33 -2
- package/dist/session/store.js +187 -3
- package/dist/tools/builtins/edit-file.js +110 -22
- package/dist/tools/builtins/grep.js +5 -2
- package/dist/tools/builtins/memory-graph.js +2 -0
- package/dist/tools/builtins/memory-search.js +20 -14
- package/dist/tools/builtins/write-file.js +4 -2
- package/dist/tools/constants.js +4 -0
- package/dist/tools/ripgrep-client.js +11 -1
- package/dist/tools/tool-runtime.js +65 -2
- package/dist/ui/diff.js +68 -4
- package/package.json +1 -1
|
@@ -16,10 +16,12 @@ function conflict(path, details) {
|
|
|
16
16
|
}
|
|
17
17
|
export const editFileTool = {
|
|
18
18
|
name: 'edit_file',
|
|
19
|
-
description: `
|
|
19
|
+
description: `The DEFAULT tool for changing an EXISTING file: transactional, surgical edits — use this instead of re-writing the whole file with write_file. Pick ONE of three modes per call:
|
|
20
20
|
|
|
21
21
|
**String replacement (default):** old_string must occur EXACTLY once; copy it verbatim from a fresh read_file output (see Tool policy — never reconstruct it from memory, summaries, or grep output). expected_hash is OPTIONAL in this mode: omit it and the tool checks the file itself (old_string must still match exactly once, and the commit is conflict-checked). If you do pass it, it must match the current file or the edit is rejected.
|
|
22
22
|
|
|
23
|
+
**Multiple replacements:** pass replacements: [{ old_string, new_string }, …] to apply MANY edits across the file in ONE atomic call. Each old_string must occur exactly once in the original file and the matches must not overlap; if ANY of them is missing, ambiguous, or overlapping, the whole call is rejected and nothing is written. Prefer this over write_file whenever you change several spots — it keeps each payload small and never touches unrelated content. Mutually exclusive with the single old_string and line-range modes.
|
|
24
|
+
|
|
23
25
|
**Line-range:** line_start/line_end (1-based, inclusive) instead of old_string — for large blocks, repeated patterns, or hard-to-reproduce text. Line numbers drift when a file changes, so expected_hash (sha256 from the read_file artifact header) is REQUIRED here; if it is stale, re-read and retry with the new hash.`,
|
|
24
26
|
risk: 'confirm',
|
|
25
27
|
parameters: {
|
|
@@ -28,9 +30,25 @@ export const editFileTool = {
|
|
|
28
30
|
path: { type: 'string', description: 'Absolute file path.' },
|
|
29
31
|
old_string: {
|
|
30
32
|
type: 'string',
|
|
31
|
-
description: '
|
|
33
|
+
description: 'Single-replacement mode: the exact text to replace (must occur once). Mutually exclusive with replacements[] and line_start/line_end.',
|
|
34
|
+
},
|
|
35
|
+
new_string: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'Single-replacement / line-range mode: the replacement text.',
|
|
38
|
+
},
|
|
39
|
+
replacements: {
|
|
40
|
+
type: 'array',
|
|
41
|
+
minItems: 1,
|
|
42
|
+
description: 'Multi-replacement mode: many edits applied ATOMICALLY in one call. Each old_string must occur exactly once in the current file and matches must not overlap; if any item is missing/ambiguous/overlapping the whole call is rejected and nothing is written. Mutually exclusive with old_string and line_start/line_end.',
|
|
43
|
+
items: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
old_string: { type: 'string', description: 'Exact text to replace (must occur once).' },
|
|
47
|
+
new_string: { type: 'string', description: 'Its replacement.' },
|
|
48
|
+
},
|
|
49
|
+
required: ['old_string', 'new_string'],
|
|
50
|
+
},
|
|
32
51
|
},
|
|
33
|
-
new_string: { type: 'string', description: 'The replacement text.' },
|
|
34
52
|
line_start: {
|
|
35
53
|
type: 'integer',
|
|
36
54
|
description: 'Line-range mode: start line (1-based, inclusive). Mutually exclusive with old_string.',
|
|
@@ -41,28 +59,64 @@ export const editFileTool = {
|
|
|
41
59
|
},
|
|
42
60
|
expected_hash: {
|
|
43
61
|
type: 'string',
|
|
44
|
-
description: 'sha256 hash from the latest read_file artifact header (sha256:<64 hex>). Optional for old_string
|
|
62
|
+
description: 'sha256 hash from the latest read_file artifact header (sha256:<64 hex>). Optional for old_string / replacements modes; required for line-range mode.',
|
|
45
63
|
},
|
|
46
64
|
},
|
|
47
|
-
// 注意:expected_hash
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
required: ['path', 'new_string'],
|
|
65
|
+
// 注意:expected_hash 与模式专属字段(new_string / replacements / line_*)都不放进 schema required。
|
|
66
|
+
// 原因:三模式互斥,必填字段取决于所选模式;AJV 只能表达「静态必填」,在 schema 层拦下只会得到
|
|
67
|
+
// 泛化的「缺少必填字段 X」,模型据此只补该字段、反而丢掉 path,形成乒乓失败。故只在 execute 内
|
|
68
|
+
// 按模式校验,缺失/互斥时返回富指导信息。
|
|
69
|
+
required: ['path'],
|
|
53
70
|
},
|
|
54
71
|
async execute(args, ctx) {
|
|
55
72
|
const file = String(args.path);
|
|
56
|
-
const newString = String(args.new_string);
|
|
57
73
|
const hasOldString = args.old_string !== undefined;
|
|
58
74
|
const hasLineStart = args.line_start !== undefined;
|
|
59
75
|
const hasLineEnd = args.line_end !== undefined;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
76
|
+
const hasReplacements = args.replacements !== undefined;
|
|
77
|
+
const hasLineRange = hasLineStart || hasLineEnd;
|
|
78
|
+
// 三模式互斥:old_string(single)/ replacements[](multi)/ line_start+line_end(line),一次恰好一种。
|
|
79
|
+
const modeCount = (hasOldString ? 1 : 0) + (hasReplacements ? 1 : 0) + (hasLineRange ? 1 : 0);
|
|
80
|
+
if (modeCount > 1) {
|
|
81
|
+
return conflict(file, 'old_string、replacements[]、line_start/line_end 三种模式互斥,一次只能用一种。');
|
|
82
|
+
}
|
|
83
|
+
if (hasLineStart !== hasLineEnd) {
|
|
84
|
+
return conflict(file, '行号模式必须同时提供 line_start 和 line_end。');
|
|
85
|
+
}
|
|
86
|
+
let mode;
|
|
87
|
+
if (hasOldString)
|
|
88
|
+
mode = 'single';
|
|
89
|
+
else if (hasReplacements)
|
|
90
|
+
mode = 'multi';
|
|
91
|
+
else if (hasLineStart && hasLineEnd)
|
|
92
|
+
mode = 'line';
|
|
93
|
+
else {
|
|
94
|
+
return conflict(file, '必须提供 old_string、replacements[] 或同时提供 line_start 和 line_end 三者之一。');
|
|
63
95
|
}
|
|
64
|
-
if (
|
|
65
|
-
return conflict(file, '
|
|
96
|
+
if ((mode === 'single' || mode === 'line') && typeof args.new_string !== 'string') {
|
|
97
|
+
return conflict(file, '必须提供字符串 new_string(要删除该段时传空串 "")。');
|
|
98
|
+
}
|
|
99
|
+
const toLf = (s) => s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
100
|
+
// multi:数组形状校验(schema 已守,这里给富指导并归一化 CRLF)。
|
|
101
|
+
let multiEdits = [];
|
|
102
|
+
if (mode === 'multi') {
|
|
103
|
+
const arr = args.replacements;
|
|
104
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
105
|
+
return conflict(file, 'replacements 必须是至少含 1 个 {old_string,new_string} 的数组。');
|
|
106
|
+
}
|
|
107
|
+
for (let i = 0; i < arr.length; i++) {
|
|
108
|
+
const item = arr[i];
|
|
109
|
+
if (!item ||
|
|
110
|
+
typeof item !== 'object' ||
|
|
111
|
+
typeof item.old_string !== 'string' ||
|
|
112
|
+
typeof item.new_string !== 'string') {
|
|
113
|
+
return conflict(file, `replacements[${i}] 必须是含字符串字段 old_string/new_string 的对象。`);
|
|
114
|
+
}
|
|
115
|
+
const rec = item;
|
|
116
|
+
if (!rec.old_string)
|
|
117
|
+
return conflict(file, `replacements[${i}].old_string 不能为空。`);
|
|
118
|
+
multiEdits.push({ old: toLf(rec.old_string), next: toLf(rec.new_string) });
|
|
119
|
+
}
|
|
66
120
|
}
|
|
67
121
|
// expected_hash:缺省/null/空串都视为「未提供」。提供了就必须格式正确并严格校验。
|
|
68
122
|
const rawHash = args.expected_hash;
|
|
@@ -70,7 +124,7 @@ export const editFileTool = {
|
|
|
70
124
|
let expectedHash = hashProvided ? normalizeContentHash(String(rawHash)) : null;
|
|
71
125
|
if (hashProvided && !expectedHash)
|
|
72
126
|
return conflict(file, 'expected_hash 必须是 sha256:<64 hex>。');
|
|
73
|
-
if (
|
|
127
|
+
if (mode === 'line' && !expectedHash) {
|
|
74
128
|
return conflict(file, '行号模式必须提供 expected_hash(行号会随文件变化漂移,需要内容版本锚点)。');
|
|
75
129
|
}
|
|
76
130
|
// 以 Buffer 读取并按 Buffer 算 hash,与 ChangeSet 内部口径一致(非法 UTF-8 时字符串 hash 会对不上)。
|
|
@@ -83,13 +137,12 @@ export const editFileTool = {
|
|
|
83
137
|
if (!expectedHash)
|
|
84
138
|
expectedHash = contentHash(buffer);
|
|
85
139
|
const data = buffer.toString('utf8');
|
|
86
|
-
const normalized = data
|
|
87
|
-
const newNormalized = newString.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
140
|
+
const normalized = toLf(data);
|
|
88
141
|
let updated;
|
|
89
|
-
if (
|
|
142
|
+
if (mode === 'single') {
|
|
90
143
|
// String replacement mode
|
|
91
|
-
const
|
|
92
|
-
const oldNormalized =
|
|
144
|
+
const newNormalized = toLf(String(args.new_string));
|
|
145
|
+
const oldNormalized = toLf(String(args.old_string));
|
|
93
146
|
const count = normalized.split(oldNormalized).length - 1;
|
|
94
147
|
if (count === 0) {
|
|
95
148
|
return conflict(file, 'old_string 未找到;请重新 read_file 并复制最新内容。');
|
|
@@ -99,8 +152,43 @@ export const editFileTool = {
|
|
|
99
152
|
}
|
|
100
153
|
updated = normalized.replace(oldNormalized, () => newNormalized);
|
|
101
154
|
}
|
|
155
|
+
else if (mode === 'multi') {
|
|
156
|
+
// Multiple replacements mode:所有 old_string 先在「原始文件」上一次性定位,
|
|
157
|
+
// 各自必须唯一命中、区间互不重叠;任何一项不满足整体拒绝、一条都不改(原子)。
|
|
158
|
+
const located = [];
|
|
159
|
+
for (let i = 0; i < multiEdits.length; i++) {
|
|
160
|
+
const edit = multiEdits[i];
|
|
161
|
+
const hits = [];
|
|
162
|
+
let from = 0;
|
|
163
|
+
let hit;
|
|
164
|
+
// 空串已在上面拒绝;indexOf 步进至少 1 不会死循环。
|
|
165
|
+
while ((hit = normalized.indexOf(edit.old, from)) !== -1) {
|
|
166
|
+
hits.push(hit);
|
|
167
|
+
from = hit + edit.old.length;
|
|
168
|
+
}
|
|
169
|
+
if (hits.length === 0) {
|
|
170
|
+
return conflict(file, `replacements[${i}].old_string 未找到;请重新 read_file 并复制最新内容。`);
|
|
171
|
+
}
|
|
172
|
+
if (hits.length > 1) {
|
|
173
|
+
return conflict(file, `replacements[${i}].old_string 出现 ${hits.length} 次;请增加上下文使其唯一。`);
|
|
174
|
+
}
|
|
175
|
+
located.push({ start: hits[0], end: hits[0] + edit.old.length, next: edit.next });
|
|
176
|
+
}
|
|
177
|
+
located.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
178
|
+
for (let i = 1; i < located.length; i++) {
|
|
179
|
+
if (located[i].start < located[i - 1].end) {
|
|
180
|
+
return conflict(file, 'replacements[] 的命中区间互相重叠;请拆分或合并这些编辑后重试(本次未写入)。');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// 定位全部通过后才应用:从后向前按区间替换,偏移不会因前序改动而失效。
|
|
184
|
+
updated = normalized;
|
|
185
|
+
for (let i = located.length - 1; i >= 0; i--) {
|
|
186
|
+
updated = updated.slice(0, located[i].start) + located[i].next + updated.slice(located[i].end);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
102
189
|
else {
|
|
103
190
|
// Line-range mode
|
|
191
|
+
const newNormalized = toLf(String(args.new_string));
|
|
104
192
|
const lineStart = Number(args.line_start);
|
|
105
193
|
const lineEnd = Number(args.line_end);
|
|
106
194
|
if (!Number.isInteger(lineStart) || !Number.isInteger(lineEnd)) {
|
|
@@ -160,7 +160,8 @@ export const grepTool = {
|
|
|
160
160
|
name: 'grep',
|
|
161
161
|
description: 'Search file contents by regex (recursive; powered by ripgrep: respects .gitignore, skips binaries).\n' +
|
|
162
162
|
'Output: per-file header "<path>: N matches, lines [l1, l2, ...]" plus rendered lines with ORIGINAL INDENTATION kept.\n' +
|
|
163
|
-
'Pass context=2..5 to include neighbouring lines inline (like ripgrep -C) instead of following each hit with a read_file round-trip. Hard wall 120s; narrow the glob if a tree contains network mounts. Use read_file when you need a whole region or exact edit text
|
|
163
|
+
'Pass context=2..5 to include neighbouring lines inline (like ripgrep -C) instead of following each hit with a read_file round-trip. Hard wall 120s; narrow the glob if a tree contains network mounts. Use read_file when you need a whole region or exact edit text.\n' +
|
|
164
|
+
'Note: .mocode/ is gitignored; to search session data (e.g. .mocode/sessions/<id>/compacted archived history) you MUST narrow glob to that path, e.g. glob=".mocode/sessions/**".',
|
|
164
165
|
parameters: {
|
|
165
166
|
type: 'object',
|
|
166
167
|
properties: {
|
|
@@ -200,7 +201,9 @@ export const grepTool = {
|
|
|
200
201
|
controller.abort();
|
|
201
202
|
}, GREP_TIMEOUT_MS);
|
|
202
203
|
try {
|
|
203
|
-
|
|
204
|
+
// glob 显式限定到 .mocode(gitignore 内)时放开 VCS ignore,否则 rg 零文件退出。
|
|
205
|
+
const noIgnoreVcs = glob === '.mocode/**' || glob.startsWith('.mocode/');
|
|
206
|
+
const result = await runRipgrep({ pattern, glob, context, noIgnoreVcs }, controller.signal);
|
|
204
207
|
switch (result.status) {
|
|
205
208
|
case 'regex_error':
|
|
206
209
|
return `错误:非法正则 ${pattern}: ${result.message}`;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { addTriple, graphStats, neighborsOf, pathBetween } from '../../memory/graph.js';
|
|
2
|
+
import { formatRecallStats } from '../../memory/recall-stats.js';
|
|
2
3
|
// ---------- memory_graph ----------
|
|
3
4
|
// 知识图谱维护工具:邻居遍历(1-3 跳,可 relation 过滤)、两点最短路径、手工加三元组、看图统计。
|
|
4
5
|
// 关键词搜索已并入 memory_search(条目 + 图谱事实一次返回),本工具不再提供 search。
|
|
@@ -55,6 +56,7 @@ export const memoryGraphTool = {
|
|
|
55
56
|
`实体 ${s.entities} 条边(active ${s.edgesActive} / 已失效 ${s.edgesInvalid})`,
|
|
56
57
|
`project: ${s.byScope.project.entities} 实体, ${s.byScope.project.edges} 边`,
|
|
57
58
|
`global: ${s.byScope.global.entities} 实体, ${s.byScope.global.edges} 边`,
|
|
59
|
+
formatRecallStats(),
|
|
58
60
|
].join('\n');
|
|
59
61
|
}
|
|
60
62
|
if (action === 'neighbors') {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { searchEntries } from '../../memory/store.js';
|
|
2
|
-
import {
|
|
2
|
+
import { searchGraphFacts } from '../../memory/graph.js';
|
|
3
|
+
import { recordRecallSearch } from '../../memory/recall-stats.js';
|
|
3
4
|
// ---------- memory_search ----------
|
|
4
5
|
// 唯一记忆搜索入口:关键词搜记忆正文(多词子串匹配,name 权重最高)+ 知识图谱事实段
|
|
5
|
-
// (
|
|
6
|
-
//
|
|
6
|
+
// (CJK 分词加权、边级相关度 + 一跳邻接,跨 global/project 两 scope)。命中条目即 bump
|
|
7
|
+
// recallCount(遗忘衰减依据)。结果走 capToolResultForHistory 的放宽上限(同 use_skill)。
|
|
7
8
|
const GRAPH_FACTS_LIMIT = 10;
|
|
8
9
|
export const memorySearchTool = {
|
|
9
10
|
name: 'memory_search',
|
|
10
|
-
description: 'Search memory entries by keyword (substring match), returning full body. Also surfaces knowledge-graph facts (active edges)
|
|
11
|
+
description: 'Search memory entries by keyword (substring match), returning full body. Also surfaces knowledge-graph facts (active edges across project + global), ranked by CJK-aware term overlap on entity names/aliases/relation/fact, plus one-hop neighbor edges for context.',
|
|
11
12
|
parameters: {
|
|
12
13
|
type: 'object',
|
|
13
14
|
properties: {
|
|
@@ -37,23 +38,28 @@ export const memorySearchTool = {
|
|
|
37
38
|
const entryText = r
|
|
38
39
|
.map((e) => `# [${e.id}] ${e.name} (${e.type}, recalled ${e.recallCount})\nsummary: ${e.summary}\n\n${e.body}`)
|
|
39
40
|
.join('\n\n---\n\n');
|
|
40
|
-
//
|
|
41
|
+
// 知识图谱事实段:边级相关度检索(容错:图坏了不连累条目搜索)。
|
|
41
42
|
let graphText = '';
|
|
43
|
+
let graphHit = false;
|
|
42
44
|
try {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
?
|
|
50
|
-
: '';
|
|
51
|
-
|
|
45
|
+
const hits = searchGraphFacts(query, { scope: 'all', limit: GRAPH_FACTS_LIMIT, neighbors: 4 });
|
|
46
|
+
graphHit = hits.length > 0;
|
|
47
|
+
if (graphHit) {
|
|
48
|
+
const lines = hits.map((h) => {
|
|
49
|
+
const src = h.srcEntity?.name ?? h.edge.src;
|
|
50
|
+
const dst = h.dstEntity?.name ?? h.edge.dst;
|
|
51
|
+
const fact = h.edge.fact ? ` (${h.edge.fact})` : '';
|
|
52
|
+
const tag = h.viaNeighbor ? ' [neighbor]' : '';
|
|
53
|
+
const scopeTag = h.edge.scope === 'global' ? '[global]' : '[project]';
|
|
54
|
+
return `${scopeTag} ${src} --[${h.edge.relation}]--> ${dst}${fact}${tag}`;
|
|
55
|
+
});
|
|
56
|
+
graphText = `\n\n## 知识图谱事实\n${lines.join('\n')}`;
|
|
52
57
|
}
|
|
53
58
|
}
|
|
54
59
|
catch {
|
|
55
60
|
// 静默:图谱段是增强,失败只降级为纯条目结果
|
|
56
61
|
}
|
|
62
|
+
recordRecallSearch(graphHit); // 埋点:pull 召回使用情况(写盘失败静默)
|
|
57
63
|
if (!entryText && !graphText)
|
|
58
64
|
return `(无匹配记忆:query="${query}")`;
|
|
59
65
|
if (!entryText)
|
|
@@ -28,8 +28,10 @@ function invalid(path, message) {
|
|
|
28
28
|
}
|
|
29
29
|
export const writeFileTool = {
|
|
30
30
|
name: 'write_file',
|
|
31
|
-
description: 'Create
|
|
32
|
-
'
|
|
31
|
+
description: 'Create a NEW file, APPEND to one, or rewrite essentially the WHOLE file — transactionally.\n' +
|
|
32
|
+
'Do NOT use this for localized changes to an existing file, even when there are several separate changes: use edit_file (a single edit, one replacements[] batch covering many spots, or a line range). Re-sending an entire file just to tweak a few lines wastes tokens and the long JSON is prone to truncation. Reach for write_file on an existing path only when most of the file is actually being replaced.\n' +
|
|
33
|
+
'expected_hash may be omitted (or null) only for create-only writes to a path that must not exist; overwriting requires the hash from a fresh read_file.\n' +
|
|
34
|
+
'To ADD to an existing file, pass append=true — only the new text goes in `content`; NO expected_hash and NO prior read_file needed (lock-protected; creates the file when missing). For long generated output, write the first chunk normally then extend it with repeated append=true calls, keeping each call small. Appends VERBATIM: if the file does not end with a newline, start your content with "\\n" or the last line will merge with yours.',
|
|
33
35
|
risk: 'confirm',
|
|
34
36
|
parameters: {
|
|
35
37
|
type: 'object',
|
package/dist/tools/constants.js
CHANGED
|
@@ -29,6 +29,10 @@ export const MAX_OLD_TOOL_STUB = 600;
|
|
|
29
29
|
export const MAX_TRACE_BYTES = 512 * 1024;
|
|
30
30
|
/** 保留的历史 trace 轮转份数(trace.1 .. trace.N),更老的硬删。 */
|
|
31
31
|
export const MAX_TRACE_ROTATIONS = 3;
|
|
32
|
+
/** compacted/ 压缩归档目录总量上限(字节):旧区原文逐段累积,超了从最老 seg 淘汰。
|
|
33
|
+
* 段大小与窗口正相关(200k 窗口单段约几百 KB),10MiB 约覆盖十几~几十次压缩。
|
|
34
|
+
* 可用 MOCODE_MAX_COMPACTED_BYTES 覆盖(测试/小盘用)。 */
|
|
35
|
+
export const MAX_COMPACTED_ARCHIVE_BYTES = 10 * 1024 * 1024;
|
|
32
36
|
/** rollback snapshots 压缩前滚动窗口:无论是否 compact,仅保留最近 N 个 user turn 的快照。 */
|
|
33
37
|
export const MAX_SNAPSHOT_TURNS = 50;
|
|
34
38
|
// ── 上下文压缩摘要(见 session/compact.ts defaultSummarize)────────────────
|
|
@@ -69,6 +69,9 @@ export function runRipgrep(params, signal) {
|
|
|
69
69
|
// --no-require-git:rg 默认只在 git 仓库内才读 .gitignore;加上后非 git 项目的 .gitignore
|
|
70
70
|
// 同样生效(实测不加时 ignored/ 会被扫到)。
|
|
71
71
|
const args = ['--json', '--hidden', '--no-require-git', '--path-separator', '/'];
|
|
72
|
+
// glob 显式指向 gitignore 内的 .mocode(回捞压缩归档等)时放开 VCS ignore。
|
|
73
|
+
if (params.noIgnoreVcs)
|
|
74
|
+
args.push('--no-ignore-vcs');
|
|
72
75
|
if (params.context > 0)
|
|
73
76
|
args.push('-C', String(params.context));
|
|
74
77
|
if (params.glob && params.glob !== '**/*')
|
|
@@ -169,7 +172,14 @@ export function runRipgrep(params, signal) {
|
|
|
169
172
|
handleLine(buffer.trim());
|
|
170
173
|
// exit 2 + stderr = 正则/参数错误;无命中事件时按 regex_error 上报。
|
|
171
174
|
if (code === 2 && byPath.size === 0 && stderr.trim()) {
|
|
172
|
-
|
|
175
|
+
const message = stderr.trim();
|
|
176
|
+
// 过滤器把所有文件排除(典型:glob 指向 .gitignore 内的 .mocode 却没放开 ignore)
|
|
177
|
+
// 不是正则错误——按无匹配处理,避免误导模型去改 pattern。
|
|
178
|
+
if (/No files were searched/i.test(message)) {
|
|
179
|
+
finish({ status: 'no-match', scanned, truncated });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
finish({ status: 'regex_error', message });
|
|
173
183
|
return;
|
|
174
184
|
}
|
|
175
185
|
if (byPath.size === 0) {
|
|
@@ -8,6 +8,67 @@ const DEFAULT_CAPABILITIES = Object.freeze({
|
|
|
8
8
|
effect: 'unknown',
|
|
9
9
|
concurrency: 'serial',
|
|
10
10
|
});
|
|
11
|
+
/** 长非法 JSON 回显时首尾各保留的字符数(尾部对判断截断最关键)。 */
|
|
12
|
+
const INVALID_JSON_SNIPPET = 150;
|
|
13
|
+
/** 按工具给出参数损坏后的可执行恢复路径。truncated=已确认输出被 token 上限截断。 */
|
|
14
|
+
function invalidJsonRecovery(name, truncated) {
|
|
15
|
+
if (truncated) {
|
|
16
|
+
if (name === 'write_file') {
|
|
17
|
+
return ('恢复方法(不要用相同参数重试):\n' +
|
|
18
|
+
'1) write_file 只写入首块内容,再用 write_file(append=true, content=…) 分多次续写,每段保持短小;\n' +
|
|
19
|
+
'2) 若只是修改已有文件的局部,改用 edit_file(只发 old_string/new_string,不要整篇重发);\n' +
|
|
20
|
+
'3) 确需一次生成,调大 MAX_TOKENS 后再试。');
|
|
21
|
+
}
|
|
22
|
+
if (name === 'edit_file') {
|
|
23
|
+
return '恢复方法:参数在 token 上限处被截断。缩短 old_string(只保留唯一命中所需的最小上下文),或调大 MAX_TOKENS 后再试。';
|
|
24
|
+
}
|
|
25
|
+
return '恢复方法:参数在 token 上限处被截断。把本次调用拆成更小的多个调用,或调大 MAX_TOKENS 后再试。';
|
|
26
|
+
}
|
|
27
|
+
if (name === 'write_file' || name === 'edit_file') {
|
|
28
|
+
return ('恢复方法:JSON 字符串值里的换行必须转义为 \\n、双引号转义为 \\"、反斜杠转义为 \\\\,不要在字符串中放裸换行。\n' +
|
|
29
|
+
'长内容优先 write_file 首块 + append=true 分段,或改用 edit_file 做局部替换。');
|
|
30
|
+
}
|
|
31
|
+
return '恢复方法:检查参数 JSON 的引号、反斜杠与换行转义后重试。';
|
|
32
|
+
}
|
|
33
|
+
/** 把 JSON.parse 的 SyntaxError 映射成可操作的中文死因。 */
|
|
34
|
+
function classifyJsonSyntaxError(message, knownTruncated) {
|
|
35
|
+
if (knownTruncated) {
|
|
36
|
+
return '死因:模型输出在 max_tokens 处被硬截断,tool_call 参数是不完整的 JSON(没有正常结尾)。';
|
|
37
|
+
}
|
|
38
|
+
if (/unterminated string/i.test(message)) {
|
|
39
|
+
return '死因:字符串未正常结束(Unterminated string)——参数可能在末尾被截断,或内容里的引号未转义。';
|
|
40
|
+
}
|
|
41
|
+
if (/bad control character|bad escaped/i.test(message)) {
|
|
42
|
+
return '死因:字符串值里出现了裸控制字符(最常见是裸换行)——JSON 字符串内换行必须写成 \\n。';
|
|
43
|
+
}
|
|
44
|
+
if (/unexpected token/i.test(message)) {
|
|
45
|
+
return '死因:出现非法字符(Unexpected token)——多为内容中的双引号/反斜杠没有转义,或参数被截断。';
|
|
46
|
+
}
|
|
47
|
+
if (/expected|end of (data|json)|unexpected end/i.test(message)) {
|
|
48
|
+
return '死因:JSON 提前结束或结构残缺——参数可能被截断。';
|
|
49
|
+
}
|
|
50
|
+
return `死因:${message}`;
|
|
51
|
+
}
|
|
52
|
+
/** argsRaw 的安全摘要:短则全量,长则只给长度+首尾片段,避免几万字符刷屏且被 TUI 二次截断。 */
|
|
53
|
+
function summarizeRawArgs(argsRaw) {
|
|
54
|
+
const len = argsRaw.length;
|
|
55
|
+
if (len <= INVALID_JSON_SNIPPET * 2 + 40)
|
|
56
|
+
return argsRaw;
|
|
57
|
+
const head = argsRaw.slice(0, INVALID_JSON_SNIPPET);
|
|
58
|
+
const tail = argsRaw.slice(len - INVALID_JSON_SNIPPET);
|
|
59
|
+
return `${head}\n…[中间省略 ${len - INVALID_JSON_SNIPPET * 2} 字符]…\n${tail}`;
|
|
60
|
+
}
|
|
61
|
+
function invalidJsonOutcome(name, argsRaw, parseError, opts, startedAt) {
|
|
62
|
+
const syntaxMessage = parseError instanceof Error ? parseError.message : String(parseError);
|
|
63
|
+
const knownTruncated = opts?.finishReason === 'length';
|
|
64
|
+
const looksTruncated = /end of (data|json)|unexpected end/i.test(syntaxMessage);
|
|
65
|
+
const output = `错误:工具 ${name} 的 arguments 不是合法 JSON(收到 ${argsRaw.length} 字符)。\n` +
|
|
66
|
+
`${classifyJsonSyntaxError(syntaxMessage, knownTruncated)}\n` +
|
|
67
|
+
`${invalidJsonRecovery(name, knownTruncated || looksTruncated)}\n` +
|
|
68
|
+
`解析器报错:${syntaxMessage}\n` +
|
|
69
|
+
`参数片段(首/尾):\n${summarizeRawArgs(argsRaw)}`;
|
|
70
|
+
return terminalOutcome('error', 'INVALID_JSON', output, startedAt);
|
|
71
|
+
}
|
|
11
72
|
const DEFAULT_DEPENDENCIES = {
|
|
12
73
|
enforceSandbox: defaultEnforceSandbox,
|
|
13
74
|
resolveResourceLockRequests: defaultResolveResourceLockRequests,
|
|
@@ -194,8 +255,10 @@ export class ToolRuntime {
|
|
|
194
255
|
try {
|
|
195
256
|
parsed = argsRaw.trim() ? JSON.parse(argsRaw) : {};
|
|
196
257
|
}
|
|
197
|
-
catch {
|
|
198
|
-
|
|
258
|
+
catch (parseError) {
|
|
259
|
+
// 不再裸 catch:保留 SyntaxError 的具体死因(截断 vs 裸换行 vs 漏转义),
|
|
260
|
+
// 并结合 finish_reason=length 给出可执行恢复路径,替代旧的整段 argsRaw 刷屏。
|
|
261
|
+
return invalidJsonOutcome(name, argsRaw, parseError, opts, startedAt);
|
|
199
262
|
}
|
|
200
263
|
const validation = validateToolArguments(tool, parsed);
|
|
201
264
|
if (!validation.valid) {
|
package/dist/ui/diff.js
CHANGED
|
@@ -295,9 +295,16 @@ export function renderFileChange(opts) {
|
|
|
295
295
|
const padW = Math.max(3, String(startLine + Math.max(oldLen, newLen) - 1).length);
|
|
296
296
|
return renderBody(head, counts, compactCtx(ops), padW, startLine, lang);
|
|
297
297
|
}
|
|
298
|
-
/** 头行
|
|
299
|
-
function
|
|
300
|
-
|
|
298
|
+
/** Update(path) 头行(edit / 多 hunk 共用)。 */
|
|
299
|
+
function updateHead(pathDisp) {
|
|
300
|
+
return `${HEAD_INDENT}${ui.bold}${ui.accent}Update${ui.reset}${ui.gray}(${ui.reset}${ui.accent}${pathDisp}${ui.reset}${ui.gray})${ui.reset}`;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* 单个 hunk 的正文行(折叠 + 截断 + 行号),不含头/计数行。
|
|
304
|
+
* 多替换模式对每个 {old_string,new_string} 各调一次,行号从该 hunk 的 startLine 独立起算。
|
|
305
|
+
*/
|
|
306
|
+
function renderHunkBody(items, padW, startLine, lang) {
|
|
307
|
+
const lines = [];
|
|
301
308
|
const metaPrefix = `${BODY_INDENT}`; // 与正文行左对齐(batch 展开时还会再加外层 indent,避免双重缩进导致提示行突兀)
|
|
302
309
|
let oldLine = startLine;
|
|
303
310
|
let newLine = startLine;
|
|
@@ -365,5 +372,62 @@ function renderBody(head, counts, items, padW, startLine, lang) {
|
|
|
365
372
|
if (overflow > 0) {
|
|
366
373
|
lines.push(`${metaPrefix}${ui.dim}…(还有 ${overflow} 行未显示)${ui.reset}`);
|
|
367
374
|
}
|
|
368
|
-
return lines
|
|
375
|
+
return lines;
|
|
376
|
+
}
|
|
377
|
+
/** 单 hunk:头行 + 计数行 + 正文。 */
|
|
378
|
+
function renderBody(head, counts, items, padW, startLine, lang) {
|
|
379
|
+
return [head, counts, ...renderHunkBody(items, padW, startLine, lang)].join('\n') + '\n';
|
|
380
|
+
}
|
|
381
|
+
/** 单个 hunk 的增删行计数。 */
|
|
382
|
+
function countOps(ops) {
|
|
383
|
+
let add = 0;
|
|
384
|
+
let del = 0;
|
|
385
|
+
for (const o of ops) {
|
|
386
|
+
if (o.op === 'add')
|
|
387
|
+
add++;
|
|
388
|
+
else if (o.op === 'del')
|
|
389
|
+
del++;
|
|
390
|
+
}
|
|
391
|
+
return { add, del };
|
|
392
|
+
}
|
|
393
|
+
function countsLine(add, del) {
|
|
394
|
+
const parts = [];
|
|
395
|
+
if (add > 0)
|
|
396
|
+
parts.push(`${ui.dim}Added ${ui.reset}${ui.green}${add}${ui.reset}${ui.dim} ${lineWord(add)}`);
|
|
397
|
+
if (del > 0)
|
|
398
|
+
parts.push(`${ui.dim}removed ${ui.reset}${ui.red}${del}${ui.reset}${ui.dim} ${lineWord(del)}`);
|
|
399
|
+
return parts.length > 0
|
|
400
|
+
? `${HEAD_INDENT} ${parts.join(`${ui.dim}, ${ui.reset}`)}${ui.reset}`
|
|
401
|
+
: `${HEAD_INDENT} ${ui.dim}No changes${ui.reset}`;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* 多替换模式:一个文件里多个互不相邻的 hunk,共用一个 Update(path) 头,
|
|
405
|
+
* 每个 hunk 独立行号(由 readDiffContext 在原文定位其起始行)。
|
|
406
|
+
* 任一 hunk 过大无法做 LCS 时返回 null,调用方退回摘要展示(与 renderFileChange 同策略)。
|
|
407
|
+
*/
|
|
408
|
+
export function renderMultiFileChange(opts) {
|
|
409
|
+
const lang = langForPath(opts.path);
|
|
410
|
+
const head = updateHead(opts.path);
|
|
411
|
+
const prepared = [];
|
|
412
|
+
let totalAdd = 0;
|
|
413
|
+
let totalDel = 0;
|
|
414
|
+
let maxLineNo = 1;
|
|
415
|
+
for (const h of opts.hunks) {
|
|
416
|
+
const ops = lineDiff(h.oldStr, h.newStr);
|
|
417
|
+
if (ops === null)
|
|
418
|
+
return null;
|
|
419
|
+
const { add, del } = countOps(ops);
|
|
420
|
+
totalAdd += add;
|
|
421
|
+
totalDel += del;
|
|
422
|
+
maxLineNo = Math.max(maxLineNo, h.startLine + Math.max(splitLines(h.oldStr).length, splitLines(h.newStr).length) - 1);
|
|
423
|
+
prepared.push({ ops, startLine: h.startLine, padW: 0 });
|
|
424
|
+
}
|
|
425
|
+
const padW = Math.max(3, String(maxLineNo).length);
|
|
426
|
+
const out = [head, countsLine(totalAdd, totalDel)];
|
|
427
|
+
prepared.forEach((p, idx) => {
|
|
428
|
+
if (idx > 0)
|
|
429
|
+
out.push(`${BODY_INDENT}${ui.dim}@@${ui.reset}`);
|
|
430
|
+
out.push(...renderHunkBody(compactCtx(p.ops), padW, p.startLine, lang));
|
|
431
|
+
});
|
|
432
|
+
return out.join('\n') + '\n';
|
|
369
433
|
}
|