mocode-ai 0.3.0 → 0.4.1

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.
@@ -5,19 +5,26 @@ import { getSandboxRoot, isInsideRoot, jailResolve } from '../../sandbox/index.j
5
5
  // ---------- grep ----------
6
6
  export const grepTool = {
7
7
  name: 'grep',
8
- description: 'Search file contents by regex, returning file:line: matched lines. Recursively searches cwd excluding node_modules/.git. Optional glob restricts file types.' +
9
- ' For architecture/call chains, prefer codegraph.',
8
+ description: 'Search file contents by regex (recursive, excludes node_modules/.git).\n' +
9
+ 'Output: per-file header "<path>: N matches, lines [l1, l2, ...]" + first N matching lines.\n' +
10
+ 'Use the line-number list to call read_file(offset=X, limit=Y) for each region — ' +
11
+ 'do NOT read entire files after grepping. Prefer codegraph for call chains.',
10
12
  parameters: {
11
13
  type: 'object',
12
14
  properties: {
13
15
  pattern: { type: 'string', description: 'Regular expression' },
14
16
  glob: { type: 'string', description: 'Optional, restrict to a file glob, e.g. *.ts' },
17
+ max_per_file: {
18
+ type: 'integer',
19
+ description: 'Max body lines per file (default 15, cap 50). Line-number list is always full.',
20
+ },
15
21
  },
16
22
  required: ['pattern'],
17
23
  },
18
24
  async execute(args) {
19
25
  const pattern = String(args.pattern);
20
26
  const g = String(args.glob ?? '**/*');
27
+ const maxPerFile = Math.min(Math.max(Number(args.max_per_file ?? 15), 1), 50);
21
28
  let re;
22
29
  try {
23
30
  re = new RegExp(pattern);
@@ -34,11 +41,10 @@ export const grepTool = {
34
41
  followSymbolicLinks: false, // 不跟随软链目录,防经软链扫到牢外文件
35
42
  throwErrorOnBrokenSymbolicLink: false,
36
43
  })).filter((f) => isInsideRoot(f)); // 后置兜底:仅留牢内
37
- const results = [];
44
+ const hits = [];
38
45
  let scanned = 0;
39
- for (const f of files) {
40
- if (results.length >= MAX_RESULTS)
41
- break;
46
+ let truncated = false;
47
+ outer: for (const f of files) {
42
48
  let content;
43
49
  try {
44
50
  // jailResolve:realpath 化,防「牢内文件软链→牢外」的内容泄露;越界/不可读均 catch 跳过
@@ -49,19 +55,50 @@ export const grepTool = {
49
55
  }
50
56
  scanned++;
51
57
  const lines = content.split(/\r?\n/);
58
+ const lineNos = [];
52
59
  for (let i = 0; i < lines.length; i++) {
53
- if (re.test(lines[i])) {
54
- results.push(`${f}:${i + 1}: ${lines[i].trim()}`);
55
- if (results.length >= MAX_RESULTS)
56
- break;
60
+ if (re.test(lines[i]))
61
+ lineNos.push(i + 1);
62
+ }
63
+ if (lineNos.length === 0)
64
+ continue;
65
+ // 全局配额:行号列表总是计入,body 行数也计入(行号 + body 两条共用 MAX_RESULTS)
66
+ const totalCost = lineNos.length + Math.min(lineNos.length, maxPerFile);
67
+ if (hits.length >= MAX_RESULTS || totalCost > MAX_RESULTS * 4) {
68
+ // 文件过多 / 配额爆:仅追加该文件行号列表,不再展开 body
69
+ if (hits.length < MAX_RESULTS) {
70
+ hits.push({ path: f, lineNos, bodies: [] });
57
71
  }
72
+ truncated = true;
73
+ continue;
58
74
  }
75
+ const bodies = lineNos.slice(0, maxPerFile).map((n) => {
76
+ const trimmed = lines[n - 1].trim();
77
+ return ` L${n}: ${trimmed}`;
78
+ });
79
+ hits.push({ path: f, lineNos, bodies });
59
80
  }
60
- if (results.length === 0)
81
+ if (hits.length === 0)
61
82
  return `无匹配(扫描了 ${scanned} 个文件)`;
62
- let out = results.join('\n');
63
- if (results.length >= MAX_RESULTS)
64
- out += `\n...(结果达到 ${MAX_RESULTS} 条上限)`;
65
- return out;
83
+ const out = [];
84
+ let totalShown = 0;
85
+ for (const h of hits) {
86
+ const header = `${h.path}: ${h.lineNos.length} 处匹配,行号 [${h.lineNos.join(', ')}]`;
87
+ out.push(header);
88
+ if (h.bodies.length > 0) {
89
+ out.push(...h.bodies);
90
+ }
91
+ else {
92
+ out.push(` (body 已折叠,见上方行号列表 → read_file 精读)`);
93
+ }
94
+ totalShown += h.lineNos.length + h.bodies.length;
95
+ if (totalShown >= MAX_RESULTS) {
96
+ out.push(`...(结果达到 ${MAX_RESULTS} 条上限)`);
97
+ break;
98
+ }
99
+ }
100
+ if (truncated)
101
+ out.push(`...(仍有更多匹配文件未展示,缩小 glob 或收窄正则)`);
102
+ return out.join('\n');
66
103
  },
67
104
  };
@@ -1,24 +1,34 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
3
  import { MAX_FILE_LINES } from '../constants.js';
4
+ /** 默认单次 read_file 拉取的行数。刻意压低,逼 LLM 分块读大文件,
5
+ * 配合 description 中的 PAGINATION IS MANDATORY 引导。
6
+ * 300 行 ≈ 一个屏幕的源码量,够定位一段逻辑而不至于吃光上下文。 */
7
+ const DEFAULT_READ_LIMIT = 300;
4
8
  // ---------- read_file ----------
5
9
  export const readFileTool = {
6
10
  name: 'read_file',
7
- description: 'Read file content with line numbers. Read before editing. offset (1-based, default 1), limit (default 2000).' +
8
- ' For architecture/call chains, prefer codegraph over reading files one at a time.',
11
+ description: 'Read file content with line numbers. Read before editing.\n' +
12
+ 'For files >500 lines: grep first to locate regions, then call read_file multiple times ' +
13
+ 'with offset+limit (e.g. offset=350, limit=120). Do NOT read an entire large file in one call.\n' +
14
+ 'Prefer codegraph over reading files one at a time for architecture/call-chain questions.',
9
15
  parameters: {
10
16
  type: 'object',
11
17
  properties: {
12
18
  path: { type: 'string', description: 'File path, relative to the working directory' },
13
- offset: { type: 'integer', description: 'Start line (1-based), default 1' },
14
- limit: { type: 'integer', description: 'Max number of lines to read, default 2000' },
19
+ offset: { type: 'integer', description: 'Start line, 1-based (default 1).' },
20
+ limit: {
21
+ type: 'integer',
22
+ description: 'Max lines to read (default 300, hard cap 2000). Keep ranges ~80-200.',
23
+ },
15
24
  },
16
25
  required: ['path'],
17
26
  },
18
27
  async execute(args) {
19
28
  const path = String(args.path);
20
29
  const offset = Number(args.offset ?? 1);
21
- const limit = Number(args.limit ?? MAX_FILE_LINES);
30
+ // 无论 LLM 传多大,单次硬钳到 MAX_FILE_LINES,杜绝「绕过分页引导一把全拿」。
31
+ const limit = Math.min(Number(args.limit ?? DEFAULT_READ_LIMIT), MAX_FILE_LINES);
22
32
  const data = await readFile(resolve(path), 'utf8');
23
33
  const lines = data.split(/\r?\n/);
24
34
  const start = Math.max(0, offset - 1);
@@ -22,6 +22,23 @@ export const GC_DAYS = 90;
22
22
  /** memory_search 结果(召回的记忆正文)的放宽上限:指令性内容,中截破坏语义,对齐 use_skill。 */
23
23
  export const MAX_MEMORY_RESULT = 64000;
24
24
  export const IGNORE = ['**/node_modules/**', '**/.git/**'];
25
+ // ── Context Budget Scheduler(五区分账)────────────────────────────────────
26
+ /** Hot/Cold 划分:当前 step 起往前 N 个 user turn 之内的工具结果视为 Hot(绝对不压),
27
+ * 之外的视为 Cold(可调度器压)。默认 4 = 跨过 4 个用户问题仍生效。 */
28
+ export const HOT_TURN_WINDOW = 4;
29
+ /** Cold 区内可被就地 stub 的 tool 消息最低 age(经过的消费者 push 数)。默认 2,
30
+ * 与 lifecycle.ts DEFAULT_AGE_THRESHOLD 对齐。 */
31
+ export const TOOL_OLD_AGE = 2;
32
+ /** 五区预算占比(总和 0.95,留 5% 给 Reserve)。对齐 user 修正版:
33
+ * System 15 / History 20 / Hot Tool 25 / Cold Tool 25 / Summary 10。 */
34
+ export const BUDGET_RATIO = {
35
+ system: 0.15,
36
+ history: 0.20,
37
+ toolRecent: 0.25,
38
+ toolOld: 0.25,
39
+ summary: 0.10,
40
+ reserve: 0.05,
41
+ };
25
42
  // ── plan 模式(只读规划,不执行)──────────────────────────────────────────────
26
43
  /**
27
44
  * plan 模式下从工具 schema 里剔除的工具(模型根本看不到 → 调不到):
package/dist/ui/prompt.js CHANGED
@@ -84,24 +84,44 @@ export async function promptWithSlashMenu(opts) {
84
84
  let menuOpen = false;
85
85
  let selected = 0;
86
86
  let filtered = [];
87
+ const MENU_MAX_VISIBLE = 5;
88
+ let menuTop = 0; // 窗口首项在 filtered 中的索引,菜单最多显示 MENU_MAX_VISIBLE 条
87
89
  let resolved = false;
88
90
  let resolve;
89
91
  let reject;
90
- /** 菜单行(预渲染,带色)——向上展开进内容区底,由 layout 贴入。 */
92
+ /** 菜单行(预渲染,带色)——向上展开进内容区底,由 layout 贴入。最多显示 MENU_MAX_VISIBLE 条,支持上下滚动。 */
91
93
  function menuLines() {
92
94
  if (!menuOpen || filtered.length === 0)
93
95
  return [];
94
96
  const cols = layout.getGeo().cols;
95
- const maxName = Math.max(...filtered.map((c) => displayWidth(c.name)));
96
- return filtered.map((c, i) => {
97
+ const visibleCount = Math.min(MENU_MAX_VISIBLE, filtered.length);
98
+ // selected 在窗口内:selected 顶到上/下边界时才挪 menuTop
99
+ if (selected < menuTop)
100
+ menuTop = selected;
101
+ else if (selected >= menuTop + visibleCount)
102
+ menuTop = selected - visibleCount + 1;
103
+ const windowItems = filtered.slice(menuTop, menuTop + visibleCount);
104
+ const maxName = Math.max(...windowItems.map((c) => displayWidth(c.name)));
105
+ const hasMoreAbove = menuTop > 0;
106
+ const hasMoreBelow = menuTop + visibleCount < filtered.length;
107
+ return windowItems.map((c, i) => {
108
+ const globalIdx = menuTop + i;
97
109
  // 选中项:▸ 与文字均 cyan+bold(去 dim),未选中项保持 dim——选中行整体高亮。
98
- const isSel = i === selected;
110
+ const isSel = globalIdx === selected;
99
111
  const color = isSel ? `${ui.cyan}${ui.bold}` : ui.dim;
100
112
  const marker = isSel ? `${ui.cyan}${ui.bold}▸${ui.reset}` : ' ';
101
113
  const name = padEndDisplay(c.name, maxName);
102
- const descW = cols - maxName - 5; // marker + 空格 + 2 间距
103
- const desc = descW > 0 ? truncateDisplay(c.desc, descW) : '';
104
- return `${marker} ${color}${name}${ui.reset} ${color}${desc}${ui.reset}`;
114
+ // 首/末附加滚动指示(▲/▼)而非替换整行
115
+ let desc = c.desc;
116
+ let scrollHint = '';
117
+ if (i === 0 && hasMoreAbove)
118
+ scrollHint = ' ▲';
119
+ if (i === windowItems.length - 1 && hasMoreBelow)
120
+ scrollHint = ' ▼';
121
+ const hintW = displayWidth(scrollHint);
122
+ const descW = cols - maxName - 5 - hintW; // marker + 空格 + 2 间距 + hint
123
+ const descStr = descW > 0 ? truncateDisplay(desc, descW) : '';
124
+ return `${marker} ${color}${name}${ui.reset} ${color}${descStr}${ui.reset}${ui.dim}${scrollHint}${ui.reset}`;
105
125
  });
106
126
  }
107
127
  /** 当前光标在该行的显示列(供 layout 定位光标)。 */
@@ -203,12 +223,15 @@ export async function promptWithSlashMenu(opts) {
203
223
  if (cl === 0 && lines[0].startsWith('/')) {
204
224
  filtered = opts.commands.filter((c) => c.name.startsWith(lines[0]));
205
225
  menuOpen = filtered.length > 0;
206
- if (selected >= filtered.length)
226
+ if (selected >= filtered.length) {
207
227
  selected = 0;
228
+ menuTop = 0;
229
+ }
208
230
  }
209
231
  else {
210
232
  filtered = [];
211
233
  menuOpen = false;
234
+ menuTop = 0;
212
235
  }
213
236
  }
214
237
  function redraw() {
@@ -283,6 +306,7 @@ export async function promptWithSlashMenu(opts) {
283
306
  menuOpen = false;
284
307
  filtered = [];
285
308
  selected = 0;
309
+ menuTop = 0;
286
310
  if (pasteTimer) {
287
311
  clearTimeout(pasteTimer);
288
312
  pasteTimer = null;
@@ -469,6 +493,8 @@ export async function promptWithSlashMenu(opts) {
469
493
  case 'escape':
470
494
  menuOpen = false;
471
495
  filtered = [];
496
+ selected = 0;
497
+ menuTop = 0;
472
498
  redraw();
473
499
  return;
474
500
  case 'left':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {