mocode-ai 0.7.2 → 1.0.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.
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { config } from '../config/index.js';
@@ -52,6 +53,11 @@ function readState(full) {
52
53
  function sameState(a, b) {
53
54
  return a.kind === b.kind && a.data === b.data && a.mode === b.mode;
54
55
  }
56
+ function stateFingerprint(state) {
57
+ return createHash('sha256')
58
+ .update(JSON.stringify([state.kind, state.data ?? null, state.mode ?? null]))
59
+ .digest('hex');
60
+ }
55
61
  function stateFromSnapshot(snapshot) {
56
62
  if (snapshot.kind) {
57
63
  return { kind: snapshot.kind, data: snapshot.before ?? undefined, mode: snapshot.mode };
@@ -64,7 +70,7 @@ function stateFromSnapshot(snapshot) {
64
70
  data: Buffer.from(snapshot.before, 'utf8').toString('base64'),
65
71
  };
66
72
  }
67
- function snapshotFromState(rel, state, sequence, op, createdParents = []) {
73
+ function snapshotFromState(rel, state, sequence, op, createdParents = [], after) {
68
74
  return {
69
75
  turnId: currentTurnId,
70
76
  path: rel,
@@ -75,6 +81,7 @@ function snapshotFromState(rel, state, sequence, op, createdParents = []) {
75
81
  sequence,
76
82
  ops: [op],
77
83
  createdParents: createdParents.length > 0 ? createdParents : undefined,
84
+ afterFingerprint: after ? stateFingerprint(after) : undefined,
78
85
  };
79
86
  }
80
87
  /** 同轮同路径只保留最早的 before;后续实际改动仅合并工具名。 */
@@ -90,11 +97,15 @@ function addSnapshot(next) {
90
97
  const existingSequence = existing.sequence ?? Number.MAX_SAFE_INTEGER;
91
98
  const nextSequence = next.sequence ?? Number.MAX_SAFE_INTEGER;
92
99
  const ops = new Set([...(existing.ops ?? []), ...(next.ops ?? [])]);
100
+ const latestAfterFingerprint = nextSequence >= existingSequence
101
+ ? next.afterFingerprint
102
+ : existing.afterFingerprint;
93
103
  if (nextSequence < existingSequence) {
94
- snapshots[existingIndex] = { ...next, ops: [...ops] };
104
+ snapshots[existingIndex] = { ...next, ops: [...ops], afterFingerprint: latestAfterFingerprint };
95
105
  }
96
106
  else {
97
107
  existing.ops = [...ops];
108
+ existing.afterFingerprint = latestAfterFingerprint;
98
109
  }
99
110
  }
100
111
  function missingParents(full) {
@@ -139,14 +150,14 @@ export function endPathMutation(capture, op) {
139
150
  const after = readState(full);
140
151
  if (!sameState(capture.before, after)) {
141
152
  changed = true;
142
- addSnapshot(snapshotFromState(capture.path, capture.before, capture.sequence, op, capture.createdParents));
153
+ addSnapshot(snapshotFromState(capture.path, capture.before, capture.sequence, op, capture.createdParents, after));
143
154
  }
144
155
  // write_file 会递归创建父目录;即使最终写文件失败,这些目录也是本轮真实副作用。
145
156
  for (const parentRel of capture.createdParents) {
146
157
  const parent = safeFullPath(parentRel);
147
158
  if (parent && readState(parent).kind !== 'missing') {
148
159
  changed = true;
149
- addSnapshot(snapshotFromState(parentRel, { kind: 'missing' }, capture.sequence, op));
160
+ addSnapshot(snapshotFromState(parentRel, { kind: 'missing' }, capture.sequence, op, [], readState(parent)));
150
161
  }
151
162
  }
152
163
  if (changed)
@@ -202,7 +213,7 @@ export function endWorkspaceMutation(capture, op) {
202
213
  if (sameState(beforeState, afterState))
203
214
  continue;
204
215
  changed = true;
205
- addSnapshot(snapshotFromState(rel, beforeState, capture.sequence, op));
216
+ addSnapshot(snapshotFromState(rel, beforeState, capture.sequence, op, [], afterState));
206
217
  }
207
218
  if (changed)
208
219
  mutationVersion += 1;
@@ -285,7 +296,11 @@ function restoreSnapshot(snapshot) {
285
296
  const state = stateFromSnapshot(snapshot);
286
297
  try {
287
298
  if (state.kind === 'missing') {
288
- rmSync(full, { recursive: true, force: true });
299
+ const current = readState(full);
300
+ if (current.kind === 'directory')
301
+ rmdirSync(full);
302
+ else
303
+ rmSync(full, { recursive: false, force: true });
289
304
  for (const parentRel of snapshot.createdParents ?? []) {
290
305
  const parent = safeFullPath(parentRel);
291
306
  if (!parent)
@@ -329,9 +344,16 @@ export function applyRollback(plan, history, revertPaths) {
329
344
  const deletedMsgs = history.length - plan.cutoffIndex;
330
345
  history.length = plan.cutoffIndex;
331
346
  const picks = new Map();
347
+ const latest = new Map();
332
348
  for (const snapshot of snapshots) {
333
349
  if (snapshot.turnId <= plan.cutoffTurnId || !revertPaths.has(snapshot.path))
334
350
  continue;
351
+ const latestSnapshot = latest.get(snapshot.path);
352
+ if (!latestSnapshot || snapshot.turnId > latestSnapshot.turnId ||
353
+ (snapshot.turnId === latestSnapshot.turnId &&
354
+ (snapshot.sequence ?? -1) > (latestSnapshot.sequence ?? -1))) {
355
+ latest.set(snapshot.path, snapshot);
356
+ }
335
357
  const existing = picks.get(snapshot.path);
336
358
  if (!existing ||
337
359
  snapshot.turnId < existing.turnId ||
@@ -341,7 +363,18 @@ export function applyRollback(plan, history, revertPaths) {
341
363
  picks.set(snapshot.path, snapshot);
342
364
  }
343
365
  }
344
- const selected = [...picks.values()];
366
+ const selected = [];
367
+ const conflictedFiles = [];
368
+ for (const snapshot of picks.values()) {
369
+ const expected = latest.get(snapshot.path)?.afterFingerprint;
370
+ const full = safeFullPath(snapshot.path);
371
+ if (expected && (!full || stateFingerprint(readState(full)) !== expected)) {
372
+ conflictedFiles.push(snapshot.path);
373
+ }
374
+ else {
375
+ selected.push(snapshot);
376
+ }
377
+ }
345
378
  // 先深到浅删除本轮新建项,再浅到深恢复原目录/文件。
346
379
  const removals = selected
347
380
  .filter((item) => stateFromSnapshot(item).kind === 'missing')
@@ -353,11 +386,13 @@ export function applyRollback(plan, history, revertPaths) {
353
386
  for (const snapshot of [...removals, ...restores]) {
354
387
  if (restoreSnapshot(snapshot))
355
388
  revertedFiles.push(snapshot.path);
389
+ else if (!conflictedFiles.includes(snapshot.path))
390
+ conflictedFiles.push(snapshot.path);
356
391
  }
357
392
  turns = turns.filter((turn) => turn.turnId <= plan.cutoffTurnId);
358
393
  snapshots = snapshots.filter((snapshot) => snapshot.turnId <= plan.cutoffTurnId);
359
394
  currentTurnId = turns.at(-1)?.turnId ?? 0;
360
- return { deletedMsgs, revertedFiles };
395
+ return { deletedMsgs, revertedFiles, conflictedFiles };
361
396
  }
362
397
  export function pruneAfterCompaction(history) {
363
398
  const count = history.filter((message) => message.role === 'user').length;
@@ -1,5 +1,5 @@
1
1
  // 沙箱子系统 barrel。叶子:仅 node:path / node:fs,不反向依赖业务。
2
- export { getSandboxRoot, setSandboxRoot } from './root.js';
2
+ export { getSandboxRoot, setSandboxRoot, withSandboxRoot } from './root.js';
3
3
  export { jailResolve, jailGlobPattern, isInsideRoot } from './jail.js';
4
4
  export { filterEnv, isCommandDenied } from './command.js';
5
5
  export { SANDBOX_EXEMPT_TOOLS, SANDBOX_PATH_TOOLS, enforceSandbox, } from './policy.js';
@@ -8,7 +8,7 @@ import { jailResolve, jailGlobPattern } from './jail.js';
8
8
  * - web_*:跨网络,非文件路径
9
9
  * - ask_human / switch_mode:无文件路径
10
10
  * - codegraph:只读 cwd 下 .codegraph/ 索引(只读、不写盘)
11
- * - task:派生子 agent,继承全局 root(子 agent 同进程天然共享 getSandboxRoot)
11
+ * - sub-agent:派生子 agent,通过 scoped sandbox root 使用隔离 overlay
12
12
  */
13
13
  export const SANDBOX_EXEMPT_TOOLS = new Set([
14
14
  'memory_save', 'memory_update', 'memory_forget', 'memory_search', 'memory_list',
@@ -16,7 +16,7 @@ export const SANDBOX_EXEMPT_TOOLS = new Set([
16
16
  'web_search', 'web_fetch',
17
17
  'ask_human', 'switch_mode',
18
18
  'codegraph',
19
- 'task',
19
+ 'sub-agent',
20
20
  ]);
21
21
  /**
22
22
  * 路径类工具:enforceSandbox 集中把 args.path 重写为牢内绝对路径(默认安全;工具内
@@ -3,12 +3,18 @@
3
3
  // —— 全是「业务 → 叶子」,同 src/agent/mode.ts。
4
4
  //
5
5
  // 设计:sandboxRoot 是纯边界记录(默认 = process.cwd(),不 chdir),避免与 config.sessionDir 等
6
- // 模块加载期计算的值产生错位(若 chdir 会令那些值变陈旧)。子 agent 同进程天然继承全局 root
7
- // (未来 agents/ worktree 隔离时再改为沿 opts 透传,照 signal 同形链路,7 处改动)
6
+ // 模块加载期计算的值产生错位(若 chdir 会令那些值变陈旧)。主 Agent 使用全局 root
7
+ // 并发子 Agent 通过 AsyncLocalStorage 获得互不干扰的 overlay root
8
+ import { AsyncLocalStorage } from 'node:async_hooks';
8
9
  let currentRoot = null;
10
+ const scopedRoots = new AsyncLocalStorage();
9
11
  /** 当前沙箱根(绝对路径)。未初始化返 null,调用方 ?? process.cwd() 兜底(防御)。 */
10
12
  export function getSandboxRoot() {
11
- return currentRoot;
13
+ return scopedRoots.getStore() ?? currentRoot;
14
+ }
15
+ /** Run one asynchronous agent against an isolated sandbox without changing sibling roots. */
16
+ export function withSandboxRoot(root, run) {
17
+ return scopedRoots.run(root, run);
12
18
  }
13
19
  /** 设置沙箱根。repl startRepl 启动时调一次(默认 process.cwd();--sandbox-root / SANDBOX_ROOT 可覆盖)。
14
20
  * 返回之前的值,供未来 save/restore(子 agent worktree 隔离)用。 */
@@ -7,6 +7,7 @@ import * as layout from '../ui/layout.js';
7
7
  import { pruneAfterCompaction } from '../rollback/index.js';
8
8
  import { toText } from '../context/utils.js';
9
9
  import { DEFAULT_BUDGET_POLICY } from '../context/budget.js';
10
+ import { collectArtifactRefs } from '../context/artifacts.js';
10
11
  export function createContextState() {
11
12
  return { lastEstimate: 0, correction: 1, calibrationSamples: 0 };
12
13
  }
@@ -210,7 +211,7 @@ async function defaultSummarize(older, focus) {
210
211
  }
211
212
  const sysMsg = {
212
213
  role: 'system',
213
- content: 'You are a session summarizer. Output only the summary body, max 300 words, preserving: the user\'s core request; files read/written/modified and key changes; key commands run and their result highlights; decisions made; current task progress and next step; open questions. Do not recap every detail.',
214
+ content: 'You are a session summarizer. Output only the summary body, max 300 words, preserving: the user\'s core request; files read/written/modified and key changes; source artifact IDs/hashes; key commands run and their result highlights; decisions made; current task progress and next step; open questions. Do not recap every detail.',
214
215
  };
215
216
  const userMsg = {
216
217
  role: 'user',
@@ -302,10 +303,11 @@ export async function compactHistory(history, opts) {
302
303
  }
303
304
  }
304
305
  }
306
+ const refs = collectArtifactRefs(older);
305
307
  const summaryMsg = {
306
308
  role: 'system',
307
309
  content: older.length > 0
308
- ? `# 会话摘要(force)\n被跳过的早期对话 ${older.length} 条已微压缩(token 数减少)。`
310
+ ? `# 会话摘要(force)\n被跳过的早期对话 ${older.length} 条已微压缩(token 数减少)。${refs.length > 0 ? `\n[artifact refs: ${refs.join(', ')}]` : ''}`
309
311
  : `# 会话摘要(force)\n无内容。`,
310
312
  };
311
313
  const rebuilt = [history[0], summaryMsg, ...keptAfter];
@@ -389,9 +391,13 @@ export async function compactHistory(history, opts) {
389
391
  summary = null; // 摘要失败 → 回退仅微压缩,不崩
390
392
  }
391
393
  if (summary) {
394
+ const artifactRefs = collectArtifactRefs(older);
395
+ const provenance = artifactRefs.length > 0
396
+ ? `\n\n[artifact refs: ${artifactRefs.join(', ')}]`
397
+ : '';
392
398
  const summaryMsg = {
393
399
  role: 'system',
394
- content: `# 会话摘要\n${summary}`,
400
+ content: `# 会话摘要\n${summary}${provenance}`,
395
401
  };
396
402
  // 原地重建:[systemPrompt, summaryMsg, ...kept]
397
403
  const systemMsg = history[0];
@@ -23,11 +23,15 @@ import { config } from '../config/index.js';
23
23
  import { maybeCompact, contextState } from './compact.js';
24
24
  import * as layout from '../ui/layout.js';
25
25
  import { ui } from '../ui/theme.js';
26
+ import { pruneStaleArtifacts, refreshArtifactFreshness } from '../context/artifacts.js';
26
27
  /** 创建 runAgentCore 闭包持有的 scheduler(每次 agent 启动一个新实例)。 */
27
28
  export function createBudgetScheduler(state = contextState) {
28
29
  const obs = {
29
30
  lastRunLog: null,
30
31
  async runStep(history, step, activeTools = chatTools) {
32
+ // Re-check file-backed hashes first, then discard stale/rebuildable facts before budgeting.
33
+ refreshArtifactFreshness(state, history);
34
+ pruneStaleArtifacts(state, history);
31
35
  const report = evaluateBudget(history, config.contextWindowTokens, step, state.correction, activeTools);
32
36
  const actions = scheduleActions(report);
33
37
  let compactHistoryCalled = false;
@@ -1,33 +1,17 @@
1
1
  // session/state.ts - 会话状态跟踪模块
2
2
  // 提供当前活跃会话 ID 的全局访问点,供 config/buildNotepadSection 等读取会话级 notes.md。
3
3
  // 避免 repl/index.ts ↔ config/index.ts 循环依赖。
4
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
5
- import path from 'node:path';
6
4
  let currentSessionId;
7
5
  /** 获取当前活跃会话 ID(供 buildNotepadSection 等使用)。 */
8
6
  export function getCurrentSessionId() {
9
7
  return currentSessionId;
10
8
  }
11
9
  /**
12
- * 设置当前活跃会话 ID,并确保该会话的 notes.md 文件存在(不存在则创建空文件)。
10
+ * 设置当前活跃会话 IDnotes.md 由 agent 按需创建;这里不能预建空文件,
11
+ * 否则 write_file(expected_hash=null) 的首次创建会必然冲突。
13
12
  * 由 repl/index.ts 在会话启动 / /resume 切换时调用。
14
13
  */
15
14
  export function setCurrentSessionId(id, cwd) {
16
15
  currentSessionId = id;
17
- if (id)
18
- ensureSessionNotes(id, cwd);
19
- }
20
- /** 确保 .mocode/sessions/<id>/notes.md 存在,不存在则创建空文件。 */
21
- function ensureSessionNotes(id, cwd) {
22
- const dir = path.join(cwd, '.mocode', 'sessions', id);
23
- const file = path.join(dir, 'notes.md');
24
- if (existsSync(file))
25
- return;
26
- try {
27
- mkdirSync(dir, { recursive: true });
28
- writeFileSync(file, '', 'utf8');
29
- }
30
- catch {
31
- // 创建失败不影响 REPL 主流程
32
- }
16
+ void cwd;
33
17
  }
@@ -0,0 +1,174 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { jailResolve } from '../../sandbox/index.js';
3
+ import { commitChangeSet, contentHash, createChangeSet, summarizeChangeSet, } from '../../changeset/index.js';
4
+ function invalid(message) {
5
+ const error = new Error(message);
6
+ error.name = 'PatchError';
7
+ return error;
8
+ }
9
+ function parsePatch(input) {
10
+ const lines = input.replace(/\r\n/g, '\n').split('\n');
11
+ if (lines[0] !== '*** Begin Patch')
12
+ throw invalid('patch 必须以 *** Begin Patch 开始。');
13
+ const operations = [];
14
+ let i = 1;
15
+ while (i < lines.length && lines[i] !== '*** End Patch') {
16
+ const header = lines[i++];
17
+ let match = /^\*\*\* Add File: (.+)$/.exec(header);
18
+ if (match) {
19
+ const body = [];
20
+ while (i < lines.length && !lines[i].startsWith('*** ')) {
21
+ const line = lines[i++];
22
+ if (!line.startsWith('+'))
23
+ throw invalid(`Add File ${match[1]} 的内容行必须以 + 开头。`);
24
+ body.push(line.slice(1));
25
+ }
26
+ operations.push({ kind: 'add', path: match[1], lines: body });
27
+ continue;
28
+ }
29
+ match = /^\*\*\* Delete File: (.+)$/.exec(header);
30
+ if (match) {
31
+ operations.push({ kind: 'delete', path: match[1] });
32
+ continue;
33
+ }
34
+ match = /^\*\*\* Update File: (.+)$/.exec(header);
35
+ if (!match)
36
+ throw invalid(`未知 patch 指令: ${header}`);
37
+ const hunks = [];
38
+ while (i < lines.length && !lines[i].startsWith('*** ')) {
39
+ if (!lines[i].startsWith('@@'))
40
+ throw invalid(`Update File ${match[1]} 缺少 @@ hunk。`);
41
+ i++;
42
+ const hunk = [];
43
+ while (i < lines.length && !lines[i].startsWith('@@') && !lines[i].startsWith('*** ')) {
44
+ hunk.push(lines[i++]);
45
+ }
46
+ hunks.push(hunk);
47
+ }
48
+ operations.push({ kind: 'update', path: match[1], hunks });
49
+ }
50
+ if (lines[i] !== '*** End Patch')
51
+ throw invalid('patch 缺少 *** End Patch。');
52
+ if (operations.length === 0)
53
+ throw invalid('patch 不包含文件操作。');
54
+ return operations;
55
+ }
56
+ function applyHunks(source, hunks, file) {
57
+ let current = source.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
58
+ for (const hunk of hunks) {
59
+ const before = [];
60
+ const after = [];
61
+ for (const line of hunk) {
62
+ if (line.startsWith(' ')) {
63
+ before.push(line.slice(1));
64
+ after.push(line.slice(1));
65
+ }
66
+ else if (line.startsWith('-')) {
67
+ before.push(line.slice(1));
68
+ }
69
+ else if (line.startsWith('+')) {
70
+ after.push(line.slice(1));
71
+ }
72
+ else if (line === '\') {
73
+ continue;
74
+ }
75
+ else {
76
+ throw invalid(`Update File ${file} 的 hunk 行必须以空格、+ 或 - 开头。`);
77
+ }
78
+ }
79
+ const oldText = before.join('\n');
80
+ const newText = after.join('\n');
81
+ if (!oldText)
82
+ throw invalid(`Update File ${file} 的 hunk 缺少上下文或删除行。`);
83
+ const first = current.indexOf(oldText);
84
+ if (first < 0)
85
+ throw invalid(`Update File ${file} 的 hunk 与当前文件不匹配。`);
86
+ if (current.indexOf(oldText, first + 1) >= 0) {
87
+ throw invalid(`Update File ${file} 的 hunk 上下文不唯一,请增加上下文。`);
88
+ }
89
+ current = current.slice(0, first) + newText + current.slice(first + oldText.length);
90
+ }
91
+ return source.includes('\r\n') ? current.replace(/\n/g, '\r\n') : current;
92
+ }
93
+ async function buildChanges(operations) {
94
+ const changes = [];
95
+ for (const operation of operations) {
96
+ if (operation.kind === 'add') {
97
+ changes.push({
98
+ path: operation.path,
99
+ operation: 'create',
100
+ expectedHash: null,
101
+ replacement: operation.lines.join('\n'),
102
+ });
103
+ continue;
104
+ }
105
+ const raw = await readFile(jailResolve(operation.path));
106
+ const expectedHash = contentHash(raw);
107
+ if (operation.kind === 'delete') {
108
+ changes.push({ path: operation.path, operation: 'delete', expectedHash });
109
+ continue;
110
+ }
111
+ const source = raw.toString('utf8');
112
+ changes.push({
113
+ path: operation.path,
114
+ operation: 'update',
115
+ expectedHash,
116
+ replacement: applyHunks(source, operation.hunks, operation.path),
117
+ });
118
+ }
119
+ return changes;
120
+ }
121
+ function conflictOutcome(result) {
122
+ return {
123
+ status: 'error',
124
+ code: 'CHANGE_CONFLICT',
125
+ retryable: false,
126
+ changedFiles: [],
127
+ staleFiles: result.conflicts.map((item) => item.path),
128
+ output: [
129
+ '错误:apply_patch 检测到内容冲突,磁盘未发生变化。',
130
+ ...result.conflicts.map((item) => `- ${item.path}: expected=${item.expectedHash ?? 'missing'}, actual=${item.actualHash ?? 'missing'} (${item.reason})`),
131
+ ].join('\n'),
132
+ };
133
+ }
134
+ export const applyPatchTool = {
135
+ name: 'apply_patch',
136
+ description: 'Apply a multi-file patch transactionally. Format: *** Begin Patch, then *** Add/Update/Delete File sections, then *** End Patch. All files are dry-run and hash-checked before any file is committed; failure leaves disk unchanged.',
137
+ risk: 'confirm',
138
+ parameters: {
139
+ type: 'object',
140
+ properties: {
141
+ patch: { type: 'string', description: 'The complete *** Begin Patch ... *** End Patch document.' },
142
+ },
143
+ required: ['patch'],
144
+ },
145
+ async execute(args, ctx) {
146
+ try {
147
+ const changes = await buildChanges(parsePatch(String(args.patch)));
148
+ const result = await commitChangeSet(createChangeSet(changes), ctx?.signal);
149
+ if (result.status === 'conflict')
150
+ return conflictOutcome(result);
151
+ if (result.status === 'failed') {
152
+ return { status: 'error', code: 'PATCH_INVALID', retryable: false, changedFiles: [], output: `错误:apply_patch 提交失败,已恢复磁盘: ${result.error}` };
153
+ }
154
+ const summary = summarizeChangeSet(result.changeSet);
155
+ return {
156
+ status: 'success',
157
+ code: 'OK',
158
+ retryable: false,
159
+ changedFiles: result.changedFiles,
160
+ changeSet: summary,
161
+ output: `已事务化应用 ChangeSet ${summary.id}:\n${summary.changes.map((item) => `- ${item.operation} ${item.path} (${item.beforeHash ?? 'missing'} -> ${item.afterHash ?? 'missing'})`).join('\n')}`,
162
+ };
163
+ }
164
+ catch (error) {
165
+ return {
166
+ status: 'error',
167
+ code: 'PATCH_INVALID',
168
+ retryable: false,
169
+ changedFiles: [],
170
+ output: `错误:apply_patch 无效,磁盘未发生变化: ${error instanceof Error ? error.message : String(error)}`,
171
+ };
172
+ }
173
+ },
174
+ };
@@ -1,56 +1,79 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
2
- import { resolve } from 'node:path';
3
- import { verifyWrittenFile } from '../../verification/postconditions.js';
4
- // ---------- edit_file ----------
1
+ import { readFile } from 'node:fs/promises';
2
+ import { jailResolve } from '../../sandbox/index.js';
3
+ import { commitChangeSet, createChangeSet, normalizeContentHash, summarizeChangeSet, } from '../../changeset/index.js';
4
+ function conflict(path, details) {
5
+ return {
6
+ status: 'error',
7
+ code: 'CHANGE_CONFLICT',
8
+ retryable: false,
9
+ changedFiles: [],
10
+ staleFiles: [path],
11
+ output: `CHANGE_CONFLICT: ${path} was not changed. ${details} Do not retry these arguments. Call read_file on this exact path and copy both its latest hash and exact target text before editing again.`,
12
+ };
13
+ }
5
14
  export const editFileTool = {
6
15
  name: 'edit_file',
7
- description: 'Replace a string in a file. old_string must occur exactly once and match exactly (including indentation/newlines). Copy old_string verbatim from a fresh read_file result for this path; do not reconstruct it from memory or summaries. Use write_file for new files.',
16
+ description: 'Replace one exact string in a file transactionally. expected_hash is required and must be copied from a fresh read_file artifact header. If the file changes after that read, the edit is rejected without writing.',
8
17
  risk: 'confirm',
9
18
  parameters: {
10
19
  type: 'object',
11
20
  properties: {
12
21
  path: { type: 'string' },
13
- old_string: { type: 'string', description: 'The original text to be replaced; must match exactly' },
14
- new_string: { type: 'string', description: 'The new text to replace it with' },
22
+ old_string: { type: 'string', description: 'The original text; must occur exactly once.' },
23
+ new_string: { type: 'string', description: 'Replacement text.' },
24
+ expected_hash: { type: 'string', description: 'sha256 hash from the latest read_file artifact header.' },
15
25
  },
16
- required: ['path', 'old_string', 'new_string'],
26
+ required: ['path', 'old_string', 'new_string', 'expected_hash'],
17
27
  },
18
- async execute(args) {
19
- const path = String(args.path);
20
- const oldStr = String(args.old_string);
21
- const newStr = String(args.new_string);
22
- const full = resolve(path);
23
- const data = await readFile(full, 'utf8');
24
- // 行尾归一化:read_file 用 split(/\r?\n/) 输出纯 LF,LLM 据此构造的 old_string/new_string
25
- // 也是 LF;但本工具原样读文件(CRLF 保留),直接精确匹配会在 CRLF 文件上必败(文件 \r\n 对不上
26
- // old_string \n)。故匹配/计数在归一化(LF)文本上做,写回时按文件原始行尾风格还原,
27
- // 不把 CRLF 文件悄悄换成 LF(只含 LF 的纯 LF 文件 norm===data,行为完全不变)
28
- const norm = data.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
29
- const normOld = oldStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
30
- const normNew = newStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
31
- const count = norm.split(normOld).length - 1;
28
+ async execute(args, ctx) {
29
+ const file = String(args.path);
30
+ const oldString = String(args.old_string);
31
+ const newString = String(args.new_string);
32
+ const expectedHash = normalizeContentHash(String(args.expected_hash));
33
+ if (!expectedHash)
34
+ return conflict(file, 'expected_hash 必须是 sha256:<64 hex>。');
35
+ const data = await readFile(jailResolve(file), 'utf8');
36
+ const normalized = data.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
37
+ const oldNormalized = oldString.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
38
+ const newNormalized = newString.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
39
+ const count = normalized.split(oldNormalized).length - 1;
32
40
  if (count === 0) {
33
- return `错误:在 ${path} 中未找到 old_string。不要重试相同参数;请先 read_file 读取目标区域,再从返回内容逐字复制新的 old_string 后重试。`;
41
+ return conflict(file, 'old_string 未找到;请重新 read_file 并复制最新内容。');
34
42
  }
35
43
  if (count > 1) {
36
- return `错误:old_string ${path} 中出现 ${count} 次,不唯一。请加入更多上下文使其唯一。`;
44
+ return conflict(file, `old_string 出现 ${count} 次;请增加上下文使其唯一。`);
37
45
  }
38
- // 用函数形式替换,避免 new_string 里的 $ 被当特殊模式
39
- const updated = norm.replace(normOld, () => normNew);
40
- // 检测原始行尾风格,写回时还原(存在 \r\n 即视为 CRLF 文件;纯 LF 文件保持 LF)
41
- const out = data.includes('\r\n') ? updated.replace(/\n/g, '\r\n') : updated;
42
- await writeFile(full, out, 'utf8');
43
- const postcondition = await verifyWrittenFile(full, out);
44
- if (postcondition.status === 'failed') {
46
+ const updated = normalized.replace(oldNormalized, () => newNormalized);
47
+ const replacement = data.includes('\r\n') ? updated.replace(/\n/g, '\r\n') : updated;
48
+ const result = await commitChangeSet(createChangeSet([{
49
+ path: file,
50
+ operation: 'update',
51
+ expectedHash,
52
+ replacement,
53
+ }]), ctx?.signal);
54
+ if (result.status === 'conflict') {
55
+ const item = result.conflicts[0];
56
+ return conflict(file, `expected=${item?.expectedHash ?? 'missing'}, actual=${item?.actualHash ?? 'missing'}。请重新读取后再编辑。`);
57
+ }
58
+ if (result.status === 'failed') {
45
59
  return {
46
60
  status: 'error',
47
- code: 'POSTCONDITION_FAILED',
48
- retryable: true,
49
- output: postcondition.diagnostics
50
- .map((item) => `[${item.code ?? 'V0_FAILED'}] ${item.file ?? path}: ${item.message}`)
51
- .join('\n'),
61
+ code: 'EXECUTION_ERROR',
62
+ retryable: false,
63
+ changedFiles: [],
64
+ output: `错误:ChangeSet 提交失败并已执行恢复: ${result.error}`,
52
65
  };
53
66
  }
54
- return `已在 ${path} 中完成 1 处替换 (sha256=${postcondition.actualHash})。`;
67
+ const summary = summarizeChangeSet(result.changeSet);
68
+ return {
69
+ status: 'success',
70
+ code: 'OK',
71
+ retryable: false,
72
+ changedFiles: result.changedFiles,
73
+ changeSet: summary,
74
+ output: result.changedFiles.length === 0
75
+ ? `文件 ${file} 内容未变化 (ChangeSet ${summary.id})。`
76
+ : `已事务化编辑 ${file} (ChangeSet ${summary.id}, sha256=${summary.changes[0]?.afterHash})。`,
77
+ };
55
78
  },
56
79
  };
@@ -16,7 +16,7 @@ import { memorySearchTool } from './memory-search.js';
16
16
  import { memoryListTool } from './memory-list.js';
17
17
  import { memoryUpdateTool } from './memory-update.js';
18
18
  import { memoryForgetTool } from './memory-forget.js';
19
- import { taskTool } from './task.js';
19
+ import { subAgentTool } from './task.js';
20
20
  import { projectSkillUpdateTool } from './project-skill-update.js';
21
21
  /**
22
22
  * 所有内置工具,按注册顺序排列。
@@ -51,8 +51,8 @@ const workspaceResource = () => ['workspace'];
51
51
  const memoryResource = () => ['memory-store'];
52
52
  const CAPABILITIES = {
53
53
  read_file: { effect: 'read', concurrency: 'parallel', retry: 'safe', resources: pathResource },
54
- write_file: { effect: 'write', concurrency: 'resource-locked', retry: 'never', resources: pathResource },
55
- edit_file: { effect: 'write', concurrency: 'resource-locked', retry: 'never', resources: pathResource },
54
+ write_file: { effect: 'write', concurrency: 'resource-locked', retry: 'never', resources: pathResource, delegatesResourceLocks: true },
55
+ edit_file: { effect: 'write', concurrency: 'resource-locked', retry: 'never', resources: pathResource, delegatesResourceLocks: true },
56
56
  run_command: { effect: 'process', concurrency: 'serial', retry: 'never', resources: workspaceResource, supportsAbort: true },
57
57
  glob: { effect: 'read', concurrency: 'parallel', retry: 'safe', resources: workspaceResource },
58
58
  grep: { effect: 'read', concurrency: 'parallel', retry: 'safe', resources: workspaceResource },
@@ -69,12 +69,14 @@ const CAPABILITIES = {
69
69
  memory_update: { effect: 'write', concurrency: 'serial', retry: 'never', resources: memoryResource },
70
70
  memory_forget: { effect: 'write', concurrency: 'serial', retry: 'never', resources: memoryResource },
71
71
  project_skill_update: { effect: 'write', concurrency: 'serial', retry: 'never', resources: workspaceResource },
72
- // task 只编排子 Agent;真实读写由子调用自行持锁,父调用不得包 workspace 锁。
73
- task: {
72
+ // sub-agent 动态协调:只读任务无锁并行;写任务在 overlay 中执行,merge 时由 ChangeSet 持 canonical lock。
73
+ 'sub-agent': {
74
74
  effect: 'write',
75
- concurrency: 'serial',
75
+ concurrency: 'resource-locked',
76
76
  retry: 'never',
77
- resources: workspaceResource,
77
+ resources: (args) => args.mode === 'write' && Array.isArray(args.writeSet) && args.writeSet.length
78
+ ? args.writeSet.map((item) => `file:${String(item)}`)
79
+ : args.mode === 'write' ? ['workspace'] : [],
78
80
  delegatesResourceLocks: true,
79
81
  supportsAbort: true,
80
82
  },
@@ -95,7 +97,7 @@ const rawBuiltinTools = [
95
97
  dropContextTool,
96
98
  ..._memoryTools,
97
99
  ..._projectSkillTools,
98
- taskTool,
100
+ subAgentTool,
99
101
  ];
100
102
  /** 所有内置工具均携带显式能力;新增工具遗漏声明时 registry 会保守串行。 */
101
103
  export const builtinTools = rawBuiltinTools.map((tool) => ({