mocode-ai 0.6.7 → 0.6.9
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 +9 -4
- package/README.zh-CN.md +5 -0
- package/dist/agent/core.js +5 -5
- package/dist/agent/index.js +10 -8
- package/dist/agent/spawn.js +1 -5
- package/dist/commands/config.js +9 -7
- package/dist/config/index.js +49 -41
- package/dist/context/budget.js +2 -3
- package/dist/i18n/index.js +450 -0
- package/dist/index.js +8 -4
- package/dist/llm/index.js +19 -16
- package/dist/mcp/client.js +389 -0
- package/dist/mcp/config.js +128 -0
- package/dist/mcp/index.js +105 -0
- package/dist/mcp/registry.js +5 -0
- package/dist/mcp/types.js +1 -0
- package/dist/permissions/index.js +16 -12
- package/dist/pet/state.js +2 -1
- package/dist/repl/index.js +210 -102
- package/dist/rollback/index.js +311 -119
- package/dist/session/persist.js +11 -5
- package/dist/tools/builtins/ask-human.js +4 -3
- package/dist/tools/builtins/run-command.js +6 -5
- package/dist/tools/builtins/task.js +5 -4
- package/dist/tools/registry.js +58 -22
- package/dist/tools/result.js +7 -0
- package/dist/ui/intervention.js +12 -7
- package/dist/ui/layout.js +4 -3
- package/dist/ui/prompt.js +83 -28
- package/dist/ui/render.js +15 -12
- package/package.json +1 -1
package/dist/rollback/index.js
CHANGED
|
@@ -1,46 +1,199 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { config } from '../config/index.js';
|
|
4
4
|
import { truncateDisplay } from '../ui/render.js';
|
|
5
5
|
import { toText } from '../context/utils.js';
|
|
6
6
|
let turnIdCounter = 0;
|
|
7
7
|
let currentTurnId = 0;
|
|
8
|
+
let sequenceCounter = 0;
|
|
8
9
|
let turns = [];
|
|
9
10
|
let snapshots = [];
|
|
10
|
-
const
|
|
11
|
-
|
|
11
|
+
const rootDir = () => path.resolve(process.cwd());
|
|
12
|
+
function isInside(parent, child) {
|
|
13
|
+
const rel = path.relative(parent, child);
|
|
14
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
15
|
+
}
|
|
16
|
+
/** 规整成 cwd 相对路径;所有持久化快照均使用此格式。 */
|
|
12
17
|
function toRel(p) {
|
|
13
18
|
try {
|
|
14
|
-
const rel = path.relative(
|
|
15
|
-
return rel === '' ?
|
|
19
|
+
const rel = path.relative(rootDir(), path.resolve(p));
|
|
20
|
+
return rel === '' ? '.' : rel;
|
|
16
21
|
}
|
|
17
22
|
catch {
|
|
18
23
|
return p;
|
|
19
24
|
}
|
|
20
25
|
}
|
|
21
|
-
/**
|
|
26
|
+
/** 防止损坏/篡改的 snapshots.json 在恢复时写出工作区。 */
|
|
27
|
+
function safeFullPath(rel) {
|
|
28
|
+
const root = rootDir();
|
|
29
|
+
const full = path.resolve(root, rel);
|
|
30
|
+
return full !== root && isInside(root, full) ? full : null;
|
|
31
|
+
}
|
|
32
|
+
function readState(full) {
|
|
33
|
+
try {
|
|
34
|
+
const stat = lstatSync(full);
|
|
35
|
+
const mode = stat.mode & 0o777;
|
|
36
|
+
if (stat.isSymbolicLink()) {
|
|
37
|
+
return { kind: 'symlink', data: readlinkSync(full), mode };
|
|
38
|
+
}
|
|
39
|
+
if (stat.isDirectory())
|
|
40
|
+
return { kind: 'directory', mode };
|
|
41
|
+
if (stat.isFile()) {
|
|
42
|
+
return { kind: 'file', data: readFileSync(full).toString('base64'), mode };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// 不存在或不可读均按 missing;工具若最终也不可读,不会产生伪变化。
|
|
47
|
+
}
|
|
48
|
+
return { kind: 'missing' };
|
|
49
|
+
}
|
|
50
|
+
function sameState(a, b) {
|
|
51
|
+
return a.kind === b.kind && a.data === b.data && a.mode === b.mode;
|
|
52
|
+
}
|
|
53
|
+
function stateFromSnapshot(snapshot) {
|
|
54
|
+
if (snapshot.kind) {
|
|
55
|
+
return { kind: snapshot.kind, data: snapshot.before ?? undefined, mode: snapshot.mode };
|
|
56
|
+
}
|
|
57
|
+
// v1 向后兼容:before 是 UTF-8 文本,null 表示原文件不存在。
|
|
58
|
+
if (snapshot.before === null)
|
|
59
|
+
return { kind: 'missing' };
|
|
60
|
+
return {
|
|
61
|
+
kind: 'file',
|
|
62
|
+
data: Buffer.from(snapshot.before, 'utf8').toString('base64'),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function snapshotFromState(rel, state, sequence, op, createdParents = []) {
|
|
66
|
+
return {
|
|
67
|
+
turnId: currentTurnId,
|
|
68
|
+
path: rel,
|
|
69
|
+
before: state.data ?? null,
|
|
70
|
+
kind: state.kind,
|
|
71
|
+
encoding: state.kind === 'file' ? 'base64' : undefined,
|
|
72
|
+
mode: state.mode,
|
|
73
|
+
sequence,
|
|
74
|
+
ops: [op],
|
|
75
|
+
createdParents: createdParents.length > 0 ? createdParents : undefined,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** 同轮同路径只保留最早的 before;后续实际改动仅合并工具名。 */
|
|
79
|
+
function addSnapshot(next) {
|
|
80
|
+
if (next.turnId <= 0)
|
|
81
|
+
return;
|
|
82
|
+
const existingIndex = snapshots.findIndex((item) => item.turnId === next.turnId && item.path === next.path);
|
|
83
|
+
if (existingIndex < 0) {
|
|
84
|
+
snapshots.push(next);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const existing = snapshots[existingIndex];
|
|
88
|
+
const existingSequence = existing.sequence ?? Number.MAX_SAFE_INTEGER;
|
|
89
|
+
const nextSequence = next.sequence ?? Number.MAX_SAFE_INTEGER;
|
|
90
|
+
const ops = new Set([...(existing.ops ?? []), ...(next.ops ?? [])]);
|
|
91
|
+
if (nextSequence < existingSequence) {
|
|
92
|
+
snapshots[existingIndex] = { ...next, ops: [...ops] };
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
existing.ops = [...ops];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function missingParents(full) {
|
|
99
|
+
const root = rootDir();
|
|
100
|
+
const result = [];
|
|
101
|
+
let current = path.dirname(full);
|
|
102
|
+
while (current !== root && isInside(root, current)) {
|
|
103
|
+
if (existsSync(current))
|
|
104
|
+
break;
|
|
105
|
+
result.push(toRel(current));
|
|
106
|
+
current = path.dirname(current);
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
/** agent 主轮入口调用;子 agent 共享当前 turnId,不另开轮次。 */
|
|
22
111
|
export function beginTurn(firstLine) {
|
|
23
112
|
turnIdCounter += 1;
|
|
24
113
|
currentTurnId = turnIdCounter;
|
|
25
114
|
turns.push({ turnId: currentTurnId, firstLine });
|
|
26
115
|
}
|
|
27
|
-
/**
|
|
28
|
-
export function
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
before
|
|
116
|
+
/** 单路径工具执行前捕获,不立即记账;失败/no-op 不应出现在 rollback 中。 */
|
|
117
|
+
export function beginPathMutation(p) {
|
|
118
|
+
const full = path.resolve(p);
|
|
119
|
+
return {
|
|
120
|
+
path: toRel(full),
|
|
121
|
+
before: readState(full),
|
|
122
|
+
sequence: ++sequenceCounter,
|
|
123
|
+
createdParents: missingParents(full),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** 单路径工具执行后提交,仅当磁盘状态确实变化时写入事务日志。 */
|
|
127
|
+
export function endPathMutation(capture, op) {
|
|
128
|
+
const full = safeFullPath(capture.path);
|
|
129
|
+
if (!full)
|
|
130
|
+
return;
|
|
131
|
+
const after = readState(full);
|
|
132
|
+
if (!sameState(capture.before, after)) {
|
|
133
|
+
addSnapshot(snapshotFromState(capture.path, capture.before, capture.sequence, op, capture.createdParents));
|
|
33
134
|
}
|
|
34
|
-
|
|
35
|
-
|
|
135
|
+
// write_file 会递归创建父目录;即使最终写文件失败,这些目录也是本轮真实副作用。
|
|
136
|
+
for (const parentRel of capture.createdParents) {
|
|
137
|
+
const parent = safeFullPath(parentRel);
|
|
138
|
+
if (parent && readState(parent).kind !== 'missing') {
|
|
139
|
+
addSnapshot(snapshotFromState(parentRel, { kind: 'missing' }, capture.sequence, op));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function isWorkspaceExcluded(full) {
|
|
144
|
+
const base = path.basename(full).toLowerCase();
|
|
145
|
+
// Git 元数据必须永久排除以保护 index;依赖树/代码索引是可再生运行时状态,
|
|
146
|
+
// 扫描它们既昂贵,也可能把后台 daemon 的写入误判成模型改动。
|
|
147
|
+
if (base === '.git' || base === '.codegraph' || base === 'node_modules')
|
|
148
|
+
return true;
|
|
149
|
+
const sessionDir = path.resolve(config.sessionDir);
|
|
150
|
+
return isInside(sessionDir, full);
|
|
151
|
+
}
|
|
152
|
+
function scanWorkspace() {
|
|
153
|
+
const entries = new Map();
|
|
154
|
+
const walk = (dir) => {
|
|
155
|
+
let children;
|
|
156
|
+
try {
|
|
157
|
+
children = readdirSync(dir, { withFileTypes: true });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
for (const child of children) {
|
|
163
|
+
const full = path.join(dir, child.name);
|
|
164
|
+
if (isWorkspaceExcluded(full))
|
|
165
|
+
continue;
|
|
166
|
+
const state = readState(full);
|
|
167
|
+
if (state.kind === 'missing')
|
|
168
|
+
continue;
|
|
169
|
+
const rel = toRel(full);
|
|
170
|
+
entries.set(rel, state);
|
|
171
|
+
if (state.kind === 'directory')
|
|
172
|
+
walk(full);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
walk(rootDir());
|
|
176
|
+
return entries;
|
|
177
|
+
}
|
|
178
|
+
/** run_command/MCP 前调用。不跟随 symlink,并排除 .git、会话快照、依赖树与代码索引。 */
|
|
179
|
+
export function beginWorkspaceMutation() {
|
|
180
|
+
return { sequence: ++sequenceCounter, entries: scanWorkspace() };
|
|
181
|
+
}
|
|
182
|
+
/** 不透明工具执行后比较整个工作区,把实际变化压入当前轮事务日志。 */
|
|
183
|
+
export function endWorkspaceMutation(capture, op) {
|
|
184
|
+
const after = scanWorkspace();
|
|
185
|
+
const paths = new Set([...capture.entries.keys(), ...after.keys()]);
|
|
186
|
+
for (const rel of paths) {
|
|
187
|
+
const beforeState = capture.entries.get(rel) ?? { kind: 'missing' };
|
|
188
|
+
const afterState = after.get(rel) ?? { kind: 'missing' };
|
|
189
|
+
if (sameState(beforeState, afterState))
|
|
190
|
+
continue;
|
|
191
|
+
addSnapshot(snapshotFromState(rel, beforeState, capture.sequence, op));
|
|
36
192
|
}
|
|
37
|
-
snapshots.push({ turnId: currentTurnId, path: rel, before });
|
|
38
193
|
}
|
|
39
|
-
/** 列出当前可回滚的轮次(1-based 序号由调用方显示)。 */
|
|
40
194
|
export function listTurns() {
|
|
41
195
|
return turns.slice();
|
|
42
196
|
}
|
|
43
|
-
/** history 里第 (n+1) 条 user 消息的下标(= 截断点);无则 history.length。 */
|
|
44
197
|
function findCutoffIndex(n, history) {
|
|
45
198
|
let seen = 0;
|
|
46
199
|
for (let i = 0; i < history.length; i++) {
|
|
@@ -53,156 +206,195 @@ function findCutoffIndex(n, history) {
|
|
|
53
206
|
return history.length;
|
|
54
207
|
}
|
|
55
208
|
/**
|
|
56
|
-
* 规划回滚到第 n
|
|
57
|
-
*
|
|
209
|
+
* 规划回滚到第 n 轮。changes 只来自已确认发生磁盘差异的事务快照,
|
|
210
|
+
* 因此失败/no-op 工具不会被误报;子 agent、run_command、MCP 变化同样可见。
|
|
58
211
|
*/
|
|
59
212
|
export function planRollback(n, history) {
|
|
60
213
|
const cutoffTurnId = turns[n - 1]?.turnId ?? 0;
|
|
61
214
|
const cutoffIndex = findCutoffIndex(n, history);
|
|
62
215
|
const order = [];
|
|
63
216
|
const map = new Map();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (!
|
|
217
|
+
const ensure = (rel) => {
|
|
218
|
+
let change = map.get(rel);
|
|
219
|
+
if (!change) {
|
|
220
|
+
change = { path: rel, ops: [], snapshotAvailable: true };
|
|
221
|
+
map.set(rel, change);
|
|
222
|
+
order.push(rel);
|
|
223
|
+
}
|
|
224
|
+
return change;
|
|
225
|
+
};
|
|
226
|
+
for (const snapshot of snapshots) {
|
|
227
|
+
if (snapshot.turnId <= cutoffTurnId)
|
|
67
228
|
continue;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (!
|
|
71
|
-
|
|
72
|
-
const argRaw = tc?.function?.arguments ?? '';
|
|
73
|
-
let p = '';
|
|
74
|
-
try {
|
|
75
|
-
p = String(JSON.parse(argRaw).path ?? '');
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
p = '';
|
|
79
|
-
}
|
|
80
|
-
if (!p)
|
|
81
|
-
continue;
|
|
82
|
-
const rel = toRel(p);
|
|
83
|
-
let fc = map.get(rel);
|
|
84
|
-
if (!fc) {
|
|
85
|
-
fc = { path: rel, ops: [], snapshotAvailable: false };
|
|
86
|
-
map.set(rel, fc);
|
|
87
|
-
order.push(rel);
|
|
88
|
-
}
|
|
89
|
-
fc.ops.push(name);
|
|
229
|
+
const change = ensure(snapshot.path);
|
|
230
|
+
for (const op of snapshot.ops ?? ['file_change']) {
|
|
231
|
+
if (!change.ops.includes(op))
|
|
232
|
+
change.ops.push(op);
|
|
90
233
|
}
|
|
91
234
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
235
|
+
return {
|
|
236
|
+
n,
|
|
237
|
+
cutoffIndex,
|
|
238
|
+
cutoffTurnId,
|
|
239
|
+
changes: order.map((rel) => map.get(rel)),
|
|
240
|
+
};
|
|
98
241
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
242
|
+
function depth(rel) {
|
|
243
|
+
return rel.split(/[\\/]+/).length;
|
|
244
|
+
}
|
|
245
|
+
function restoreSnapshot(snapshot) {
|
|
246
|
+
const full = safeFullPath(snapshot.path);
|
|
247
|
+
if (!full)
|
|
248
|
+
return false;
|
|
249
|
+
const state = stateFromSnapshot(snapshot);
|
|
250
|
+
try {
|
|
251
|
+
if (state.kind === 'missing') {
|
|
252
|
+
rmSync(full, { recursive: true, force: true });
|
|
253
|
+
for (const parentRel of snapshot.createdParents ?? []) {
|
|
254
|
+
const parent = safeFullPath(parentRel);
|
|
255
|
+
if (!parent)
|
|
256
|
+
continue;
|
|
257
|
+
try {
|
|
258
|
+
rmdirSync(parent);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// 仅删除本轮创建且当前为空的父目录;非空/已不存在均保持。
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return true;
|
|
117
265
|
}
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
266
|
+
if (state.kind === 'directory') {
|
|
267
|
+
const current = readState(full);
|
|
268
|
+
if (current.kind !== 'missing' && current.kind !== 'directory') {
|
|
269
|
+
rmSync(full, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
mkdirSync(full, { recursive: true });
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
mkdirSync(path.dirname(full), { recursive: true });
|
|
275
|
+
rmSync(full, { recursive: true, force: true });
|
|
276
|
+
if (state.kind === 'file') {
|
|
277
|
+
writeFileSync(full, Buffer.from(state.data ?? '', 'base64'));
|
|
124
278
|
}
|
|
125
279
|
else {
|
|
126
|
-
|
|
280
|
+
symlinkSync(state.data ?? '', full);
|
|
127
281
|
}
|
|
128
|
-
revertedFiles.push(rel);
|
|
129
282
|
}
|
|
130
|
-
|
|
131
|
-
|
|
283
|
+
if (state.mode !== undefined && state.kind !== 'symlink')
|
|
284
|
+
chmodSync(full, state.mode);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** 执行回滚:恢复工作树快照并截断对话/事务;从不调用 Git。 */
|
|
292
|
+
export function applyRollback(plan, history, revertPaths) {
|
|
293
|
+
const deletedMsgs = history.length - plan.cutoffIndex;
|
|
294
|
+
history.length = plan.cutoffIndex;
|
|
295
|
+
const picks = new Map();
|
|
296
|
+
for (const snapshot of snapshots) {
|
|
297
|
+
if (snapshot.turnId <= plan.cutoffTurnId || !revertPaths.has(snapshot.path))
|
|
298
|
+
continue;
|
|
299
|
+
const existing = picks.get(snapshot.path);
|
|
300
|
+
if (!existing ||
|
|
301
|
+
snapshot.turnId < existing.turnId ||
|
|
302
|
+
(snapshot.turnId === existing.turnId &&
|
|
303
|
+
(snapshot.sequence ?? Number.MAX_SAFE_INTEGER) <
|
|
304
|
+
(existing.sequence ?? Number.MAX_SAFE_INTEGER))) {
|
|
305
|
+
picks.set(snapshot.path, snapshot);
|
|
132
306
|
}
|
|
133
307
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
308
|
+
const selected = [...picks.values()];
|
|
309
|
+
// 先深到浅删除本轮新建项,再浅到深恢复原目录/文件。
|
|
310
|
+
const removals = selected
|
|
311
|
+
.filter((item) => stateFromSnapshot(item).kind === 'missing')
|
|
312
|
+
.sort((a, b) => depth(b.path) - depth(a.path));
|
|
313
|
+
const restores = selected
|
|
314
|
+
.filter((item) => stateFromSnapshot(item).kind !== 'missing')
|
|
315
|
+
.sort((a, b) => depth(a.path) - depth(b.path));
|
|
316
|
+
const revertedFiles = [];
|
|
317
|
+
for (const snapshot of [...removals, ...restores]) {
|
|
318
|
+
if (restoreSnapshot(snapshot))
|
|
319
|
+
revertedFiles.push(snapshot.path);
|
|
320
|
+
}
|
|
321
|
+
turns = turns.filter((turn) => turn.turnId <= plan.cutoffTurnId);
|
|
322
|
+
snapshots = snapshots.filter((snapshot) => snapshot.turnId <= plan.cutoffTurnId);
|
|
323
|
+
currentTurnId = turns.at(-1)?.turnId ?? 0;
|
|
137
324
|
return { deletedMsgs, revertedFiles };
|
|
138
325
|
}
|
|
139
|
-
/** compact 摘要成功后调:按存活轮次数裁剪(M = 新 history 里 user 消息数)。 */
|
|
140
326
|
export function pruneAfterCompaction(history) {
|
|
141
|
-
const
|
|
142
|
-
turns =
|
|
143
|
-
const alive = new Set(turns.map((
|
|
144
|
-
snapshots = snapshots.filter((
|
|
327
|
+
const count = history.filter((message) => message.role === 'user').length;
|
|
328
|
+
turns = count >= turns.length ? turns : turns.slice(-count);
|
|
329
|
+
const alive = new Set(turns.map((turn) => turn.turnId));
|
|
330
|
+
snapshots = snapshots.filter((snapshot) => alive.has(snapshot.turnId));
|
|
145
331
|
}
|
|
146
|
-
/** /clear 调:清空全部状态。 */
|
|
147
332
|
export function resetState() {
|
|
148
333
|
turns = [];
|
|
149
334
|
snapshots = [];
|
|
150
335
|
turnIdCounter = 0;
|
|
151
336
|
currentTurnId = 0;
|
|
337
|
+
sequenceCounter = 0;
|
|
152
338
|
}
|
|
153
|
-
/**
|
|
154
|
-
* 无 snapshots 文件时(/resume 旧会话)从 history 重建 turns(扫 user 消息,1..M,
|
|
155
|
-
* 无快照 → 那些轮次的文件改动不可撤销)。turnIdCounter = M,后续新轮次从 M+1 续。
|
|
156
|
-
*/
|
|
157
339
|
export function rebuildFromHistory(history) {
|
|
158
|
-
const
|
|
159
|
-
for (
|
|
160
|
-
if (
|
|
340
|
+
const rebuilt = [];
|
|
341
|
+
for (const message of history) {
|
|
342
|
+
if (message.role !== 'user')
|
|
161
343
|
continue;
|
|
162
|
-
const first = toText(
|
|
163
|
-
|
|
344
|
+
const first = toText(message.content).split('\n')[0] ?? '';
|
|
345
|
+
rebuilt.push({
|
|
346
|
+
turnId: rebuilt.length + 1,
|
|
347
|
+
firstLine: truncateDisplay(first, 40),
|
|
348
|
+
});
|
|
164
349
|
}
|
|
165
|
-
turns =
|
|
350
|
+
turns = rebuilt;
|
|
166
351
|
snapshots = [];
|
|
167
|
-
turnIdCounter =
|
|
352
|
+
turnIdCounter = rebuilt.length;
|
|
168
353
|
currentTurnId = 0;
|
|
354
|
+
sequenceCounter = 0;
|
|
169
355
|
}
|
|
170
356
|
function snapshotsPath(id) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return newPath;
|
|
357
|
+
const current = path.join(config.sessionDir, id, 'snapshots.json');
|
|
358
|
+
if (existsSync(current))
|
|
359
|
+
return current;
|
|
175
360
|
return path.join(config.sessionDir, `${id}.snapshots.json`);
|
|
176
361
|
}
|
|
177
|
-
/** 随 saveSession 调:把 turns + snapshots 落盘(turns 为空则跳过,不写空文件)。 */
|
|
178
362
|
export function persistSnapshots(id) {
|
|
179
|
-
|
|
180
|
-
|
|
363
|
+
const dir = path.join(config.sessionDir, id);
|
|
364
|
+
const current = path.join(dir, 'snapshots.json');
|
|
365
|
+
const legacy = path.join(config.sessionDir, `${id}.snapshots.json`);
|
|
181
366
|
try {
|
|
182
|
-
|
|
367
|
+
if (turns.length === 0) {
|
|
368
|
+
// 全量回滚后不能保留旧快照,否则 /resume 可能重新加载已删除轮次。
|
|
369
|
+
if (existsSync(current))
|
|
370
|
+
unlinkSync(current);
|
|
371
|
+
if (existsSync(legacy))
|
|
372
|
+
unlinkSync(legacy);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
183
375
|
mkdirSync(dir, { recursive: true });
|
|
184
|
-
writeFileSync(
|
|
376
|
+
writeFileSync(current, JSON.stringify({ version: 2, turns, snapshots }), 'utf8');
|
|
377
|
+
// 迁移后的旧式扁平快照不再需要,避免磁盘上残留已回滚内容。
|
|
378
|
+
if (existsSync(legacy))
|
|
379
|
+
unlinkSync(legacy);
|
|
185
380
|
}
|
|
186
381
|
catch {
|
|
187
|
-
//
|
|
382
|
+
// 落盘失败不阻断会话;只失去跨重启回滚能力。
|
|
188
383
|
}
|
|
189
384
|
}
|
|
190
|
-
/**
|
|
191
|
-
* /resume / --resume 加载会话后调:读回 turns + snapshots。成功返 true(状态已覆盖);
|
|
192
|
-
* 失败 / 无文件返 false,调用方应改调 rebuildFromHistory(history) 兜底。
|
|
193
|
-
*/
|
|
194
385
|
export function loadSnapshots(id) {
|
|
195
|
-
const
|
|
196
|
-
if (!existsSync(
|
|
386
|
+
const snapshotFile = snapshotsPath(id);
|
|
387
|
+
if (!existsSync(snapshotFile))
|
|
197
388
|
return false;
|
|
198
389
|
try {
|
|
199
|
-
const
|
|
200
|
-
if (!
|
|
390
|
+
const record = JSON.parse(readFileSync(snapshotFile, 'utf8'));
|
|
391
|
+
if (!record || !Array.isArray(record.turns) || !Array.isArray(record.snapshots)) {
|
|
201
392
|
return false;
|
|
202
393
|
}
|
|
203
|
-
turns =
|
|
204
|
-
snapshots =
|
|
205
|
-
turnIdCounter = turns.reduce((
|
|
394
|
+
turns = record.turns;
|
|
395
|
+
snapshots = record.snapshots;
|
|
396
|
+
turnIdCounter = turns.reduce((max, turn) => Math.max(max, turn.turnId), 0);
|
|
397
|
+
sequenceCounter = snapshots.reduce((max, snapshot) => Math.max(max, snapshot.sequence ?? 0), 0);
|
|
206
398
|
currentTurnId = 0;
|
|
207
399
|
return true;
|
|
208
400
|
}
|
package/dist/session/persist.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { config } from '../config/index.js';
|
|
4
4
|
import { truncateDisplay } from '../ui/render.js';
|
|
@@ -44,7 +44,7 @@ function firstUserOf(history) {
|
|
|
44
44
|
function sessionPath(id) {
|
|
45
45
|
return path.join(config.sessionDir, id, 'session.json');
|
|
46
46
|
}
|
|
47
|
-
/**
|
|
47
|
+
/** 保存会话到磁盘。全新且只有 system 的会话不创建文件;已有会话即使回滚为空也必须覆盖旧记录。 */
|
|
48
48
|
export function saveSession(history, id) {
|
|
49
49
|
const meta = {
|
|
50
50
|
id,
|
|
@@ -52,12 +52,18 @@ export function saveSession(history, id) {
|
|
|
52
52
|
model: config.model,
|
|
53
53
|
firstUser: history.length > 1 ? firstUserOf(history) : '',
|
|
54
54
|
};
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
const currentPath = sessionPath(id);
|
|
56
|
+
const legacyPath = path.join(config.sessionDir, `${id}.json`);
|
|
57
|
+
if (history.length <= 1 && !existsSync(currentPath) && !existsSync(legacyPath)) {
|
|
58
|
+
return meta;
|
|
59
|
+
}
|
|
57
60
|
const dir = path.join(config.sessionDir, id);
|
|
58
61
|
mkdirSync(dir, { recursive: true });
|
|
59
62
|
const record = { ...meta, history };
|
|
60
|
-
writeFileSync(
|
|
63
|
+
writeFileSync(currentPath, JSON.stringify(record), 'utf8');
|
|
64
|
+
// 一旦写入新式目录,删除旧式扁平副本,避免已回滚消息仍残留在磁盘。
|
|
65
|
+
if (existsSync(legacyPath))
|
|
66
|
+
unlinkSync(legacyPath);
|
|
61
67
|
return meta;
|
|
62
68
|
}
|
|
63
69
|
/** 加载会话;不存在 / 损坏返 null(不抛)。优先新式目录,回退旧式文件。 */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { promptIntervention } from '../../ui/intervention.js';
|
|
2
2
|
import { sendState } from '../../pet/bridge.js';
|
|
3
|
+
import { t } from '../../i18n/index.js';
|
|
3
4
|
/** 把单个选项元素安全地转成 {label, detail}。LLM 有时传对象而不是字符串,这里提取可读字段。 */
|
|
4
5
|
function optionToChoice(o) {
|
|
5
6
|
if (o === null || o === undefined)
|
|
@@ -148,11 +149,11 @@ export const askHumanTool = {
|
|
|
148
149
|
detail: context,
|
|
149
150
|
});
|
|
150
151
|
if (result.action === 'cancelled') {
|
|
151
|
-
return '
|
|
152
|
+
return t('askHuman.cancelled');
|
|
152
153
|
}
|
|
153
154
|
if (result.action === 'submitted') {
|
|
154
|
-
return
|
|
155
|
+
return t('askHuman.submitted', { value: result.value ?? '' });
|
|
155
156
|
}
|
|
156
|
-
return
|
|
157
|
+
return t('askHuman.selected', { value: result.value ?? '' });
|
|
157
158
|
},
|
|
158
159
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
2
|
import { MAX_OUTPUT } from '../constants.js';
|
|
3
3
|
import { getSandboxRoot, filterEnv, isCommandDenied } from '../../sandbox/index.js';
|
|
4
|
+
import { t } from '../../i18n/index.js';
|
|
4
5
|
const OUTPUT_HEAD_LIMIT = Math.floor(MAX_OUTPUT * 0.4);
|
|
5
6
|
const OUTPUT_TAIL_LIMIT = MAX_OUTPUT - OUTPUT_HEAD_LIMIT;
|
|
6
7
|
/** 有界采集:短输出逐字保留;超限后保留 head+tail,避免构建/测试错误只出现在尾部时被丢弃。 */
|
|
@@ -21,7 +22,7 @@ class BoundedCommandOutput {
|
|
|
21
22
|
if (this.total <= MAX_OUTPUT)
|
|
22
23
|
return this.head + this.tail;
|
|
23
24
|
const removed = this.total - MAX_OUTPUT;
|
|
24
|
-
return `${this.head}\n
|
|
25
|
+
return `${this.head}\n${t('command.outputTruncated', { count: removed })}\n${this.tail}`;
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
// ---------- run_command ----------
|
|
@@ -73,7 +74,7 @@ export const runCommandTool = {
|
|
|
73
74
|
// abort(用户 Ctrl+C,经 executeTool ctx.signal 透传)→ 杀子进程树 + 返[已中断]
|
|
74
75
|
const onAbort = () => {
|
|
75
76
|
killTree();
|
|
76
|
-
finish(
|
|
77
|
+
finish(`${t('command.interrupted')}\n${output.render().trim()}`);
|
|
77
78
|
};
|
|
78
79
|
const finish = (s) => {
|
|
79
80
|
if (finished)
|
|
@@ -88,14 +89,14 @@ export const runCommandTool = {
|
|
|
88
89
|
};
|
|
89
90
|
child.stdout.on('data', onChunk);
|
|
90
91
|
child.stderr.on('data', onChunk);
|
|
91
|
-
child.on('error', (e) => finish(
|
|
92
|
+
child.on('error', (e) => finish(t('command.executionFailed', { message: e.message })));
|
|
92
93
|
child.on('close', (code) => {
|
|
93
94
|
const result = output.render().trim();
|
|
94
|
-
finish(
|
|
95
|
+
finish(`${t('command.exitCode', { code: code ?? 'null' })}\n${result || t('toolSummary.noOutput')}`);
|
|
95
96
|
});
|
|
96
97
|
timer = setTimeout(() => {
|
|
97
98
|
killTree();
|
|
98
|
-
finish(
|
|
99
|
+
finish(`${t('command.timedOut')}\n${output.render().trim()}`);
|
|
99
100
|
}, timeout);
|
|
100
101
|
// 外部 abort signal:已 aborted 即时杀(防御;agent 循环顶检查通常会先拦),否则挂监听
|
|
101
102
|
if (ctx?.signal) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawnAgent } from '../../agent/spawn.js';
|
|
2
2
|
import { MAX_OUTPUT } from '../constants.js';
|
|
3
|
+
import { t } from '../../i18n/index.js';
|
|
3
4
|
// ---------- task ----------
|
|
4
5
|
// 派生子 agent 执行独立子任务。子 agent 有独立 history(不污染主对话),
|
|
5
6
|
// 可受限工具子集 + 低步数上限,最终摘要回灌主 history 供主 agent 继续。
|
|
@@ -36,7 +37,7 @@ export const taskTool = {
|
|
|
36
37
|
async execute(args, ctx) {
|
|
37
38
|
const prompt = String(args.prompt ?? '');
|
|
38
39
|
if (!prompt)
|
|
39
|
-
return '
|
|
40
|
+
return t('task.missingPrompt');
|
|
40
41
|
const tools = Array.isArray(args.tools)
|
|
41
42
|
? args.tools.map((t) => String(t))
|
|
42
43
|
: undefined;
|
|
@@ -46,16 +47,16 @@ export const taskTool = {
|
|
|
46
47
|
// 透传主 agent 的 abort signal:主 Ctrl+C 树杀子 agent(chat abort + 工具 abort)。
|
|
47
48
|
const result = await spawnAgent({ prompt, tools, maxSteps, signal: ctx?.signal });
|
|
48
49
|
if (!result.completed) {
|
|
49
|
-
return '
|
|
50
|
+
return t('task.interrupted');
|
|
50
51
|
}
|
|
51
52
|
if (!result.summary) {
|
|
52
|
-
return '
|
|
53
|
+
return t('task.noSummary');
|
|
53
54
|
}
|
|
54
55
|
// 摘要可能很长,截到 MAX_OUTPUT 保主 history 不爆。
|
|
55
56
|
const summary = result.summary;
|
|
56
57
|
if (summary.length > MAX_OUTPUT) {
|
|
57
58
|
return (summary.slice(0, MAX_OUTPUT) +
|
|
58
|
-
`\n\n
|
|
59
|
+
`\n\n${t('task.summaryTruncated', { count: summary.length - MAX_OUTPUT })}`);
|
|
59
60
|
}
|
|
60
61
|
return summary;
|
|
61
62
|
},
|