mocode-ai 0.6.6 → 0.6.7

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.
@@ -1,76 +1,202 @@
1
- /**
2
- * Code Encoder(read_file):折叠连续空行(≥3 → 1,保留首空行行号前缀)。
3
- *
4
- * 输入:read_file 返回的 ` N\t<content>` 行(6 宽右对齐行号 + tab + 内容),可能带尾部
5
- * `... (N 行未显示,共 M 行)`。
6
- * 输出:连续 ≥3 个空行(行号前缀 + 空 content)折叠为 1 个(保留首行前缀,丢后续空行);其余逐字保留。
7
- *
8
- * 不变量(离线脚本断言):
9
- * - 所有非空 content 行逐字保留(前缀 + 内容不变)→ edit_file 的 old_string 按内容匹配仍可用。
10
- * - content 行的行号前缀不变 → LLM 看到的行号与文件一致。
11
- * - 仅空行(前缀 + tab + 空 content)被折叠,且仅 ≥3 连续时;≤2 空行原样(常见,不动)。
12
- * - 尾部标记 `... (...)` 不匹配行号前缀,原样保留。
13
- *
14
- * ⚠️ 残余风险(可接受、可恢复):若 LLM 编辑一个含 ≥3 连续空行的区域,它看到的空行数比文件实际少,
15
- * old_string 的空行数可能不匹配 → edit_file 返"未找到" → LLM 重读重试(系统提示已要求编辑前 read_file)。
16
- * 真实代码极少 ≥3 连续空行(linter 通常强制 ≤2),故实际几乎不触发。出问题可设
17
- * MOCODE_CONTEXT_OPTIMIZE=false 全局回退,或调高阈值。
18
- *
19
- * 不做长度裁剪(cap 兜底);不动行号前缀;不删 content 行;不改内容(含尾随空白)。
20
- * 空行判定用前缀感知(前缀 + tab + 空 content),不能用 trim——read_file 空行 ` 2\t` trim 后剩 `2`。
21
- */
1
+ /** read_file line: right-aligned source line number + tab + exact content. */
22
2
  const LINE_RE = /^(\s*\d+)\t(.*)$/;
23
- function isBlankCodeLine(l) {
24
- const m = LINE_RE.exec(l);
25
- return m ? m[2] === '' : false;
3
+ const JS_LIKE_PATH_RE = /\.(?:[cm]?js|jsx|ts|tsx)$/i;
4
+ function parseLine(raw) {
5
+ const match = LINE_RE.exec(raw);
6
+ if (!match)
7
+ return null;
8
+ return { raw, line: Number(match[1].trim()), content: match[2] };
26
9
  }
27
- export const codeEncoder = {
28
- kind: 'code',
29
- encode({ output }) {
30
- const lines = output.split('\n');
31
- const out = [];
32
- let i = 0;
33
- let collapsedRuns = 0;
34
- while (i < lines.length) {
35
- if (isBlankCodeLine(lines[i])) {
36
- let j = i;
37
- while (j < lines.length && isBlankCodeLine(lines[j]))
38
- j++;
39
- const run = j - i;
40
- if (run >= 3) {
41
- out.push(lines[i]); // 保留首空行(含其行号前缀)
42
- collapsedRuns++;
43
- }
44
- else {
45
- for (let k = 0; k < run; k++)
46
- out.push(lines[i]);
47
- }
48
- i = j;
10
+ function collapseBlankCodeRuns(output) {
11
+ const lines = output.split('\n');
12
+ const out = [];
13
+ let collapsed = 0;
14
+ let i = 0;
15
+ while (i < lines.length) {
16
+ if (parseLine(lines[i])?.content === '') {
17
+ let j = i;
18
+ while (j < lines.length && parseLine(lines[j])?.content === '')
19
+ j++;
20
+ if (j - i >= 3) {
21
+ out.push(lines[i]);
22
+ collapsed++;
49
23
  }
50
24
  else {
51
- out.push(lines[i]);
25
+ for (let k = i; k < j; k++)
26
+ out.push(lines[k]);
27
+ }
28
+ i = j;
29
+ continue;
30
+ }
31
+ out.push(lines[i++]);
32
+ }
33
+ return { text: out.join('\n'), collapsed };
34
+ }
35
+ function isJsLikePath(args) {
36
+ return typeof args?.path === 'string' && JS_LIKE_PATH_RE.test(args.path);
37
+ }
38
+ function isSingleLineImport(content) {
39
+ const line = content.trim();
40
+ return /^import\b/.test(line) && /(?:['"][^'"]+['"]\s*;?|;)$/.test(line);
41
+ }
42
+ function collapseImportBlocks(text) {
43
+ const lines = text.split('\n');
44
+ const out = [];
45
+ let collapsed = 0;
46
+ let i = 0;
47
+ while (i < lines.length) {
48
+ const first = parseLine(lines[i]);
49
+ if (!first || !isSingleLineImport(first.content)) {
50
+ out.push(lines[i++]);
51
+ continue;
52
+ }
53
+ let j = i + 1;
54
+ while (j < lines.length) {
55
+ const next = parseLine(lines[j]);
56
+ if (!next || !isSingleLineImport(next.content))
57
+ break;
58
+ j++;
59
+ }
60
+ const run = j - i;
61
+ if (run >= 4) {
62
+ const last = parseLine(lines[j - 1]);
63
+ out.push(lines[i]);
64
+ out.push(`… ${run - 2} import lines folded (source lines ${first.line + 1}–${last.line - 1}; cold read)`);
65
+ out.push(lines[j - 1]);
66
+ collapsed++;
67
+ }
68
+ else {
69
+ for (let k = i; k < j; k++)
70
+ out.push(lines[k]);
71
+ }
72
+ i = j;
73
+ }
74
+ const result = out.join('\n');
75
+ return result.length < text.length
76
+ ? { text: result, collapsed }
77
+ : { text, collapsed: 0 };
78
+ }
79
+ function braceDelta(line, state) {
80
+ let delta = 0;
81
+ for (let i = 0; i < line.length; i++) {
82
+ const ch = line[i];
83
+ const next = line[i + 1];
84
+ if (state.blockComment) {
85
+ if (ch === '*' && next === '/') {
86
+ state.blockComment = false;
52
87
  i++;
53
88
  }
89
+ continue;
90
+ }
91
+ if (state.quote) {
92
+ if (state.escaped) {
93
+ state.escaped = false;
94
+ }
95
+ else if (ch === '\\') {
96
+ state.escaped = true;
97
+ }
98
+ else if (ch === state.quote) {
99
+ state.quote = null;
100
+ }
101
+ continue;
102
+ }
103
+ if (ch === '/' && next === '/')
104
+ break;
105
+ if (ch === '/' && next === '*') {
106
+ state.blockComment = true;
107
+ i++;
108
+ continue;
109
+ }
110
+ if (ch === "'" || ch === '"' || ch === '`') {
111
+ state.quote = ch;
112
+ state.escaped = false;
113
+ continue;
114
+ }
115
+ if (ch === '{')
116
+ delta++;
117
+ else if (ch === '}')
118
+ delta--;
119
+ }
120
+ state.escaped = false;
121
+ return delta;
122
+ }
123
+ function isFunctionStart(content) {
124
+ return (/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function(?:\s+[\w$]+)?\s*\(/.test(content) ||
125
+ /^\s*(?:export\s+)?(?:const|let|var)\s+[\w$]+\s*=.*=>\s*\{\s*$/.test(content) ||
126
+ /^\s*(?:(?:public|private|protected|static|abstract|override|async|get|set)\s+)*(?:constructor|[\w$]+)\s*\([^;]*\)\s*(?::[^={]+)?\s*\{\s*$/.test(content));
127
+ }
128
+ function collapseFunctionBodies(text) {
129
+ const lines = text.split('\n');
130
+ const out = [];
131
+ let collapsed = 0;
132
+ let i = 0;
133
+ while (i < lines.length) {
134
+ const start = parseLine(lines[i]);
135
+ if (!start || !isFunctionStart(start.content)) {
136
+ out.push(lines[i++]);
137
+ continue;
54
138
  }
55
- if (collapsedRuns === 0) {
56
- return {
57
- text: output,
58
- meta: {
59
- kind: 'code',
60
- originalLen: output.length,
61
- encodedLen: output.length,
62
- note: 'no ≥3 blank runs passthrough',
63
- },
64
- };
139
+ const state = { quote: null, escaped: false, blockComment: false };
140
+ let depth = braceDelta(start.content, state);
141
+ if (depth <= 0) {
142
+ out.push(lines[i++]);
143
+ continue;
144
+ }
145
+ let j = i + 1;
146
+ for (; j < lines.length && depth > 0; j++) {
147
+ const current = parseLine(lines[j]);
148
+ if (!current)
149
+ break;
150
+ depth += braceDelta(current.content, state);
151
+ }
152
+ const endIndex = j - 1;
153
+ const end = parseLine(lines[endIndex] ?? '');
154
+ const bodyLines = endIndex - i - 1;
155
+ if (depth === 0 && end && bodyLines >= 8) {
156
+ out.push(lines[i]);
157
+ out.push(`… ${bodyLines} function-body lines folded (source lines ${start.line + 1}–${end.line - 1}; cold read)`);
158
+ out.push(lines[endIndex]);
159
+ collapsed++;
160
+ i = j;
161
+ }
162
+ else {
163
+ out.push(lines[i++]);
164
+ }
165
+ }
166
+ const result = out.join('\n');
167
+ return result.length < text.length
168
+ ? { text: result, collapsed }
169
+ : { text, collapsed: 0 };
170
+ }
171
+ export const codeEncoder = {
172
+ kind: 'code',
173
+ encode(input) {
174
+ const blankResult = collapseBlankCodeRuns(input.output);
175
+ let text = blankResult.text;
176
+ const notes = [];
177
+ if (blankResult.collapsed)
178
+ notes.push(`collapsed ${blankResult.collapsed} blank runs`);
179
+ if (input.phase === 'sweep' &&
180
+ input.isCold === true &&
181
+ (input.age ?? 0) >= 2 &&
182
+ input.isFirstRead === false &&
183
+ isJsLikePath(input.args)) {
184
+ const imports = collapseImportBlocks(text);
185
+ text = imports.text;
186
+ if (imports.collapsed)
187
+ notes.push(`folded ${imports.collapsed} import blocks`);
188
+ const functions = collapseFunctionBodies(text);
189
+ text = functions.text;
190
+ if (functions.collapsed)
191
+ notes.push(`folded ${functions.collapsed} function bodies`);
65
192
  }
66
- const text = out.join('\n');
67
193
  return {
68
194
  text,
69
195
  meta: {
70
196
  kind: 'code',
71
- originalLen: output.length,
197
+ originalLen: input.output.length,
72
198
  encodedLen: text.length,
73
- note: `collapsed ${collapsedRuns} blank runs (≥3 → 1)`,
199
+ note: notes.join(', ') || 'no change',
74
200
  },
75
201
  };
76
202
  },
@@ -1,30 +1,80 @@
1
1
  import { stripAnsi, collapseBlankRuns } from './_util.js';
2
- /**
3
- * Graph Encoder(codegraph):去 ANSI + 折叠连续空行(≥3 1)
4
- *
5
- * 输入:codegraph CLI 返回的 `[退出码 N]\n<源码 + 调用路径>`(可能含 ANSI 颜色、多余空行)。
6
- * 输出:去 ANSI + 空行折叠;结构(调用路径块、源码段)不动。
7
- *
8
- * 不变量:退出码行保留;源码与调用路径文本逐字保留(仅去颜色码 + 折叠空行);不删内容行。
9
- * 保守:不重构调用路径 / 不去重源码(codegraph CLI 输出格式未稳定,需先采样真实输出定不变量,留后续)
10
- */
2
+ function isAgedCold(input) {
3
+ return input.phase === 'sweep' && input.isCold === true && (input.age ?? 0) >= 2;
4
+ }
5
+ function normalizeBlock(block) {
6
+ return block
7
+ .replace(/\r\n/g, '\n')
8
+ .split('\n')
9
+ .map((line) => line.trimEnd())
10
+ .join('\n')
11
+ .trim();
12
+ }
13
+ function dedupeFencedBlocks(text) {
14
+ const seen = new Set();
15
+ let removed = 0;
16
+ const result = text.replace(/```[^\n]*\n[\s\S]*?\n```/g, (block) => {
17
+ const key = normalizeBlock(block);
18
+ if (key.length < 120 || !seen.has(key)) {
19
+ seen.add(key);
20
+ return block;
21
+ }
22
+ removed++;
23
+ return '… duplicate source block omitted (cold graph)';
24
+ });
25
+ return { text: result, removed };
26
+ }
27
+ function looksLikeSourceBlock(block) {
28
+ const lines = block.split('\n');
29
+ const sourceLines = lines.filter((line) => /^\s*\d+\t/.test(line) ||
30
+ /^\s*(?:L)?\d+[:|]\s/.test(line) ||
31
+ /^.*:\d+(?::\d+)?:\s/.test(line)).length;
32
+ return block.length >= 120 && sourceLines >= 3;
33
+ }
34
+ function dedupeParagraphSourceBlocks(text) {
35
+ const parts = text.split(/(\n{2,})/);
36
+ const seen = new Set();
37
+ let removed = 0;
38
+ for (let i = 0; i < parts.length; i += 2) {
39
+ const block = parts[i];
40
+ if (!looksLikeSourceBlock(block))
41
+ continue;
42
+ const key = normalizeBlock(block);
43
+ if (seen.has(key)) {
44
+ parts[i] = '… duplicate source block omitted (cold graph)';
45
+ removed++;
46
+ }
47
+ else {
48
+ seen.add(key);
49
+ }
50
+ }
51
+ return { text: parts.join(''), removed };
52
+ }
11
53
  export const graphEncoder = {
12
54
  kind: 'graph',
13
- encode({ output }) {
14
- const stripped = stripAnsi(output);
15
- const text = collapseBlankRuns(stripped, 3);
16
- const hadAnsi = /\x1b/.test(output);
17
- const hadBlanks = text !== stripped;
18
- const note = [hadAnsi ? 'ANSI stripped' : '', hadBlanks ? 'blank runs collapsed' : '']
19
- .filter(Boolean)
20
- .join(', ') || 'no change';
55
+ encode(input) {
56
+ const stripped = stripAnsi(input.output);
57
+ let text = stripped;
58
+ let duplicates = 0;
59
+ if (isAgedCold(input)) {
60
+ const fenced = dedupeFencedBlocks(text);
61
+ const paragraphs = dedupeParagraphSourceBlocks(fenced.text);
62
+ text = paragraphs.text;
63
+ duplicates = fenced.removed + paragraphs.removed;
64
+ }
65
+ text = collapseBlankRuns(text, 3);
66
+ const notes = [
67
+ /\x1b/.test(input.output) ? 'ANSI stripped' : '',
68
+ text !== stripped && duplicates === 0 ? 'blank runs collapsed' : '',
69
+ duplicates > 0 ? `${duplicates} duplicate source blocks omitted` : '',
70
+ ].filter(Boolean);
21
71
  return {
22
72
  text,
23
73
  meta: {
24
74
  kind: 'graph',
25
- originalLen: output.length,
75
+ originalLen: input.output.length,
26
76
  encodedLen: text.length,
27
- note,
77
+ note: notes.join(', ') || 'no change',
28
78
  },
29
79
  };
30
80
  },
@@ -1,68 +1,134 @@
1
- const GREP_RE = /^(.*?):(\d+):(.*)$/;
1
+ const LEGACY_GREP_RE = /^(.*?):(\d+):(.*)$/;
2
+ const STRUCTURED_HEADER_RE = /^(.*): (\d+) 处匹配,行号 \[([0-9,\s]+)\]$/;
3
+ const STRUCTURED_BODY_RE = /^\s{2}L\d+:/;
4
+ const STRUCTURED_FOLDED_RE = /^\s{2}\(body 已折叠/;
5
+ function isAgedCold(input) {
6
+ return input.phase === 'sweep' && input.isCold === true && (input.age ?? 0) >= 2;
7
+ }
8
+ /** Current grep output already has lossless file headers; Cold drops body previews only. */
9
+ function collapseStructuredGrep(output) {
10
+ const lines = output.split('\n');
11
+ const out = [];
12
+ let files = 0;
13
+ let inFile = false;
14
+ for (const line of lines) {
15
+ if (STRUCTURED_HEADER_RE.test(line)) {
16
+ files++;
17
+ inFile = true;
18
+ out.push(line);
19
+ continue;
20
+ }
21
+ if (inFile && (STRUCTURED_BODY_RE.test(line) || STRUCTURED_FOLDED_RE.test(line))) {
22
+ continue;
23
+ }
24
+ inFile = false;
25
+ out.push(line);
26
+ }
27
+ return files > 0 ? { text: out.join('\n'), files } : null;
28
+ }
29
+ function encodeLegacyGrep(output) {
30
+ const lines = output.split('\n');
31
+ const matches = [];
32
+ const tail = [];
33
+ let inTail = false;
34
+ for (const line of lines) {
35
+ if (!line)
36
+ continue;
37
+ if (inTail) {
38
+ tail.push(line);
39
+ continue;
40
+ }
41
+ const match = LEGACY_GREP_RE.exec(line);
42
+ if (match) {
43
+ matches.push({ file: match[1], line: match[2], content: match[3] });
44
+ }
45
+ else {
46
+ inTail = true;
47
+ tail.push(line);
48
+ }
49
+ }
50
+ if (matches.length === 0)
51
+ return null;
52
+ const groups = new Map();
53
+ const order = [];
54
+ for (const match of matches) {
55
+ if (!groups.has(match.file)) {
56
+ groups.set(match.file, []);
57
+ order.push(match.file);
58
+ }
59
+ groups.get(match.file).push({ line: match.line, content: match.content });
60
+ }
61
+ const out = [`# ${matches.length} matches · ${order.length} files · search-encoded`];
62
+ for (const file of order) {
63
+ out.push(`${file}:`);
64
+ for (const item of groups.get(file))
65
+ out.push(` ${item.line}:${item.content}`);
66
+ }
67
+ if (tail.length)
68
+ out.push(...tail);
69
+ return { text: out.join('\n'), matches: matches.length, files: order.length };
70
+ }
71
+ function collapseLegacyBodies(text) {
72
+ if (!text.startsWith('# ') || !text.includes('search-encoded'))
73
+ return text;
74
+ return text
75
+ .split('\n')
76
+ .filter((line) => !/^\s{2}\d+:/.test(line))
77
+ .join('\n');
78
+ }
2
79
  export const searchEncoder = {
3
80
  kind: 'search',
4
- encode({ output }) {
5
- const lines = output.split('\n');
6
- const matches = [];
7
- const tail = [];
8
- let inTail = false;
9
- for (const l of lines) {
10
- if (!l)
11
- continue;
12
- if (inTail) {
13
- tail.push(l);
14
- continue;
15
- }
16
- const m = GREP_RE.exec(l);
17
- if (m) {
18
- matches.push({ file: m[1], line: m[2], content: m[3] });
19
- }
20
- else {
21
- // 首个非 grep 行起视作 tail(grep 上限标记 / 无匹配串 / web_search 非 grep 结构)
22
- inTail = true;
23
- tail.push(l);
24
- }
25
- }
26
- if (matches.length === 0) {
27
- // 非 grep 格式(web_search 等)→ 不动其已格式化结构
81
+ encode(input) {
82
+ const structured = collapseStructuredGrep(input.output);
83
+ if (structured) {
84
+ const text = isAgedCold(input) ? structured.text : input.output;
28
85
  return {
29
- text: output,
86
+ text,
30
87
  meta: {
31
88
  kind: 'search',
32
- originalLen: output.length,
33
- encodedLen: output.length,
34
- note: 'no file:line matches → passthrough',
89
+ originalLen: input.output.length,
90
+ encodedLen: text.length,
91
+ note: isAgedCold(input)
92
+ ? `${structured.files} files · Cold body previews removed`
93
+ : `${structured.files} structured grep files · passthrough`,
35
94
  },
36
95
  };
37
96
  }
38
- const groups = new Map();
39
- const order = [];
40
- for (const m of matches) {
41
- if (!groups.has(m.file)) {
42
- groups.set(m.file, []);
43
- order.push(m.file);
44
- }
45
- groups.get(m.file).push({ line: m.line, content: m.content });
97
+ // A previous pass may already have transformed legacy file:line output.
98
+ if (input.output.startsWith('# ') && input.output.includes('search-encoded')) {
99
+ const text = isAgedCold(input) ? collapseLegacyBodies(input.output) : input.output;
100
+ return {
101
+ text,
102
+ meta: {
103
+ kind: 'search',
104
+ originalLen: input.output.length,
105
+ encodedLen: text.length,
106
+ note: isAgedCold(input) ? 'Cold legacy bodies removed' : 'already encoded',
107
+ },
108
+ };
46
109
  }
47
- const out = [
48
- `# ${matches.length} matches · ${order.length} files · search-encoded`,
49
- ];
50
- for (const file of order) {
51
- out.push(`${file}:`);
52
- for (const { line, content } of groups.get(file)) {
53
- out.push(` ${line}:${content}`);
54
- }
110
+ const legacy = encodeLegacyGrep(input.output);
111
+ if (!legacy) {
112
+ return {
113
+ text: input.output,
114
+ meta: {
115
+ kind: 'search',
116
+ originalLen: input.output.length,
117
+ encodedLen: input.output.length,
118
+ note: 'no recognized grep structure → passthrough',
119
+ },
120
+ };
55
121
  }
56
- if (tail.length)
57
- out.push(...tail);
58
- const text = out.join('\n');
122
+ const text = isAgedCold(input)
123
+ ? collapseLegacyBodies(legacy.text)
124
+ : legacy.text;
59
125
  return {
60
126
  text,
61
127
  meta: {
62
128
  kind: 'search',
63
- originalLen: output.length,
129
+ originalLen: input.output.length,
64
130
  encodedLen: text.length,
65
- note: `${matches.length} matches / ${order.length} files`,
131
+ note: `${legacy.matches} matches / ${legacy.files} files${isAgedCold(input) ? ' · Cold bodies removed' : ''}`,
66
132
  },
67
133
  };
68
134
  },
@@ -8,4 +8,4 @@ export { optimizeToolResult } from './pipeline.js';
8
8
  export { classify, knownToolKinds } from './classifier.js';
9
9
  export { registerEncoder, registerAll, getEncoder, registeredKinds, } from './registry.js';
10
10
  // ── Context Budget Scheduler ───────────────────────────────────────────────
11
- export { evaluateBudget, scheduleActions, formatReport, quickEstimate, userTurnBoundary, BUDGET_LAYERS, BUDGET_RATIO, HOT_TURN_WINDOW, TOOL_OLD_AGE, } from './budget.js';
11
+ export { evaluateBudget, scheduleActions, formatReport, quickEstimate, userTurnBoundary, BUDGET_LAYERS, DEFAULT_BUDGET_POLICY, BUDGET_RATIO, HOT_TURN_WINDOW, TOOL_OLD_AGE, } from './budget.js';
@@ -58,7 +58,7 @@ function budgetFor(name) {
58
58
  * @param argsRaw 工具 arguments 原始 JSON 字符串(tc.arguments,可空;未传则 args=null)
59
59
  * @returns 进 history 的 content 字符串(永不抛错)
60
60
  */
61
- export function optimizeToolResult(name, output, argsRaw) {
61
+ export function optimizeToolResult(name, output, argsRaw, context = {}) {
62
62
  boot();
63
63
  // 总开关关闭:完全走老路径,零行为变化(Phase 1 默认 true,但保留紧急回退开关)。
64
64
  if (!config.contextOptimize) {
@@ -73,6 +73,10 @@ export function optimizeToolResult(name, output, argsRaw) {
73
73
  output,
74
74
  args,
75
75
  budget: budgetFor(name),
76
+ age: context.age ?? 0,
77
+ isCold: context.isCold ?? false,
78
+ isFirstRead: context.isFirstRead,
79
+ phase: context.phase ?? 'push',
76
80
  });
77
81
  // 末尾长度裁剪兜底(同改造前):encoder 已更短则 no-op;use_skill/memory_search 的放宽 cap 由此保留。
78
82
  return capToolResultForHistory(name, text);