mocode-ai 0.1.4 → 0.1.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 +196 -188
- package/dist/agent/core.js +309 -0
- package/dist/agent/index.js +62 -249
- package/dist/agent/mode.js +46 -0
- package/dist/agent/spawn.js +123 -0
- package/dist/commands/config.js +8 -26
- package/dist/config/file.js +48 -0
- package/dist/config/index.js +81 -36
- package/dist/context/classifier.js +83 -0
- package/dist/context/encoders/index.js +10 -0
- package/dist/context/encoders/passthrough.js +23 -0
- package/dist/context/index.js +10 -0
- package/dist/context/pipeline.js +84 -0
- package/dist/context/registry.js +30 -0
- package/dist/context/types.js +12 -0
- package/dist/index.js +12 -2
- package/dist/llm/index.js +11 -9
- package/dist/memory/index.js +2 -2
- package/dist/memory/reflect.js +11 -11
- package/dist/memory/store.js +4 -4
- package/dist/repl/index.js +269 -109
- package/dist/sandbox/command.js +41 -0
- package/dist/sandbox/index.js +5 -0
- package/dist/sandbox/jail.js +75 -0
- package/dist/sandbox/policy.js +58 -0
- package/dist/sandbox/root.js +19 -0
- package/dist/session/compact.js +1 -1
- package/dist/skills/index.js +2 -2
- package/dist/tools/builtins/ask-human.js +7 -7
- package/dist/tools/builtins/codegraph.js +113 -0
- package/dist/tools/builtins/edit-file.js +3 -3
- package/dist/tools/builtins/glob.js +10 -5
- package/dist/tools/builtins/grep.js +14 -9
- package/dist/tools/builtins/index.js +7 -1
- package/dist/tools/builtins/memory-forget.js +2 -2
- package/dist/tools/builtins/memory-list.js +2 -2
- package/dist/tools/builtins/memory-save.js +7 -7
- package/dist/tools/builtins/memory-search.js +4 -4
- package/dist/tools/builtins/memory-update.js +6 -6
- package/dist/tools/builtins/read-file.js +5 -4
- package/dist/tools/builtins/run-command.js +47 -8
- package/dist/tools/builtins/switch-mode.js +46 -0
- package/dist/tools/builtins/task.js +62 -0
- package/dist/tools/builtins/use-skill.js +2 -2
- package/dist/tools/builtins/web-fetch.js +16 -3
- package/dist/tools/builtins/web-search.js +6 -6
- package/dist/tools/builtins/write-file.js +3 -3
- package/dist/tools/constants.js +16 -0
- package/dist/tools/registry.js +18 -4
- package/dist/ui/content.js +25 -17
- package/dist/ui/diff.js +28 -26
- package/dist/ui/intervention.js +24 -3
- package/dist/ui/layout.js +295 -81
- package/dist/ui/markdown.js +607 -0
- package/dist/ui/mouse.js +93 -0
- package/dist/ui/prompt.js +259 -5
- package/dist/ui/render.js +1 -1
- package/dist/ui/theme.js +146 -13
- package/package.json +2 -2
package/dist/agent/index.js
CHANGED
|
@@ -1,74 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// 主 agent:runAgentCore + TUI 渲染 hooks 的薄封装。
|
|
2
|
+
// 循环逻辑(流式 chat → 工具分组执行 → 回灌 / abort 还原 / maybeCompact)全部在 core.ts,
|
|
3
|
+
// 本文件只负责把展示副作用(layout.contentWrite / Spinner / diff / 回滚轮次)注入为 AgentHooks。
|
|
4
|
+
// 行为与重构前的单文件 runAgent 完全一致——这是安全重构,实跑回归验证。
|
|
5
5
|
import { ui } from '../ui/theme.js';
|
|
6
6
|
import { Spinner } from '../ui/spinner.js';
|
|
7
7
|
import { summarizeToolCall, summarizeToolResult, truncateDisplay, fmtElapsed, } from '../ui/render.js';
|
|
8
8
|
import { renderFileChange } from '../ui/diff.js';
|
|
9
9
|
import * as layout from '../ui/layout.js';
|
|
10
10
|
import { beginTurn } from '../rollback/index.js';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
/** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
|
|
14
|
-
function parseArgs(raw) {
|
|
15
|
-
try {
|
|
16
|
-
return raw.trim() ? JSON.parse(raw) : {};
|
|
17
|
-
}
|
|
18
|
-
catch {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
/** 只读工具集:一轮多个时,连续的只读工具成组 Promise.all 并行(无副作用、互不依赖)。 */
|
|
23
|
-
const READ_TOOL_NAMES = new Set([
|
|
24
|
-
'read_file',
|
|
25
|
-
'glob',
|
|
26
|
-
'grep',
|
|
27
|
-
'web_search',
|
|
28
|
-
'web_fetch',
|
|
29
|
-
]);
|
|
30
|
-
/** mutation 工具:写盘 + 在 executeTool 内记回滚 before 快照,必须串行保快照序。 */
|
|
31
|
-
const isMutationTool = (name) => name === 'edit_file' || name === 'write_file';
|
|
11
|
+
import { config } from '../config/index.js';
|
|
12
|
+
import { runAgentCore, isMutationTool, } from './core.js';
|
|
32
13
|
/** 工具调用 ● 头:工具名 + 参数摘要(按 tool_calls 原顺序打印,让用户看到本轮跑哪些工具)。 */
|
|
33
14
|
function writeToolHeader(tc) {
|
|
34
15
|
const summary = summarizeToolCall(tc.name, tc.arguments);
|
|
35
16
|
layout.contentWrite(` ${ui.brightMagenta}●${ui.reset} ${ui.cyan}${tc.name}${ui.reset} ${ui.dim}${summary}${ui.reset}\n`);
|
|
36
17
|
}
|
|
37
|
-
/**
|
|
38
|
-
* mutation 执行前读旧内容供 diff:write_file 取整文件旧内容(不存在→null=新建),
|
|
39
|
-
* edit_file 取 old_string 起始行号(供 diff 显示真实文件行号)。读不到则 diff 退化为相对行号。
|
|
40
|
-
* 非 mutation 或参数非法返 { preWriteOld: null, editStartLine: 1 }。失败不阻断。
|
|
41
|
-
*/
|
|
42
|
-
function readDiffContext(tc, parsed) {
|
|
43
|
-
if (!parsed)
|
|
44
|
-
return { preWriteOld: null, editStartLine: 1 };
|
|
45
|
-
const p = String(parsed.path ?? '');
|
|
46
|
-
if (!p)
|
|
47
|
-
return { preWriteOld: null, editStartLine: 1 };
|
|
48
|
-
if (tc.name === 'write_file') {
|
|
49
|
-
try {
|
|
50
|
-
return { preWriteOld: readFileSync(resolve(p), 'utf8'), editStartLine: 1 };
|
|
51
|
-
}
|
|
52
|
-
catch {
|
|
53
|
-
return { preWriteOld: null, editStartLine: 1 }; // 文件不存在(新建)或不可读
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
if (tc.name === 'edit_file') {
|
|
57
|
-
const oldStr = String(parsed.old_string ?? '');
|
|
58
|
-
try {
|
|
59
|
-
const data = readFileSync(resolve(p), 'utf8');
|
|
60
|
-
const idx = oldStr ? data.indexOf(oldStr) : -1;
|
|
61
|
-
return {
|
|
62
|
-
preWriteOld: null,
|
|
63
|
-
editStartLine: idx >= 0 ? data.slice(0, idx).split('\n').length : 1,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
catch {
|
|
67
|
-
return { preWriteOld: null, editStartLine: 1 }; // 读不到:diff 退化为相对行号
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return { preWriteOld: null, editStartLine: 1 };
|
|
71
|
-
}
|
|
72
18
|
/** 渲染工具结果:mutation 成功走 diff 块(行号 + 语法高亮,仿 Claude Code);其余走一行 preview。 */
|
|
73
19
|
function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
|
|
74
20
|
if (isMutationTool(tc.name) && parsed && !output.startsWith('错误')) {
|
|
@@ -89,44 +35,30 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
|
|
|
89
35
|
}
|
|
90
36
|
}
|
|
91
37
|
}
|
|
92
|
-
/** 回灌 tool 结果到 history(裁到单条上限);tool_call_id 与 assistant.tool_calls 按序配对。 */
|
|
93
|
-
function pushToolResult(history, tc, output) {
|
|
94
|
-
history.push({
|
|
95
|
-
role: 'tool',
|
|
96
|
-
tool_call_id: tc.id,
|
|
97
|
-
content: capToolResultForHistory(tc.name, output),
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
38
|
/**
|
|
101
|
-
* agent
|
|
102
|
-
* 流式调 LLM(onText
|
|
39
|
+
* agent 核心循环(主 agent,TUI 渲染版):
|
|
40
|
+
* 流式调 LLM(onText 实时写内容区)→ 有 tool_calls 就执行并回灌
|
|
103
41
|
* → 否则流式正文即最终回复。history 在调用间持久,由 REPL 持有。
|
|
104
42
|
* 步前经 session/maybeCompact 自动压缩(接近窗口上限时三层压缩);
|
|
105
43
|
* 工具结果进 history 前经 capToolResultForHistory 裁到单条上限。
|
|
106
44
|
*
|
|
107
45
|
* 所有正文写经 layout.contentWrite(保证落在内容区、跟踪续写位、底栏不被顶);
|
|
108
46
|
* spinner 经 onFrame 回调刷状态行(layout.drawStatusBar),等待时内容区静止、底栏转圈。
|
|
109
|
-
*
|
|
110
|
-
*
|
|
47
|
+
* 思考期间不写思考内容,只让 spinner 持续转「思考中…」(首个正文 / tool_call token 到达才停)。
|
|
48
|
+
*
|
|
49
|
+
* 循环逻辑委托 runAgentCore;本函数只注入 TUI 渲染 hooks(layout + spinner + diff + 回滚轮次)。
|
|
50
|
+
* 开新轮次(beginTurn)在入口调——core 不依赖 rollback,回滚轮次是主 agent 的展示副作用。
|
|
111
51
|
*/
|
|
112
|
-
export async function runAgent(history, userInput,
|
|
52
|
+
export async function runAgent(history, userInput, signal,
|
|
113
53
|
/** 每步 chat() 返回后回调:repl 据此重算并重画状态行 context 用量条(运行中实时刷新,不冻结在轮首)。 */
|
|
114
54
|
onContextUpdate) {
|
|
115
|
-
// 中断回滚快照:入口(本 turn push 任何消息前)整段浅拷贝。abort 时 length=0;push(...saved) 还原。
|
|
116
|
-
// 用 slice() 而非 length:maybeCompact 会原地重建(length=0;push(...rebuilt)),savedLen 会失效。
|
|
117
|
-
const savedHistory = history.slice();
|
|
118
|
-
// 本轮计时:从入口到完毕(正常 return / 达上限),供 finally 打 ✻ Worked for 摘要行。
|
|
119
|
-
// 与 layout 的 turnStart 各自独立(差几毫秒),避免 agent 反向读 layout 状态的耦合。
|
|
120
|
-
const t0 = Date.now();
|
|
121
|
-
let done = false; // 正常完毕 / 达上限 true;中断 false(不显摘要)
|
|
122
|
-
history.push({ role: 'user', content: userInput });
|
|
123
55
|
// 开新轮次(回滚用):首行截断 40,供 /rollback 轮次菜单展示。
|
|
124
56
|
beginTurn(truncateDisplay(userInput.split('\n')[0] ?? '', 40));
|
|
125
|
-
layout.contentMode(); //
|
|
126
|
-
// spinner
|
|
127
|
-
//
|
|
57
|
+
layout.contentMode(); // 防御性:运行态光标归输入框光标位供 IME 锚定(enterRunningMode 已置,这里兜底)
|
|
58
|
+
// spinner:续写位内联转圈(思考中 / 执行 工具时,内容区不再「干等」)。
|
|
59
|
+
// 只走内联 paintLiveAtCursor——不调 setStatus,状态行不重复 spinner 文字(状态行只显走时,
|
|
60
|
+
// 由 turnTimer 200ms 续刷);内联帧不进缓冲、停时清掉,随后结果即写在该行——故 spinner 不入历史、PgUp 看不到。
|
|
128
61
|
const spinner = new Spinner((msg, frame) => {
|
|
129
|
-
layout.setStatus(msg, frame ?? undefined);
|
|
130
62
|
if (frame) {
|
|
131
63
|
layout.paintLiveAtCursor(` ${ui.brightMagenta}${frame}${ui.reset} ${ui.dim}${msg}…${ui.reset}`);
|
|
132
64
|
}
|
|
@@ -134,178 +66,59 @@ onContextUpdate) {
|
|
|
134
66
|
layout.clearLiveAtCursor();
|
|
135
67
|
}
|
|
136
68
|
});
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
let gotText = false;
|
|
69
|
+
// lastChar 镜像:core 跟踪流式末字符决定补换行,但 TUI hooks 需读它决定 layout.contentWrite('\n')。
|
|
70
|
+
// core 的 onTextEnd hook 只在 lastChar !== '\n' 时才调,调后置 '\n';镜像与此同步。
|
|
140
71
|
let lastChar = '';
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// 之后任何输出按 text 走,不再回到 thinking(避免后续再被错误折叠)
|
|
153
|
-
mode = 'text';
|
|
154
|
-
};
|
|
155
|
-
const onThinking = (s) => {
|
|
156
|
-
if (mode === 'idle') {
|
|
157
|
-
spinner.stop();
|
|
158
|
-
if (lastChar && lastChar !== '\n')
|
|
72
|
+
const hooks = {
|
|
73
|
+
onText: (s) => {
|
|
74
|
+
spinner.stop(); // 任何正文 token 都停 spinner(首 token 停「思考中」;onToolCall 重启后若又来文本则停「生成中」)。未旋转时 stop 为 no-op。
|
|
75
|
+
layout.contentWriteMd(s); // 正文走 markdown 渲染(代码块高亮 / 标题 / 列表 / 行内 …),见 ui/markdown.ts
|
|
76
|
+
if (s)
|
|
77
|
+
lastChar = s[s.length - 1];
|
|
78
|
+
},
|
|
79
|
+
onToolCall: (name) => {
|
|
80
|
+
// 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
|
|
81
|
+
// 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
|
|
82
|
+
if (lastChar && lastChar !== '\n') {
|
|
159
83
|
layout.contentWrite('\n');
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
84
|
+
lastChar = '\n';
|
|
85
|
+
}
|
|
86
|
+
if (name)
|
|
87
|
+
spinner.start(`生成 ${name}…`);
|
|
88
|
+
},
|
|
89
|
+
onStepStart: () => spinner.start('思考中'),
|
|
90
|
+
onChatDone: () => spinner.stop(),
|
|
91
|
+
onTextEnd: () => {
|
|
92
|
+
if (lastChar && lastChar !== '\n') {
|
|
93
|
+
layout.contentWrite('\n');
|
|
94
|
+
lastChar = '\n';
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
onToolHeader: (tc) => writeToolHeader(tc),
|
|
98
|
+
onToolStart: (name) => spinner.start(`执行 ${name}`),
|
|
99
|
+
onToolDone: () => spinner.stop(),
|
|
100
|
+
onToolResult: (tc, output, parsed, preWriteOld, editStartLine) => writeToolResult(tc, output, parsed, preWriteOld, editStartLine),
|
|
101
|
+
onToolBatchEnd: () => layout.contentWrite('\n'),
|
|
102
|
+
onNoReply: () => layout.contentWrite(`${ui.dim}(无回复)${ui.reset}\n`),
|
|
103
|
+
onMaxSteps: () => layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}达到最大步数(${config.maxSteps}),本轮停止。${ui.reset}\n`),
|
|
104
|
+
onAbort: () => {
|
|
105
|
+
spinner.stop();
|
|
165
106
|
if (lastChar && lastChar !== '\n')
|
|
166
107
|
layout.contentWrite('\n');
|
|
167
|
-
layout.
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
mode = 'thinking';
|
|
171
|
-
thinkingBuffer += s;
|
|
172
|
-
layout.contentWrite(`${ui.dim}${s}${ui.reset}`);
|
|
173
|
-
if (s)
|
|
174
|
-
lastChar = s[s.length - 1];
|
|
175
|
-
};
|
|
176
|
-
const onText = (s) => {
|
|
177
|
-
flushThinkCollapsed(); // 思考→正文过渡时折叠
|
|
178
|
-
spinner.stop(); // 任何正文 token 都停 spinner(首 token 停「思考中」;onToolCall 重启后若又来文本则停「生成中」)。未旋转时 stop 为 no-op。
|
|
179
|
-
mode = 'text';
|
|
180
|
-
gotText = true;
|
|
181
|
-
layout.contentWrite(s);
|
|
182
|
-
if (s)
|
|
183
|
-
lastChar = s[s.length - 1];
|
|
184
|
-
};
|
|
185
|
-
const onToolCall = (name) => {
|
|
186
|
-
// 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
|
|
187
|
-
// 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
|
|
188
|
-
if (lastChar && lastChar !== '\n') {
|
|
189
|
-
layout.contentWrite('\n');
|
|
190
|
-
lastChar = '\n';
|
|
191
|
-
}
|
|
192
|
-
if (name)
|
|
193
|
-
spinner.start(`生成 ${name}…`);
|
|
108
|
+
layout.contentWrite(`${ui.dim}(已中断)${ui.reset}\n`);
|
|
109
|
+
},
|
|
110
|
+
onDone: (elapsedMs) => layout.contentWrite(` ${ui.dim}✻ Worked for ${fmtElapsed(elapsedMs)}${ui.reset}\n`),
|
|
194
111
|
};
|
|
195
112
|
try {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
let result;
|
|
204
|
-
try {
|
|
205
|
-
result = await chat(history, { onText, onThinking, onToolCall }, signal);
|
|
206
|
-
}
|
|
207
|
-
catch (e) {
|
|
208
|
-
// 中断(用户运行中 Ctrl+C):停 spinner、补换行、提示、history 还原到本 turn 前、return(不抛)。
|
|
209
|
-
// abort 只在 await chat() 期生效;tool 执行不可中断,故不会留下未配对的 tool_call_id。
|
|
210
|
-
if (signal?.aborted ||
|
|
211
|
-
(e instanceof Error &&
|
|
212
|
-
(e.name === 'AbortError' || e.name === 'APIUserAbortError'))) {
|
|
213
|
-
spinner.stop();
|
|
214
|
-
if (lastChar && lastChar !== '\n')
|
|
215
|
-
layout.contentWrite('\n');
|
|
216
|
-
layout.contentWrite(`${ui.dim}(已中断)${ui.reset}\n`);
|
|
217
|
-
history.length = 0;
|
|
218
|
-
history.push(...savedHistory);
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
throw e;
|
|
222
|
-
}
|
|
223
|
-
contextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
|
|
224
|
-
spinner.stop();
|
|
225
|
-
// lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
|
|
226
|
-
onContextUpdate?.();
|
|
227
|
-
if (result.toolCalls.length > 0) {
|
|
228
|
-
// 思考后直接进入 tool_call(无 text):先把可见的思考段折叠
|
|
229
|
-
flushThinkCollapsed();
|
|
230
|
-
// 流式正文末尾补换行(若 onToolCall 已补则 lastChar='\n',此处 no-op);防 ● 行黏在正文行尾
|
|
231
|
-
if (mode !== 'idle' && lastChar !== '\n')
|
|
232
|
-
layout.contentWrite('\n');
|
|
233
|
-
// 带工具调用的 assistant 消息原样回灌(OpenAI 格式要求)
|
|
234
|
-
history.push({
|
|
235
|
-
role: 'assistant',
|
|
236
|
-
content: result.content,
|
|
237
|
-
tool_calls: result.toolCalls.map((tc) => ({
|
|
238
|
-
id: tc.id,
|
|
239
|
-
type: 'function',
|
|
240
|
-
function: { name: tc.name, arguments: tc.arguments },
|
|
241
|
-
})),
|
|
242
|
-
});
|
|
243
|
-
// 工具分组执行(保 tool_calls 原顺序):连续的只读工具(READ_TOOL_NAMES)成组并发——一次性启动全部
|
|
244
|
-
// executeTool(调用即开始 I/O),再按原顺序逐个 await + 渲染(● 头与 ↳ 结果紧邻,修并行时"全 ● 后全 ↳"分离)。
|
|
245
|
-
// mutation(write_file/edit_file)及 run_command/use_skill 各为单步串行屏障——mutation 串行保
|
|
246
|
-
// recordMutation 调用序 = 回滚快照序(executeTool 内写前记 before 快照,同文件多次写需按序)。
|
|
247
|
-
// 渲染与 history 回灌一律按原顺序;并发只影响执行时序,tool_call_id 仍按序配对。
|
|
248
|
-
// executeTool 永不抛错(调度器 try/catch 返字符串),故 await 单个 promise 不会抛(永远 resolve 为字符串)。
|
|
249
|
-
const calls = result.toolCalls;
|
|
250
|
-
let i = 0;
|
|
251
|
-
while (i < calls.length) {
|
|
252
|
-
if (READ_TOOL_NAMES.has(calls[i].name)) {
|
|
253
|
-
// 收集连续只读组(≥1),并发执行:先一次性启动所有(executeTool 调用即开始 I/O),
|
|
254
|
-
// 再按原顺序逐个 await + 渲染——● 头与 ↳ 结果紧邻、顺序 = tool_calls 序(修"全 ● 后全 ↳"分离 bug)。
|
|
255
|
-
// 异步工具(web_fetch 等)并发跑、总耗时 ≈ 最慢一个;同步工具(glob/grep)map 时已顺序跑完,await 即返。
|
|
256
|
-
let j = i;
|
|
257
|
-
while (j < calls.length && READ_TOOL_NAMES.has(calls[j].name))
|
|
258
|
-
j++;
|
|
259
|
-
const batch = calls.slice(i, j);
|
|
260
|
-
const started = batch.map((tc) => executeTool(tc.name, tc.arguments));
|
|
261
|
-
for (let k = 0; k < batch.length; k++) {
|
|
262
|
-
const tc = batch[k];
|
|
263
|
-
writeToolHeader(tc);
|
|
264
|
-
spinner.start(`执行 ${tc.name}`);
|
|
265
|
-
const output = await started[k];
|
|
266
|
-
spinner.stop();
|
|
267
|
-
writeToolResult(tc, output, null, null, 1); // 只读工具无 diff
|
|
268
|
-
pushToolResult(history, tc, output);
|
|
269
|
-
}
|
|
270
|
-
i = j;
|
|
271
|
-
}
|
|
272
|
-
else {
|
|
273
|
-
// 单步串行(mutation / run_command / use_skill)——逐个执行,保快照序
|
|
274
|
-
const tc = calls[i];
|
|
275
|
-
writeToolHeader(tc);
|
|
276
|
-
const parsed = isMutationTool(tc.name)
|
|
277
|
-
? parseArgs(tc.arguments)
|
|
278
|
-
: null;
|
|
279
|
-
const { preWriteOld, editStartLine } = readDiffContext(tc, parsed);
|
|
280
|
-
spinner.start(`执行 ${tc.name}`);
|
|
281
|
-
const output = await executeTool(tc.name, tc.arguments);
|
|
282
|
-
spinner.stop();
|
|
283
|
-
writeToolResult(tc, output, parsed, preWriteOld, editStartLine);
|
|
284
|
-
pushToolResult(history, tc, output);
|
|
285
|
-
i++;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
continue; // 带着工具结果再调一次 LLM
|
|
289
|
-
}
|
|
290
|
-
// 思考后既无 tool_call 也无 text(纯思考):兜底折叠
|
|
291
|
-
flushThinkCollapsed();
|
|
292
|
-
if (mode !== 'idle' && lastChar !== '\n')
|
|
293
|
-
layout.contentWrite('\n'); // 流式末尾补换行
|
|
294
|
-
// 没有工具调用:流式正文即最终回复(已实时打印)
|
|
295
|
-
if (!gotText)
|
|
296
|
-
layout.contentWrite(`${ui.dim}(无回复)${ui.reset}\n`);
|
|
297
|
-
history.push({ role: 'assistant', content: result.content });
|
|
298
|
-
done = true;
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}达到最大步数(${MAX_STEPS}),本轮停止。${ui.reset}\n`);
|
|
302
|
-
done = true;
|
|
113
|
+
await runAgentCore({
|
|
114
|
+
history,
|
|
115
|
+
userInput,
|
|
116
|
+
signal,
|
|
117
|
+
onContextUpdate,
|
|
118
|
+
hooks,
|
|
119
|
+
});
|
|
303
120
|
}
|
|
304
121
|
finally {
|
|
305
122
|
spinner.stop();
|
|
306
|
-
// 跑完(正常 / 达上限)在回复末尾打耗时摘要行(仿 Claude Code);中断 done=false 不打。
|
|
307
|
-
if (done) {
|
|
308
|
-
layout.contentWrite(` ${ui.dim}✻ Worked for ${fmtElapsed(Date.now() - t0)}${ui.reset}\n`);
|
|
309
|
-
}
|
|
310
123
|
}
|
|
311
124
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent 模式的共享状态(零依赖纯叶子)。
|
|
3
|
+
*
|
|
4
|
+
* 把 `agentMode` 从 repl 的模块变量提到这里,让三方都能用、且不破坏依赖单向:
|
|
5
|
+
* - `tools/builtins/switch-mode.ts` 写它(setAgentMode)——LLM 在 agent 循环里自切 plan↔auto。
|
|
6
|
+
* - `agent/index.ts` 每步读它(getAgentMode)——决定 chat() 用全量 chatTools 还是 planChatTools 只读子集,
|
|
7
|
+
* 以及串行分支的 plan 防御 backstop。这样 LLM 中途切模式后,下一次 chat() 立即看到新工具集。
|
|
8
|
+
* - `repl/index.ts` 读写它 + 注册 onModeChange 监听器(做 applyMode 重写 history[0] + refreshStatusBase
|
|
9
|
+
* 刷状态行 modeTag)。
|
|
10
|
+
*
|
|
11
|
+
* 依赖方向无环:本模块不 import 任何业务模块。`agent/index → tools/registry → tools/builtins/switch-mode
|
|
12
|
+
* → agent/mode` 链终点是本叶子,不回到 agent/index。`tools → agent/mode` 与既有 `tools → ui`(ask-human)、
|
|
13
|
+
* `tools → rollback`(registry)同性质——都是引叶子。
|
|
14
|
+
*
|
|
15
|
+
* listener 同步触发:setAgentMode 在变更模式后同步调 listener(无 async)。switch_mode 工具在 executeTool
|
|
16
|
+
* 内调 setAgentMode → listener 同步 applyMode(重写 history[0])+ refreshStatusBase;execute 返回字符串后,
|
|
17
|
+
* agent 串行分支继续,下一步 chat() 读到新模式 + 新系统提示,一致。
|
|
18
|
+
*/
|
|
19
|
+
let currentMode = 'auto';
|
|
20
|
+
let listener = null;
|
|
21
|
+
/** 当前 agent 模式。auto=全工具执行;plan=只读探查 + 产出计划(写盘/命令/记忆写入工具被 schema 剔除)。 */
|
|
22
|
+
export function getAgentMode() {
|
|
23
|
+
return currentMode;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 设置 agent 模式。同模式 no-op(不触发 listener)。变更时同步触发 onModeChange 注册的监听器
|
|
27
|
+
* (repl 在 startRepl 注册:applyMode 重写 history[0] + refreshStatusBase 刷状态行)。
|
|
28
|
+
* 返回之前的模式,供调用方(如 runAgent 中断恢复)还原。
|
|
29
|
+
*/
|
|
30
|
+
export function setAgentMode(m) {
|
|
31
|
+
const prev = currentMode;
|
|
32
|
+
if (prev === m)
|
|
33
|
+
return prev;
|
|
34
|
+
currentMode = m;
|
|
35
|
+
try {
|
|
36
|
+
listener?.(m);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// listener 不应抛(applyMode/refreshStatusBase 都不抛);兜底,不阻断工具执行。
|
|
40
|
+
}
|
|
41
|
+
return prev;
|
|
42
|
+
}
|
|
43
|
+
/** 注册模式变更监听器(单一,后注册覆盖先注册)。repl 在 startRepl 启动时注册一次。 */
|
|
44
|
+
export function onModeChange(cb) {
|
|
45
|
+
listener = cb;
|
|
46
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// 子 agent 封装:runAgentCore + 静默 hooks。独立 history / 可受限工具子集 / 低步数上限。
|
|
2
|
+
// 供 task 工具(主 agent 派生子任务)调用——子 agent 的最终摘要回灌主 history。
|
|
3
|
+
//
|
|
4
|
+
// 与主 agent 的区别:
|
|
5
|
+
// - 不写主屏(layout.contentWrite):中间过程(流式正文 / 工具头 / diff)缓冲到内部字符串,
|
|
6
|
+
// 结束返回给 task 工具(task 把它当 tool 结果回灌主 history,主 agent 据此继续)。
|
|
7
|
+
// - 独立 history:不共享主对话,避免子任务的工具噪声污染主上下文。
|
|
8
|
+
// - 系统提示复用主 agent 组装链(config.systemPrompt + memory 段 + skills 段)+ 子 agent 角色后缀。
|
|
9
|
+
// - 工具子集:按白名单从 chatTools 过滤;无白名单 = 全量(但 task 工具调用方通常会限定只读)。
|
|
10
|
+
// - 不调 beginTurn(不进主回滚链);但文件 mutation 仍经 executeTool→recordMutation,
|
|
11
|
+
// 归入当前主轮次(语义:子 agent 的改动属于当前主轮,可随 /rollback 一起撤销)。
|
|
12
|
+
// - 步数上限默认更低(config.subAgentMaxSteps ?? 50),防子任务失控耗尽配额。
|
|
13
|
+
// - 中断透传:opts.signal(主 agent 的 abort signal)透传给 runAgentCore → chat/executeTool,
|
|
14
|
+
// 主 Ctrl+C 树杀子 agent(chat 流式 abort + run_command/web_fetch 即时取消)。
|
|
15
|
+
// - 逻辑隔离(回滚):skipRollback=true,子 agent 的 write_file/edit_file 改动不进主回滚快照链,
|
|
16
|
+
// 主 /rollback 不撤销子 agent 改动(靠 git 兜底)。子 agent 与主 agent 共享 cwd(文件改动可见)。
|
|
17
|
+
import { chatTools } from '../llm/index.js';
|
|
18
|
+
import { config } from '../config/index.js';
|
|
19
|
+
import { effectiveSystemPrompt } from '../skills/index.js';
|
|
20
|
+
import { buildMemorySection, buildMemoryIndexSection } from '../memory/index.js';
|
|
21
|
+
import { ui } from '../ui/theme.js';
|
|
22
|
+
import { runAgentCore } from './core.js';
|
|
23
|
+
import { summarizeToolCall, summarizeToolResult, truncateDisplay } from '../ui/render.js';
|
|
24
|
+
/** 子 agent 系统提示后缀:角色与约束。 */
|
|
25
|
+
const SUBAGENT_SUFFIX = `
|
|
26
|
+
|
|
27
|
+
## ⛯ SUB-AGENT MODE (you are a sub-agent)
|
|
28
|
+
You are a sub-agent spawned by the main agent to handle an isolated sub-task. You have your own conversation history (independent of the main thread).
|
|
29
|
+
- Focus solely on the assigned sub-task. Do NOT attempt to call the "task" tool (no recursive spawning).
|
|
30
|
+
- Use the tools available to you to complete the sub-task.
|
|
31
|
+
- When done, your final text reply will be returned to the main agent as a summary — make it concise and actionable: what you did, key findings, files changed, and any issues. The main agent will decide the next step based on your summary.`;
|
|
32
|
+
/**
|
|
33
|
+
* 派生一个子 agent 执行独立子任务。
|
|
34
|
+
*
|
|
35
|
+
* 行为:
|
|
36
|
+
* - 构造独立 history:[{system: 主系统提示 + SUBAGENT_SUFFIX + 自定义后缀}, {user: prompt}]
|
|
37
|
+
* - 工具子集:按 opts.tools 白名单过滤 chatTools;无白名单 = 全量。
|
|
38
|
+
* - 静默 hooks:流式正文 / 工具头 / 结果缓冲到 transcript;不写主屏(layout)。
|
|
39
|
+
* - 返回 { summary, completed, transcript }。summary 给 task 工具回灌主 history。
|
|
40
|
+
*
|
|
41
|
+
* 中断:opts.signal 透传给子 runAgentCore——主 Ctrl+C 树杀子 agent(chat abort + 工具 abort)。
|
|
42
|
+
* 子 agent 跑在主 signal 下,主 abort 即子 abort;子 agent 的 abortRestore 还原子 history + 模式。
|
|
43
|
+
*/
|
|
44
|
+
export async function spawnAgent(opts) {
|
|
45
|
+
const maxSteps = opts.maxSteps ?? config.subAgentMaxSteps ?? 50;
|
|
46
|
+
// 构造子 agent 系统提示:复用主 agent 组装链 + 子 agent 角色后缀 + 自定义后缀。
|
|
47
|
+
const systemPrompt = effectiveSystemPrompt(config.systemPrompt +
|
|
48
|
+
buildMemorySection() +
|
|
49
|
+
buildMemoryIndexSection() +
|
|
50
|
+
SUBAGENT_SUFFIX +
|
|
51
|
+
(opts.systemPromptSuffix ? `\n\n${opts.systemPromptSuffix}` : ''));
|
|
52
|
+
// 工具子集:白名单过滤。无白名单 = 全量 chatTools,但始终剔除 task(防递归派生)。
|
|
53
|
+
let toolsOverride;
|
|
54
|
+
if (opts.tools && opts.tools.length > 0) {
|
|
55
|
+
const allow = new Set(opts.tools);
|
|
56
|
+
toolsOverride = chatTools.filter((t) => allow.has(t.function.name) && t.function.name !== 'task');
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
toolsOverride = chatTools.filter((t) => t.function.name !== 'task');
|
|
60
|
+
}
|
|
61
|
+
// 独立 history(子 agent 自己持有,不共享主对话)。
|
|
62
|
+
// 只塞 system;user 消息由 runAgentCore 的 userInput 参数 push(与主 agent 一致)。
|
|
63
|
+
const history = [
|
|
64
|
+
{ role: 'system', content: systemPrompt },
|
|
65
|
+
];
|
|
66
|
+
// 静默 hooks:缓冲中间过程到 transcript,不写主屏。
|
|
67
|
+
let transcript = '';
|
|
68
|
+
const buf = [];
|
|
69
|
+
const writeBuf = (s) => {
|
|
70
|
+
buf.push(s);
|
|
71
|
+
transcript += s;
|
|
72
|
+
};
|
|
73
|
+
let lastChar = '';
|
|
74
|
+
const hooks = {
|
|
75
|
+
onText: (s) => {
|
|
76
|
+
writeBuf(s); // 缓冲流式正文(无 markdown 渲染,原始文本)
|
|
77
|
+
if (s)
|
|
78
|
+
lastChar = s[s.length - 1];
|
|
79
|
+
},
|
|
80
|
+
onToolCall: (name) => {
|
|
81
|
+
if (lastChar && lastChar !== '\n') {
|
|
82
|
+
writeBuf('\n');
|
|
83
|
+
lastChar = '\n';
|
|
84
|
+
}
|
|
85
|
+
// 子 agent 静默:不启 spinner(name 参数仅主 agent 用)
|
|
86
|
+
},
|
|
87
|
+
onToolHeader: (tc) => {
|
|
88
|
+
const summary = summarizeToolCall(tc.name, tc.arguments);
|
|
89
|
+
writeBuf(` ● ${tc.name} ${summary}\n`);
|
|
90
|
+
},
|
|
91
|
+
onToolResult: (tc, output) => {
|
|
92
|
+
const preview = summarizeToolResult(tc.name, output);
|
|
93
|
+
if (preview)
|
|
94
|
+
writeBuf(` ↳ ${preview}\n`);
|
|
95
|
+
},
|
|
96
|
+
onTextEnd: () => {
|
|
97
|
+
if (lastChar && lastChar !== '\n') {
|
|
98
|
+
writeBuf('\n');
|
|
99
|
+
lastChar = '\n';
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
onToolBatchEnd: () => writeBuf('\n'),
|
|
103
|
+
onNoReply: () => writeBuf(`${ui.dim}(无回复)${ui.reset}\n`),
|
|
104
|
+
onMaxSteps: () => writeBuf(` ● 达到最大步数(${maxSteps}),子 agent 停止。\n`),
|
|
105
|
+
onDone: (elapsedMs) => writeBuf(` ✻ 子 agent 耗时 ${(elapsedMs / 1000).toFixed(1)}s\n`),
|
|
106
|
+
// onStepStart / onChatDone / onToolStart / onToolDone / onAbort:子 agent 静默,无需 spinner / 中断渲染。
|
|
107
|
+
// abort 还原(history 还原 + 模式还原)由 core 的 abortRestore 处理,hooks 只管展示。
|
|
108
|
+
};
|
|
109
|
+
const result = await runAgentCore({
|
|
110
|
+
history,
|
|
111
|
+
userInput: opts.prompt,
|
|
112
|
+
signal: opts.signal, // 主 Ctrl+C 树杀子 agent(chat abort + 工具 abort)
|
|
113
|
+
hooks,
|
|
114
|
+
maxSteps,
|
|
115
|
+
toolsOverride,
|
|
116
|
+
skipRollback: true, // 逻辑隔离:子 agent 文件改动不进主回滚快照链,主 /rollback 不撤销(靠 git 兜底)
|
|
117
|
+
});
|
|
118
|
+
return {
|
|
119
|
+
summary: result.finalText,
|
|
120
|
+
completed: result.completed,
|
|
121
|
+
transcript: truncateDisplay(transcript, 20000), // 防过大;调试用,回灌主 history 的是 summary 不是 transcript
|
|
122
|
+
};
|
|
123
|
+
}
|
package/dist/commands/config.js
CHANGED
|
@@ -1,31 +1,19 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
1
|
import * as readline from 'node:readline';
|
|
5
|
-
import
|
|
6
|
-
/** ~/.mocode/config:全局配置(与 ~/.mocode/skills 同目录,任意终端 / 任意目录生效)。 */
|
|
7
|
-
const CONFIG_PATH = path.join(os.homedir(), '.mocode', 'config');
|
|
2
|
+
import { CONFIG_PATH, readConfigFile, writeConfigKeys } from '../config/file.js';
|
|
8
3
|
function ask(rl, q) {
|
|
9
4
|
return new Promise((resolve) => {
|
|
10
5
|
rl.question(q, (ans) => resolve(ans.trim()));
|
|
11
6
|
});
|
|
12
7
|
}
|
|
13
|
-
/** 读已有配置(没有则空对象),用于回填默认值与保留其它键。 */
|
|
14
|
-
function readExisting() {
|
|
15
|
-
try {
|
|
16
|
-
return dotenv.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
17
|
-
}
|
|
18
|
-
catch {
|
|
19
|
-
return {};
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
8
|
/**
|
|
23
9
|
* 首跑配置向导:交互填 LLM_BASE_URL / LLM_API_KEY / LLM_MODEL,写 ~/.mocode/config。
|
|
24
|
-
* 只交互三键、保留文件里其它键(MAX_TOKENS / CONTEXT_WINDOW_TOKENS
|
|
25
|
-
*
|
|
10
|
+
* 只交互三键、保留文件里其它键(MAX_TOKENS / CONTEXT_WINDOW_TOKENS / MOCODE_THEME 等)。
|
|
11
|
+
* prompt 全纯文本(readline 光标按字符算,不能含 ANSI)。
|
|
12
|
+
* 由 index.ts 在 `mocode config` 时动态加载,故不 import config/index.ts(避免缺配置时 requireEnv
|
|
13
|
+
* 直接退出);只 import config/file.ts(纯 I/O 叶子,无 env 校验 / process.exit)。
|
|
26
14
|
*/
|
|
27
15
|
export async function runConfigWizard() {
|
|
28
|
-
const cur =
|
|
16
|
+
const cur = readConfigFile();
|
|
29
17
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
30
18
|
console.log(`mocode 配置向导 — 写入 ${CONFIG_PATH}(Ctrl+C 取消)\n`);
|
|
31
19
|
const baseURLIn = await ask(rl, `LLM_BASE_URL${cur.LLM_BASE_URL ? ` [${cur.LLM_BASE_URL}]` : ''}(如 https://open.bigmodel.cn/api/v3): `);
|
|
@@ -46,16 +34,10 @@ export async function runConfigWizard() {
|
|
|
46
34
|
const model = modelIn || cur.LLM_MODEL || 'gpt-4o-mini';
|
|
47
35
|
rl.close();
|
|
48
36
|
// 合并:保留其它键,只覆盖三键。
|
|
49
|
-
|
|
50
|
-
...cur,
|
|
37
|
+
writeConfigKeys({
|
|
51
38
|
LLM_BASE_URL: baseURL,
|
|
52
39
|
LLM_API_KEY: apiKey,
|
|
53
40
|
LLM_MODEL: model,
|
|
54
|
-
};
|
|
55
|
-
const body = Object.entries(merged)
|
|
56
|
-
.map(([k, v]) => `${k}=${v}`)
|
|
57
|
-
.join('\n');
|
|
58
|
-
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
59
|
-
fs.writeFileSync(CONFIG_PATH, `${body}\n`, 'utf8');
|
|
41
|
+
});
|
|
60
42
|
console.log(`\n已写入 ${CONFIG_PATH}。现在运行 \`mocode\` 即可启动(任意目录、任意终端)。`);
|
|
61
43
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import dotenv from 'dotenv';
|
|
5
|
+
/**
|
|
6
|
+
* ~/.mocode/config 全局 dotenv 配置的读写叶子(纯 node:fs / node:os / node:path + dotenv)。
|
|
7
|
+
*
|
|
8
|
+
* 独立于 `config/index.ts`——**不**触发 requireEnv / process.exit,故 `commands/config.ts`
|
|
9
|
+
* (首跑向导,刻意不 import config/index.ts)与 `repl/index.ts`(`/theme` 持久化)都能安全共享。
|
|
10
|
+
* 主题等 UI 偏好走 `updateConfigKey` 单键写;向导多键一次性写走 `writeConfigKeys`。
|
|
11
|
+
*/
|
|
12
|
+
/** ~/.mocode/config:全局配置(与 ~/.mocode/skills 同目录,任意终端 / 任意目录生效)。 */
|
|
13
|
+
export const CONFIG_PATH = path.join(os.homedir(), '.mocode', 'config');
|
|
14
|
+
/** 读已有配置(没有则空对象),用于回填默认值与保留其它键。 */
|
|
15
|
+
export function readConfigFile() {
|
|
16
|
+
try {
|
|
17
|
+
return dotenv.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 读-合并-写:把 entries 覆盖进 ~/.mocode/config,保留文件里其它键。
|
|
25
|
+
* mkdirSync 兜底(首跑无 ~/.mocode)。不吞 I/O 异常——向导路径让其抛(与历史行为一致);
|
|
26
|
+
* UI 偏好路径用 `updateConfigKey`(自带 try/catch 静默)。
|
|
27
|
+
*/
|
|
28
|
+
export function writeConfigKeys(entries) {
|
|
29
|
+
const merged = { ...readConfigFile(), ...entries };
|
|
30
|
+
const body = Object.entries(merged)
|
|
31
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
32
|
+
.join('\n');
|
|
33
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
34
|
+
fs.writeFileSync(CONFIG_PATH, `${body}\n`, 'utf8');
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 单键持久化(主题等 UI 偏好):写失败静默——下次启动读不到则回退默认,不阻断 REPL。
|
|
38
|
+
* 注意:shell env 设了同名键时,`config/index.ts` 的 loadEnvFiles 只 backfill undefined 的
|
|
39
|
+
* process.env,故文件写对**下次启动**可能被 shell 盖——调用方据此给 dim 警告。
|
|
40
|
+
*/
|
|
41
|
+
export function updateConfigKey(key, value) {
|
|
42
|
+
try {
|
|
43
|
+
writeConfigKeys({ [key]: value });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// 写失败不阻断(UI 偏好)
|
|
47
|
+
}
|
|
48
|
+
}
|