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.
@@ -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 MUTATION_TOOLS = new Set(['write_file', 'edit_file']);
11
- /** 规整成 cwd 相对路径(快照存相对,跨 /resume 同项目可识别;resolve 已归一 ./ 和 ..)。 */
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(process.cwd(), path.resolve(p));
15
- return rel === '' ? p : 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
- /** agent:runAgent 入口调——开新轮次。firstLine 已由调用方截断到 40。 */
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
- /** tools/registry:write_file/edit_file 执行前调——记 before 快照(在 tool.execute 之前读)。 */
28
- export function recordMutation(p) {
29
- const rel = toRel(p);
30
- let before;
31
- try {
32
- before = readFileSync(path.resolve(p), 'utf8');
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
- catch {
35
- before = null; // 文件不存在(新建)
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 轮(1-based):算截断点 + 被删轮次里涉及的文件改动(按 path 去重)。
57
- * 调用方据 changes 逐 path 问保留 / 撤销;snapshotAvailable=false 的项无法撤销。
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
- for (let i = cutoffIndex; i < history.length; i++) {
65
- const tcs = history[i].tool_calls;
66
- if (!Array.isArray(tcs))
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
- for (const tc of tcs) {
69
- const name = tc?.function?.name ?? '';
70
- if (!MUTATION_TOOLS.has(name))
71
- continue;
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
- const changes = order.map((rel) => {
93
- const fc = map.get(rel);
94
- fc.snapshotAvailable = snapshots.some((s) => s.turnId > cutoffTurnId && s.path === rel);
95
- return fc;
96
- });
97
- return { n, cutoffIndex, cutoffTurnId, changes };
235
+ return {
236
+ n,
237
+ cutoffIndex,
238
+ cutoffTurnId,
239
+ changes: order.map((rel) => map.get(rel)),
240
+ };
98
241
  }
99
- /**
100
- * 执行回滚:原地截断 history 到 cutoffIndex + 按选择恢复文件 + 裁剪 turns/snapshots。
101
- * revertPaths 为选「撤销」的相对路径集合。返回删除消息数 + 撤销文件列表。
102
- */
103
- export function applyRollback(plan, history, revertPaths) {
104
- const deletedMsgs = history.length - plan.cutoffIndex;
105
- history.length = plan.cutoffIndex; // 原地截断(保 history[0] system + 可能的 index-1 摘要)
106
- const revertedFiles = [];
107
- for (const rel of revertPaths) {
108
- // turnId > cutoff 的最小 turnId 快照 = 所选轮末状态
109
- let pick = null;
110
- for (const s of snapshots) {
111
- if (s.path !== rel)
112
- continue;
113
- if (s.turnId <= plan.cutoffTurnId)
114
- continue;
115
- if (!pick || s.turnId < pick.turnId)
116
- pick = s;
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 (!pick)
119
- continue; // 无快照(不应发生:repl 已据 snapshotAvailable 过滤)
120
- const full = path.resolve(rel);
121
- try {
122
- if (pick.before === null) {
123
- unlinkSync(full);
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
- writeFileSync(full, pick.before, 'utf8');
280
+ symlinkSync(state.data ?? '', full);
127
281
  }
128
- revertedFiles.push(rel);
129
282
  }
130
- catch {
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
- // 裁剪:保 turnId ≤ cutoff(删掉被回滚掉的轮次及其快照)
135
- turns = turns.filter((t) => t.turnId <= plan.cutoffTurnId);
136
- snapshots = snapshots.filter((s) => s.turnId <= plan.cutoffTurnId);
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 m = history.filter((msg) => msg.role === 'user').length;
142
- turns = m >= turns.length ? turns : turns.slice(-m);
143
- const alive = new Set(turns.map((t) => t.turnId));
144
- snapshots = snapshots.filter((s) => alive.has(s.turnId));
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 out = [];
159
- for (let i = 0; i < history.length; i++) {
160
- if (history[i].role !== 'user')
340
+ const rebuilt = [];
341
+ for (const message of history) {
342
+ if (message.role !== 'user')
161
343
  continue;
162
- const first = toText(history[i].content).split('\n')[0] ?? '';
163
- out.push({ turnId: out.length + 1, firstLine: truncateDisplay(first, 40) });
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 = out;
350
+ turns = rebuilt;
166
351
  snapshots = [];
167
- turnIdCounter = out.length;
352
+ turnIdCounter = rebuilt.length;
168
353
  currentTurnId = 0;
354
+ sequenceCounter = 0;
169
355
  }
170
356
  function snapshotsPath(id) {
171
- // 新式目录,回退旧式文件
172
- const newPath = path.join(config.sessionDir, id, 'snapshots.json');
173
- if (existsSync(newPath))
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
- if (turns.length === 0)
180
- return;
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
- const dir = path.join(config.sessionDir, id);
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(path.join(dir, 'snapshots.json'), JSON.stringify({ version: 1, turns, snapshots }), 'utf8');
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 p = snapshotsPath(id);
196
- if (!existsSync(p))
386
+ const snapshotFile = snapshotsPath(id);
387
+ if (!existsSync(snapshotFile))
197
388
  return false;
198
389
  try {
199
- const rec = JSON.parse(readFileSync(p, 'utf8'));
200
- if (!rec || !Array.isArray(rec.turns) || !Array.isArray(rec.snapshots)) {
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 = rec.turns;
204
- snapshots = rec.snapshots;
205
- turnIdCounter = turns.reduce((mx, t) => Math.max(mx, t.turnId), 0);
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
  }
@@ -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
- /** 保存会话到磁盘(history.length<=1 时跳过写盘,只返 meta)。 */
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
- if (history.length <= 1)
56
- return meta; // 仅 system,不落盘
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(sessionPath(id), JSON.stringify(record), 'utf8');
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 `用户回答:${result.value ?? ''}`;
155
+ return t('askHuman.submitted', { value: result.value ?? '' });
155
156
  }
156
- return `用户选择:${result.value ?? ''}`;
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...(输出已截断 ${removed} 字符,保留开头与结尾)...\n${this.tail}`;
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(`[已中断]\n${output.render().trim()}`);
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(`执行失败: ${e.message}`));
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(`[退出码 ${code}]\n${result || '(无输出)'}`);
95
+ finish(`${t('command.exitCode', { code: code ?? 'null' })}\n${result || t('toolSummary.noOutput')}`);
95
96
  });
96
97
  timer = setTimeout(() => {
97
98
  killTree();
98
- finish(`[超时,已终止]\n${output.render().trim()}`);
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 '错误:缺少 prompt(子任务指令)。';
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 '子 agent 被中断,未完成。';
50
+ return t('task.interrupted');
50
51
  }
51
52
  if (!result.summary) {
52
- return '子 agent 完成但未返回文本摘要(可能只调了工具或达到步数上限)。';
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…(子 agent 摘要已截断 ${summary.length - MAX_OUTPUT} 字符)`);
59
+ `\n\n${t('task.summaryTruncated', { count: summary.length - MAX_OUTPUT })}`);
59
60
  }
60
61
  return summary;
61
62
  },