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.
- package/dist/agent/core.js +61 -11
- package/dist/config/index.js +10 -1
- package/dist/context/budget.js +225 -0
- package/dist/context/index.js +5 -2
- package/dist/context/lifecycle.js +384 -0
- package/dist/context/relevance.js +284 -0
- package/dist/llm/index.js +77 -6
- package/dist/repl/index.js +64 -11
- package/dist/session/compact.js +120 -12
- package/dist/session/index.js +5 -0
- package/dist/session/scheduler.js +172 -0
- package/dist/tools/builtins/ask-human.js +42 -3
- package/dist/tools/builtins/grep.js +52 -15
- package/dist/tools/builtins/read-file.js +15 -5
- package/dist/tools/constants.js +17 -0
- package/dist/ui/prompt.js +34 -8
- package/package.json +1 -1
|
@@ -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,
|
|
9
|
-
'
|
|
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
|
|
44
|
+
const hits = [];
|
|
38
45
|
let scanned = 0;
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 (
|
|
81
|
+
if (hits.length === 0)
|
|
61
82
|
return `无匹配(扫描了 ${scanned} 个文件)`;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
8
|
-
'
|
|
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
|
|
14
|
-
limit: {
|
|
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
|
-
|
|
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);
|
package/dist/tools/constants.js
CHANGED
|
@@ -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
|
|
96
|
-
|
|
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 =
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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':
|