minimal-agent 0.18.8 → 0.18.10

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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/loop.js +35 -5
  3. package/src/loop.ts +35 -5
  4. package/src/memory/factLearning.js +3 -3
  5. package/src/memory/factLearning.ts +3 -7
  6. package/src/memory/jobPolicy.js +21 -13
  7. package/src/memory/jobPolicy.ts +22 -11
  8. package/src/memory/learning.js +3 -3
  9. package/src/memory/learning.ts +3 -3
  10. package/src/memory/learningModel.js +4 -2
  11. package/src/memory/learningModel.ts +4 -2
  12. package/src/prompts/delegation.js +15 -0
  13. package/src/prompts/delegation.ts +20 -0
  14. package/src/prompts/policyRegistry.js +3 -2
  15. package/src/prompts/policyRegistry.ts +3 -2
  16. package/src/tools/agent/agent.js +2 -1
  17. package/src/tools/agent/agent.ts +2 -1
  18. package/src/tools/applypatch/applyPatch.js +1 -1
  19. package/src/tools/applypatch/applyPatch.ts +1 -1
  20. package/src/tools/edit/edit.js +11 -15
  21. package/src/tools/edit/edit.ts +10 -15
  22. package/src/tools/edit/multi-edit.js +14 -4
  23. package/src/tools/edit/multi-edit.ts +10 -4
  24. package/src/tools/memory/maintenance.js +2 -0
  25. package/src/tools/memory/maintenance.ts +2 -0
  26. package/src/tools/memory/memory.js +58 -14
  27. package/src/tools/memory/memory.ts +57 -13
  28. package/src/tools/memory/policy.js +16 -1
  29. package/src/tools/memory/policy.ts +16 -1
  30. package/src/tools/read/read.js +91 -47
  31. package/src/tools/read/read.ts +77 -46
  32. package/src/tools/shared/diffSummary.js +25 -3
  33. package/src/tools/shared/diffSummary.ts +23 -3
  34. package/src/tools/shared/editTransaction.js +45 -0
  35. package/src/tools/shared/editTransaction.ts +39 -0
  36. package/src/tools/shared/fileActivity.js +10 -2
  37. package/src/tools/shared/fileActivity.ts +11 -2
  38. package/src/tools/shared/fileState.js +2 -0
  39. package/src/tools/shared/fileState.ts +4 -0
  40. package/src/tools/shared/readBudget.js +12 -0
  41. package/src/tools/shared/readBudget.ts +12 -0
  42. package/src/tools/toolPromptGuides.js +6 -5
  43. package/src/tools/toolPromptGuides.ts +6 -5
  44. package/src/tools/types.js +2 -6
  45. package/src/tools/types.ts +2 -6
  46. package/src/types.ts +4 -2
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * v0.2 增强:
9
9
  * - 路径安全校验(null byte / UNC 路径)
10
- * - 文件大小上限(1GB)
10
+ * - 共享有界编辑事务与提交后回读核验
11
11
  * - old_string 找不到时提供模糊匹配提示(最常见模型错误)
12
12
  * ============================================================
13
13
  */
@@ -15,14 +15,13 @@ import { existsSync } from 'node:fs';
15
15
  import { z } from 'zod';
16
16
  import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
17
17
  import { toToolParameters } from '../../utils/zodToJson.js';
18
- import { resolveToolPath, findActualString, preserveQuoteStyle, detectLineEndingsForString, applyLineEnding, countOccurrences, splitReplaceAll, tryAutoSnapshot, atomicWriteFile, encodeTextFile, readTextFile, } from '../shared/fileUtils.js';
18
+ import { resolveToolPath, findActualString, preserveQuoteStyle, detectLineEndingsForString, applyLineEnding, countOccurrences, splitReplaceAll, tryAutoSnapshot, atomicWriteFile, encodeTextFile, } from '../shared/fileUtils.js';
19
19
  import { filePathField } from '../shared/schemas.js';
20
20
  import { assertFresh, hashFileBytes, recordRead, wasTruncated } from '../shared/fileState.js';
21
21
  import { fileActivityMetadata } from '../shared/fileActivity.js';
22
22
  import { emitFileMutation } from '../shared/fileMutationBus.js';
23
23
  import { buildPatchSummary } from '../shared/diffSummary.js';
24
- /** 编辑文件大小上限:1GB。防止模型试图 Edit 超大文件撑爆内存。 */
25
- const MAX_EDIT_FILE_SIZE_BYTES = 1024 * 1024 * 1024;
24
+ import { readEditSnapshot, commitEdit } from '../shared/editTransaction.js';
26
25
  // ---------------- 1. Zod 输入 schema ----------------
27
26
  const inputSchema = z.object({
28
27
  file_path: filePathField('编辑'),
@@ -43,7 +42,7 @@ const description = `Performs exact string replacements in files.
43
42
  Usage:
44
43
  - You MUST use your \`Read\` tool to read the current content of the file BEFORE calling Edit.
45
44
  Memory is unreliable — if you think you "remember" how the code looks, you are probably wrong.
46
- Re-read the relevant sections (function, module, or area you plan to change) every time.
45
+ Read the relevant sections; reuse unchanged read evidence instead of repeatedly reading the whole file.
47
46
  - To create a new file, pass an empty \`old_string\` and the desired contents as \`new_string\`. When creating a new file (path does not yet exist), you may call Edit directly — no prior Read is needed.
48
47
  - If the file already exists and you pass an empty \`old_string\`, the edit is rejected with a clear error. To replace the entire content of an existing file, use the Write tool instead.
49
48
  - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
@@ -116,9 +115,11 @@ async function call(input, state, cwd) {
116
115
  };
117
116
  }
118
117
  let original;
118
+ let editSnapshot;
119
119
  let sourceEncoding;
120
120
  try {
121
- const snapshot = await readTextFile(filePath);
121
+ const snapshot = await readEditSnapshot(filePath);
122
+ editSnapshot = snapshot;
122
123
  original = snapshot.content;
123
124
  sourceEncoding = snapshot.encoding;
124
125
  }
@@ -127,14 +128,6 @@ async function call(input, state, cwd) {
127
128
  }
128
129
  // 5d-1. 记录原文件行尾符风格(写入时保持一致)
129
130
  const originalLineEnding = detectLineEndingsForString(original);
130
- // 5d. 文件大小上限检查
131
- const fileSize = Buffer.byteLength(original, 'utf8');
132
- if (fileSize > MAX_EDIT_FILE_SIZE_BYTES) {
133
- return {
134
- ok: false,
135
- error: `文件过大(${fileSize} 字节 > ${MAX_EDIT_FILE_SIZE_BYTES} 字节 ≈ 1GB)。Edit 不适合操作超大文件,请用 Write 工具。`,
136
- };
137
- }
138
131
  // 唯性检查
139
132
  if (old_string === '') {
140
133
  return {
@@ -176,7 +169,7 @@ async function call(input, state, cwd) {
176
169
  // 5f. 保持原文件行尾符风格
177
170
  const normalizedReplaced = applyLineEnding(replaced, originalLineEnding);
178
171
  try {
179
- await atomicWriteFile(filePath, normalizedReplaced, sourceEncoding);
172
+ await commitEdit(filePath, editSnapshot, normalizedReplaced);
180
173
  recordRead(filePath, {
181
174
  contentHash: hashFileBytes(encodeTextFile(normalizedReplaced, sourceEncoding)),
182
175
  }, state);
@@ -193,6 +186,7 @@ async function call(input, state, cwd) {
193
186
  ok: true,
194
187
  content: `已编辑 ${filePath}\n` +
195
188
  `替换次数:${replaceAll ? occurrences : 1}\n` +
189
+ `已回读核验提交内容(不代表代码测试通过)。\n` +
196
190
  `行数:${linesBefore} → ${linesAfter}\n` + (patch.unifiedDiff ?? '[diff omitted: too large]'),
197
191
  metadata: await fileActivityMetadata(filePath, 'modified', 'not_run', 'support', cwd, patch),
198
192
  };
@@ -208,6 +202,8 @@ async function call(input, state, cwd) {
208
202
  * 3. 返回重叠率最高的前 N 行作为提示
209
203
  */
210
204
  function findFuzzyMatchHint(fileContent, target) {
205
+ if (fileContent.length > 200_000 || target.length > 2_000)
206
+ return '大文件不执行全文件模糊匹配;用 Grep 定位,再 Read 目标范围获取精确文本。';
211
207
  const MIN_OVERLAP_RATIO = 0.5; // 至少 50% 字符重叠才认为"接近"
212
208
  const MAX_HINTS = 3; // 最多提示 3 个候选
213
209
  const targetTrimmed = target.trim();
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * v0.2 增强:
9
9
  * - 路径安全校验(null byte / UNC 路径)
10
- * - 文件大小上限(1GB)
10
+ * - 共享有界编辑事务与提交后回读核验
11
11
  * - old_string 找不到时提供模糊匹配提示(最常见模型错误)
12
12
  * ============================================================
13
13
  */
@@ -36,9 +36,8 @@ import { assertFresh, hashFileBytes, recordRead, wasTruncated, type FileReadStat
36
36
  import { fileActivityMetadata } from '../shared/fileActivity.js';
37
37
  import { emitFileMutation } from '../shared/fileMutationBus.js';
38
38
  import { buildPatchSummary } from '../shared/diffSummary.js';
39
+ import { readEditSnapshot, commitEdit } from '../shared/editTransaction.js';
39
40
 
40
- /** 编辑文件大小上限:1GB。防止模型试图 Edit 超大文件撑爆内存。 */
41
- const MAX_EDIT_FILE_SIZE_BYTES = 1024 * 1024 * 1024;
42
41
 
43
42
  // ---------------- 1. Zod 输入 schema ----------------
44
43
  const inputSchema = z.object({
@@ -65,7 +64,7 @@ const description = `Performs exact string replacements in files.
65
64
  Usage:
66
65
  - You MUST use your \`Read\` tool to read the current content of the file BEFORE calling Edit.
67
66
  Memory is unreliable — if you think you "remember" how the code looks, you are probably wrong.
68
- Re-read the relevant sections (function, module, or area you plan to change) every time.
67
+ Read the relevant sections; reuse unchanged read evidence instead of repeatedly reading the whole file.
69
68
  - To create a new file, pass an empty \`old_string\` and the desired contents as \`new_string\`. When creating a new file (path does not yet exist), you may call Edit directly — no prior Read is needed.
70
69
  - If the file already exists and you pass an empty \`old_string\`, the edit is rejected with a clear error. To replace the entire content of an existing file, use the Write tool instead.
71
70
  - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
@@ -145,9 +144,11 @@ async function call(input: EditInput, state?: FileReadState, cwd?: string): Prom
145
144
  }
146
145
 
147
146
  let original: string;
147
+ let editSnapshot: Awaited<ReturnType<typeof readEditSnapshot>>;
148
148
  let sourceEncoding: Awaited<ReturnType<typeof readTextFile>>['encoding'];
149
149
  try {
150
- const snapshot = await readTextFile(filePath);
150
+ const snapshot = await readEditSnapshot(filePath);
151
+ editSnapshot = snapshot;
151
152
  original = snapshot.content;
152
153
  sourceEncoding = snapshot.encoding;
153
154
  } catch (e) {
@@ -157,15 +158,6 @@ async function call(input: EditInput, state?: FileReadState, cwd?: string): Prom
157
158
  // 5d-1. 记录原文件行尾符风格(写入时保持一致)
158
159
  const originalLineEnding = detectLineEndingsForString(original);
159
160
 
160
- // 5d. 文件大小上限检查
161
- const fileSize = Buffer.byteLength(original, 'utf8');
162
- if (fileSize > MAX_EDIT_FILE_SIZE_BYTES) {
163
- return {
164
- ok: false,
165
- error: `文件过大(${fileSize} 字节 > ${MAX_EDIT_FILE_SIZE_BYTES} 字节 ≈ 1GB)。Edit 不适合操作超大文件,请用 Write 工具。`,
166
- };
167
- }
168
-
169
161
  // 唯性检查
170
162
  if (old_string === '') {
171
163
  return {
@@ -212,7 +204,7 @@ async function call(input: EditInput, state?: FileReadState, cwd?: string): Prom
212
204
  const normalizedReplaced = applyLineEnding(replaced, originalLineEnding);
213
205
 
214
206
  try {
215
- await atomicWriteFile(filePath, normalizedReplaced, sourceEncoding);
207
+ await commitEdit(filePath, editSnapshot, normalizedReplaced);
216
208
  recordRead(filePath, {
217
209
  contentHash: hashFileBytes(encodeTextFile(normalizedReplaced, sourceEncoding)),
218
210
  }, state);
@@ -230,6 +222,7 @@ async function call(input: EditInput, state?: FileReadState, cwd?: string): Prom
230
222
  content:
231
223
  `已编辑 ${filePath}\n` +
232
224
  `替换次数:${replaceAll ? occurrences : 1}\n` +
225
+ `已回读核验提交内容(不代表代码测试通过)。\n` +
233
226
  `行数:${linesBefore} → ${linesAfter}\n` + (patch.unifiedDiff ?? '[diff omitted: too large]'),
234
227
  metadata: await fileActivityMetadata(filePath, 'modified', 'not_run', 'support', cwd, patch),
235
228
  };
@@ -247,6 +240,8 @@ async function call(input: EditInput, state?: FileReadState, cwd?: string): Prom
247
240
  * 3. 返回重叠率最高的前 N 行作为提示
248
241
  */
249
242
  function findFuzzyMatchHint(fileContent: string, target: string): string | null {
243
+ if (fileContent.length > 200_000 || target.length > 2_000)
244
+ return '大文件不执行全文件模糊匹配;用 Grep 定位,再 Read 目标范围获取精确文本。';
250
245
  const MIN_OVERLAP_RATIO = 0.5; // 至少 50% 字符重叠才认为"接近"
251
246
  const MAX_HINTS = 3; // 最多提示 3 个候选
252
247
 
@@ -17,12 +17,13 @@ import { existsSync } from 'node:fs';
17
17
  import { z } from 'zod';
18
18
  import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
19
19
  import { toToolParameters } from '../../utils/zodToJson.js';
20
- import { resolveToolPath, detectLineEndingsForString, applyLineEnding, findActualString, preserveQuoteStyle, countOccurrences, splitReplaceAll, tryAutoSnapshot, atomicWriteFile, encodeTextFile, readTextFile, } from '../shared/fileUtils.js';
20
+ import { resolveToolPath, detectLineEndingsForString, applyLineEnding, findActualString, preserveQuoteStyle, countOccurrences, splitReplaceAll, tryAutoSnapshot, encodeTextFile, } from '../shared/fileUtils.js';
21
21
  import { filePathField } from '../shared/schemas.js';
22
22
  import { assertFresh, hashFileBytes, recordRead, wasTruncated } from '../shared/fileState.js';
23
23
  import { fileActivityMetadata } from '../shared/fileActivity.js';
24
24
  import { emitFileMutation } from '../shared/fileMutationBus.js';
25
25
  import { buildPatchSummary } from '../shared/diffSummary.js';
26
+ import { readEditSnapshot, commitEdit } from '../shared/editTransaction.js';
26
27
  // ---------------- 1. Zod 输入 schema ----------------
27
28
  const editItemSchema = z.object({
28
29
  old_string: z.string().min(1).describe('要替换的原文本(不允许为空 —— 创建新文件请用 Edit 工具)'),
@@ -108,9 +109,11 @@ async function call(input, state, cwd) {
108
109
  }
109
110
  // 4e. 读原文
110
111
  let originalContent;
112
+ let editSnapshot;
111
113
  let sourceEncoding;
112
114
  try {
113
- const snapshot = await readTextFile(filePath);
115
+ const snapshot = await readEditSnapshot(filePath);
116
+ editSnapshot = snapshot;
114
117
  originalContent = snapshot.content;
115
118
  sourceEncoding = snapshot.encoding;
116
119
  }
@@ -119,6 +122,8 @@ async function call(input, state, cwd) {
119
122
  }
120
123
  // 4f. 在内存中顺序应用所有 edit(任一失败立即返回,磁盘不动)
121
124
  let currentContent = originalContent;
125
+ let replacements = 0;
126
+ let unchanged = 0;
122
127
  const lineEnding = detectLineEndingsForString(originalContent);
123
128
  const truncHint = wasTruncated(filePath, state)
124
129
  ? `\n\n⚠️ 上次 Read 该文件时被截断了(只看到部分内容)。当前 old_string 可能在截断范围外。请用更小的 limit + offset 精确读取目标行号附近后再 MultiEdit。`
@@ -153,6 +158,11 @@ async function call(input, state, cwd) {
153
158
  error: `edits[${i}].old_string 在当前内容中出现 ${occurrences} 次,不唯一。请扩大 old_string 包含更多上下文,或显式传 replace_all=true。${truncHint}`,
154
159
  };
155
160
  }
161
+ if (searchTarget === processedNewString) {
162
+ unchanged++;
163
+ continue;
164
+ }
165
+ replacements += replaceAll ? occurrences : 1;
156
166
  currentContent = replaceAll
157
167
  ? splitReplaceAll(currentContent, searchTarget, processedNewString)
158
168
  : currentContent.replace(searchTarget, () => processedNewString);
@@ -160,7 +170,7 @@ async function call(input, state, cwd) {
160
170
  // 4g. 全部成功 → 保持原文件行尾符风格,写盘
161
171
  const finalContent = applyLineEnding(currentContent, lineEnding);
162
172
  try {
163
- await atomicWriteFile(filePath, finalContent, sourceEncoding);
173
+ await commitEdit(filePath, editSnapshot, finalContent);
164
174
  recordRead(filePath, {
165
175
  contentHash: hashFileBytes(encodeTextFile(finalContent, sourceEncoding)),
166
176
  }, state);
@@ -172,7 +182,7 @@ async function call(input, state, cwd) {
172
182
  const patch = buildPatchSummary(originalContent, finalContent, input.file_path);
173
183
  return {
174
184
  ok: true,
175
- content: `已对 ${filePath} 应用 ${edits.length} 处修改\n${patch.unifiedDiff ?? '[diff omitted: too large]'}`,
185
+ content: `已编辑 ${filePath}:${edits.length} 条操作,实际替换 ${replacements} 次,${unchanged} 条无变化。已回读核验提交内容。\n${patch.unifiedDiff ?? '[diff 未统计:文件过大;不代表无变化]'}`,
176
186
  metadata: await fileActivityMetadata(filePath, 'modified', 'not_run', 'support', cwd, patch),
177
187
  };
178
188
  }
@@ -29,7 +29,6 @@ import {
29
29
  countOccurrences,
30
30
  splitReplaceAll,
31
31
  tryAutoSnapshot,
32
- atomicWriteFile,
33
32
  encodeTextFile,
34
33
  readTextFile,
35
34
  } from '../shared/fileUtils.js';
@@ -38,6 +37,7 @@ import { assertFresh, hashFileBytes, recordRead, wasTruncated, type FileReadStat
38
37
  import { fileActivityMetadata } from '../shared/fileActivity.js';
39
38
  import { emitFileMutation } from '../shared/fileMutationBus.js';
40
39
  import { buildPatchSummary } from '../shared/diffSummary.js';
40
+ import { readEditSnapshot, commitEdit } from '../shared/editTransaction.js';
41
41
 
42
42
  // ---------------- 1. Zod 输入 schema ----------------
43
43
  const editItemSchema = z.object({
@@ -134,9 +134,11 @@ async function call(input: MultiEditInput, state?: FileReadState, cwd?: string):
134
134
 
135
135
  // 4e. 读原文
136
136
  let originalContent: string;
137
+ let editSnapshot: Awaited<ReturnType<typeof readEditSnapshot>>;
137
138
  let sourceEncoding: Awaited<ReturnType<typeof readTextFile>>['encoding'];
138
139
  try {
139
- const snapshot = await readTextFile(filePath);
140
+ const snapshot = await readEditSnapshot(filePath);
141
+ editSnapshot = snapshot;
140
142
  originalContent = snapshot.content;
141
143
  sourceEncoding = snapshot.encoding;
142
144
  } catch (e) {
@@ -145,6 +147,8 @@ async function call(input: MultiEditInput, state?: FileReadState, cwd?: string):
145
147
 
146
148
  // 4f. 在内存中顺序应用所有 edit(任一失败立即返回,磁盘不动)
147
149
  let currentContent = originalContent;
150
+ let replacements = 0;
151
+ let unchanged = 0;
148
152
  const lineEnding = detectLineEndingsForString(originalContent);
149
153
  const truncHint = wasTruncated(filePath, state)
150
154
  ? `\n\n⚠️ 上次 Read 该文件时被截断了(只看到部分内容)。当前 old_string 可能在截断范围外。请用更小的 limit + offset 精确读取目标行号附近后再 MultiEdit。`
@@ -182,6 +186,8 @@ async function call(input: MultiEditInput, state?: FileReadState, cwd?: string):
182
186
  };
183
187
  }
184
188
 
189
+ if (searchTarget === processedNewString) { unchanged++; continue; }
190
+ replacements += replaceAll ? occurrences : 1;
185
191
  currentContent = replaceAll
186
192
  ? splitReplaceAll(currentContent, searchTarget, processedNewString)
187
193
  : currentContent.replace(searchTarget, () => processedNewString);
@@ -191,7 +197,7 @@ async function call(input: MultiEditInput, state?: FileReadState, cwd?: string):
191
197
  const finalContent = applyLineEnding(currentContent, lineEnding);
192
198
 
193
199
  try {
194
- await atomicWriteFile(filePath, finalContent, sourceEncoding);
200
+ await commitEdit(filePath, editSnapshot, finalContent);
195
201
  recordRead(filePath, {
196
202
  contentHash: hashFileBytes(encodeTextFile(finalContent, sourceEncoding)),
197
203
  }, state);
@@ -203,7 +209,7 @@ async function call(input: MultiEditInput, state?: FileReadState, cwd?: string):
203
209
  const patch = buildPatchSummary(originalContent, finalContent, input.file_path);
204
210
  return {
205
211
  ok: true,
206
- content: `已对 ${filePath} 应用 ${edits.length} 处修改\n${patch.unifiedDiff ?? '[diff omitted: too large]'}`,
212
+ content: `已编辑 ${filePath}:${edits.length} 条操作,实际替换 ${replacements} 次,${unchanged} 条无变化。已回读核验提交内容。\n${patch.unifiedDiff ?? '[diff 未统计:文件过大;不代表无变化]'}`,
207
213
  metadata: await fileActivityMetadata(filePath, 'modified', 'not_run', 'support', cwd, patch),
208
214
  };
209
215
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { backgroundLearningHealth } from '../../memory/jobPolicy.js';
2
3
  import { getWorkingDir } from '../../bootstrap/workingDir.js';
3
4
  import { sha256 } from '../../memory/codec.js';
4
5
  import { captureDependencies } from '../../memory/dependencies.js';
@@ -289,6 +290,7 @@ export const memoryMaintenanceTool = {
289
290
  ok: true,
290
291
  content: JSON.stringify({
291
292
  ...(await persistedLearningStatus(ctx.workspaceId)),
293
+ recentBackgroundFailures: backgroundLearningHealth(ctx.workspaceId),
292
294
  revision: store.revision,
293
295
  taskSummaries: {
294
296
  count: store.taskSummaries?.length ?? 0,
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { backgroundLearningHealth } from '../../memory/jobPolicy.js';
2
3
  import { getWorkingDir } from '../../bootstrap/workingDir.js';
3
4
  import { sha256 } from '../../memory/codec.js';
4
5
  import { captureDependencies } from '../../memory/dependencies.js';
@@ -388,6 +389,7 @@ export const memoryMaintenanceTool: Tool<z.infer<typeof schema>> = {
388
389
  content: JSON.stringify(
389
390
  {
390
391
  ...(await persistedLearningStatus(ctx.workspaceId)),
392
+ recentBackgroundFailures: backgroundLearningHealth(ctx.workspaceId),
391
393
  revision: store.revision,
392
394
  taskSummaries: {
393
395
  count: store.taskSummaries?.length ?? 0,
@@ -6,7 +6,7 @@ import { memoryRecordPath } from '../../memory/fileStore.js';
6
6
  import { memoryService, } from '../../memory/memoryService.js';
7
7
  import { toToolParameters } from '../../utils/zodToJson.js';
8
8
  import { DEFAULT_MAX_RESULT_SIZE_CHARS } from '../types.js';
9
- import { EXPLICIT_CROSS_WORKSPACE_INTENT, EXPLICIT_FORGET_INTENT, EXPLICIT_MEMORY_INTENT, EXPLICIT_PERSONA_INTENT, confirmSensitiveMemory, denyUnlessLocalOwner, requireCurrentEvidence, } from './policy.js';
9
+ import { EXPLICIT_CROSS_WORKSPACE_INTENT, EXPLICIT_FORGET_INTENT, EXPLICIT_MEMORY_INTENT, EXPLICIT_PERSONA_INTENT, confirmSensitiveMemory, confirmMemoryCorrection, denyUnlessLocalOwner, requireCurrentEvidence, } from './policy.js';
10
10
  const scopeSchema = z.enum(['personal', 'workspace']);
11
11
  const categorySchema = z.enum([
12
12
  'profile',
@@ -21,7 +21,7 @@ function formatRecord(record, version) {
21
21
  ? ` workspace=${record.workspaceId}`
22
22
  : '';
23
23
  const lifecycle = ` status=${record.status ?? 'active'} revision=${record.revision ?? 1}`;
24
- return `- [${record.id}] scope=${record.scope} ${record.category}/${record.subject}${workspace}${lifecycle}${version ? ` version=${version}` : ''}: ${record.statement}(${record.evidence},${new Date(record.updatedAt).toISOString()})`;
24
+ return `- [${record.id}] scope=${record.scope} ${record.category}/${record.subject}${workspace}${lifecycle}${version ? ` version=${version}` : ''}: ${record.statement}(${record.evidence},${new Date(record.updatedAt).toISOString()})${record.status === 'needs_review' && record.confirmationContext ? `\n复核线索(非事实):${record.confirmationContext}` : ''}`;
25
25
  }
26
26
  function diagnosticSuffix(diagnostics) {
27
27
  if (!diagnostics.length)
@@ -159,15 +159,18 @@ export const rememberMemoryTool = {
159
159
  },
160
160
  };
161
161
  const updateSchema = z.object({
162
+ action: z.enum(['update', 'review', 'confirm']).optional().describe('默认 update:当前用户原话纠正;review:仅标记待复核,不改事实;confirm:通过宿主向用户确认拟更新内容后保存'),
163
+ reason: z.string().min(1).max(1000).optional().describe('review/confirm 必填:冲突或纠正依据,注明来源;不得伪造用户陈述'),
162
164
  id: z.string().uuid(),
163
165
  scope: scopeSchema,
164
166
  category: categorySchema.optional(),
165
167
  subject: z.string().min(1).max(120).optional(),
166
- statement: z.string().min(1).max(1000),
168
+ statement: z.string().min(1).max(1000).optional(),
167
169
  evidenceQuote: z
168
170
  .string()
169
171
  .min(1)
170
172
  .max(1000)
173
+ .optional()
171
174
  .describe('当前用户消息中的逐字证据片段'),
172
175
  sensitivity: z.enum(['normal', 'sensitive']).optional(),
173
176
  aliases: z.array(z.string().min(1).max(120)).max(20).optional(),
@@ -184,7 +187,7 @@ const updateSchema = z.object({
184
187
  });
185
188
  export const updateMemoryTool = {
186
189
  name: 'UpdateMemory',
187
- description: '在用户当前陈述纠正或替换旧事实时,按 RecallMemory 返回的 ID 整条更新。必须先读取旧记录,evidenceQuote 必须逐字来自当前用户消息;不得用推断补写历史。',
190
+ description: '纠正用户事实记忆:先 RecallMemory 读取 ID/version。默认 update 需要 statement 与当前用户原话 evidenceQuote;review 需要 reason,仅标记待复核、保留原事实与证据;confirm 需要 statement/reason,通过宿主展示新旧内容并取得用户确认再更新。拒绝或无法交互不写入。项目知识和经验走 MemoryMaintenance,不伪造用户证据。',
188
191
  inputSchema: updateSchema,
189
192
  parameters: toToolParameters(updateSchema),
190
193
  isReadOnly: false,
@@ -196,9 +199,23 @@ export const updateMemoryTool = {
196
199
  if (denied)
197
200
  return denied;
198
201
  const current = ctx.currentUserInput ?? '';
199
- const invalidEvidence = requireCurrentEvidence(ctx, input.evidenceQuote);
200
- if (invalidEvidence)
201
- return invalidEvidence;
202
+ const action = input.action ?? 'update';
203
+ if (action === 'review' && (input.statement !== undefined || input.evidenceQuote !== undefined)) {
204
+ return { ok: false, error: 'review 仅提交 reason,不提交 statement/evidenceQuote;标记疑点不代表确认新事实' };
205
+ }
206
+ if (action !== 'update' && (input.category !== undefined || input.subject !== undefined || input.aliases !== undefined || input.supersedesIds !== undefined || input.sensitivity !== undefined)) {
207
+ return { ok: false, error: 'review/confirm 只处理当前记录的复核状态或正文,不同时修改分类、敏感级别、别名或替代其他记录' };
208
+ }
209
+ if (action === 'update') {
210
+ if (!input.statement || !input.evidenceQuote)
211
+ return { ok: false, error: 'update 需要 statement 和当前用户原话 evidenceQuote' };
212
+ const invalidEvidence = requireCurrentEvidence(ctx, input.evidenceQuote);
213
+ if (invalidEvidence)
214
+ return invalidEvidence;
215
+ }
216
+ else if (!input.reason || (action === 'confirm' && !input.statement)) {
217
+ return { ok: false, error: 'review 需要 reason;confirm 需要 reason 和 statement' };
218
+ }
202
219
  if (input.scope === 'workspace' && !ctx.workspaceId) {
203
220
  return {
204
221
  ok: false,
@@ -206,7 +223,7 @@ export const updateMemoryTool = {
206
223
  };
207
224
  }
208
225
  if (!(await memoryService.settings()).autoRemember &&
209
- !EXPLICIT_MEMORY_INTENT.test(current)) {
226
+ !EXPLICIT_MEMORY_INTENT.test(current) && action !== 'confirm') {
210
227
  return {
211
228
  ok: false,
212
229
  error: '用户已关闭自动记忆;当前消息没有明确要求更新记忆',
@@ -215,18 +232,43 @@ export const updateMemoryTool = {
215
232
  const currentRecord = await memoryService.readWithVersion(input.id, input.scope, input.scope === 'workspace' ? ctx.workspaceId : undefined);
216
233
  if (!currentRecord)
217
234
  return { ok: false, error: '未找到该记忆,未做修改' };
218
- const sensitivity = input.sensitivity ?? currentRecord.record.sensitivity ?? 'normal';
235
+ const expectedVersion = input.expectedVersion ?? currentRecord.version;
236
+ if (expectedVersion !== currentRecord.version) {
237
+ return {
238
+ ok: false,
239
+ error: '记忆已被手工或并发修改。未应用本次更新;保留以下当前记录的并发修改后可直接重试,不存在时重新定位。\n'
240
+ + JSON.stringify({ current: currentRecord }),
241
+ };
242
+ }
243
+ if (action === 'review') {
244
+ signal?.throwIfAborted();
245
+ try {
246
+ const reviewed = await memoryService.updateWithVersion(input.id, input.scope, expectedVersion, { status: 'needs_review', confirmationContext: `待复核原因(非确认事实):${input.reason}` }, input.scope === 'workspace' ? ctx.workspaceId : undefined);
247
+ return reviewed ? { ok: true, content: `已标记待复核,原事实和证据未改写。id=${input.id}\nversion=${reviewed.version}` }
248
+ : { ok: false, error: '记录已不存在,未做修改' };
249
+ }
250
+ catch (error) {
251
+ if (error.message === 'MEMORY_VERSION_CONFLICT')
252
+ return { ok: false, error: '记忆并发变更,请重新读取;未标记待复核' };
253
+ throw error;
254
+ }
255
+ }
256
+ const statement = input.statement;
257
+ const confirmed = action === 'confirm' && await confirmMemoryCorrection(currentRecord.record.subject, currentRecord.record.statement, statement, input.reason, signal);
258
+ if (action === 'confirm' && !confirmed)
259
+ return { ok: false, error: '未获得用户确认(拒绝或宿主无法交互),原记忆未改写;可标记待复核,不要重复请求确认' };
260
+ const evidenceQuote = confirmed ? `用户通过纠正记忆确认:${statement}` : input.evidenceQuote;
261
+ const sensitivity = currentRecord.record.sensitivity === 'sensitive' ? 'sensitive' : input.sensitivity ?? 'normal';
219
262
  if (sensitivity === 'sensitive' &&
220
- input.statement.trim() === input.evidenceQuote.trim()) {
263
+ !confirmed && statement.trim() === evidenceQuote.trim()) {
221
264
  return {
222
265
  ok: false,
223
266
  error: '敏感内容不得逐字更新;请先概括、去除不必要细节后再请求确认',
224
267
  };
225
268
  }
226
- const evidence = await confirmSensitiveMemory(input.statement, sensitivity, signal);
269
+ const evidence = confirmed ? 'user_confirmed' : await confirmSensitiveMemory(statement, sensitivity, signal);
227
270
  if (!evidence)
228
271
  return { ok: false, error: '敏感记忆更新未获确认' };
229
- const expectedVersion = input.expectedVersion ?? currentRecord.version;
230
272
  let updated;
231
273
  try {
232
274
  updated = await memoryService.updateWithVersion(input.id, input.scope, expectedVersion, {
@@ -234,9 +276,11 @@ export const updateMemoryTool = {
234
276
  ? { category: input.category }
235
277
  : {}),
236
278
  ...(input.subject ? { subject: input.subject } : {}),
237
- statement: input.statement,
279
+ statement,
280
+ status: 'active',
238
281
  evidence,
239
- evidenceQuote: input.evidenceQuote,
282
+ evidenceQuote,
283
+ confirmationContext: confirmed ? `宿主确认纠正:${input.reason}` : '',
240
284
  sourceSessionId: ctx.sessionId ?? 'unknown',
241
285
  sourceMessageId: ctx.userMessageId ?? 'unknown',
242
286
  sensitivity,
@@ -18,6 +18,7 @@ import {
18
18
  EXPLICIT_MEMORY_INTENT,
19
19
  EXPLICIT_PERSONA_INTENT,
20
20
  confirmSensitiveMemory,
21
+ confirmMemoryCorrection,
21
22
  denyUnlessLocalOwner,
22
23
  requireCurrentEvidence,
23
24
  } from './policy.js';
@@ -39,7 +40,7 @@ function formatRecord(
39
40
  ? ` workspace=${record.workspaceId}`
40
41
  : '';
41
42
  const lifecycle = ` status=${record.status ?? 'active'} revision=${record.revision ?? 1}`;
42
- return `- [${record.id}] scope=${record.scope} ${record.category}/${record.subject}${workspace}${lifecycle}${version ? ` version=${version}` : ''}: ${record.statement}(${record.evidence},${new Date(record.updatedAt).toISOString()})`;
43
+ return `- [${record.id}] scope=${record.scope} ${record.category}/${record.subject}${workspace}${lifecycle}${version ? ` version=${version}` : ''}: ${record.statement}(${record.evidence},${new Date(record.updatedAt).toISOString()})${record.status === 'needs_review' && record.confirmationContext ? `\n复核线索(非事实):${record.confirmationContext}` : ''}`;
43
44
  }
44
45
 
45
46
  function diagnosticSuffix(
@@ -188,15 +189,18 @@ export const rememberMemoryTool: Tool<z.infer<typeof rememberSchema>> = {
188
189
  };
189
190
 
190
191
  const updateSchema = z.object({
192
+ action: z.enum(['update', 'review', 'confirm']).optional().describe('默认 update:当前用户原话纠正;review:仅标记待复核,不改事实;confirm:通过宿主向用户确认拟更新内容后保存'),
193
+ reason: z.string().min(1).max(1000).optional().describe('review/confirm 必填:冲突或纠正依据,注明来源;不得伪造用户陈述'),
191
194
  id: z.string().uuid(),
192
195
  scope: scopeSchema,
193
196
  category: categorySchema.optional(),
194
197
  subject: z.string().min(1).max(120).optional(),
195
- statement: z.string().min(1).max(1000),
198
+ statement: z.string().min(1).max(1000).optional(),
196
199
  evidenceQuote: z
197
200
  .string()
198
201
  .min(1)
199
202
  .max(1000)
203
+ .optional()
200
204
  .describe('当前用户消息中的逐字证据片段'),
201
205
  sensitivity: z.enum(['normal', 'sensitive']).optional(),
202
206
  aliases: z.array(z.string().min(1).max(120)).max(20).optional(),
@@ -217,7 +221,7 @@ const updateSchema = z.object({
217
221
  export const updateMemoryTool: Tool<z.infer<typeof updateSchema>> = {
218
222
  name: 'UpdateMemory',
219
223
  description:
220
- '在用户当前陈述纠正或替换旧事实时,按 RecallMemory 返回的 ID 整条更新。必须先读取旧记录,evidenceQuote 必须逐字来自当前用户消息;不得用推断补写历史。',
224
+ '纠正用户事实记忆:先 RecallMemory 读取 ID/version。默认 update 需要 statement 与当前用户原话 evidenceQuote;review 需要 reason,仅标记待复核、保留原事实与证据;confirm 需要 statement/reason,通过宿主展示新旧内容并取得用户确认再更新。拒绝或无法交互不写入。项目知识和经验走 MemoryMaintenance,不伪造用户证据。',
221
225
  inputSchema: updateSchema,
222
226
  parameters: toToolParameters(updateSchema),
223
227
  isReadOnly: false,
@@ -228,8 +232,20 @@ export const updateMemoryTool: Tool<z.infer<typeof updateSchema>> = {
228
232
  const denied = denyUnlessLocalOwner(ctx);
229
233
  if (denied) return denied;
230
234
  const current = ctx.currentUserInput ?? '';
231
- const invalidEvidence = requireCurrentEvidence(ctx, input.evidenceQuote);
232
- if (invalidEvidence) return invalidEvidence;
235
+ const action = input.action ?? 'update';
236
+ if (action === 'review' && (input.statement !== undefined || input.evidenceQuote !== undefined)) {
237
+ return { ok: false, error: 'review 仅提交 reason,不提交 statement/evidenceQuote;标记疑点不代表确认新事实' };
238
+ }
239
+ if (action !== 'update' && (input.category !== undefined || input.subject !== undefined || input.aliases !== undefined || input.supersedesIds !== undefined || input.sensitivity !== undefined)) {
240
+ return { ok: false, error: 'review/confirm 只处理当前记录的复核状态或正文,不同时修改分类、敏感级别、别名或替代其他记录' };
241
+ }
242
+ if (action === 'update') {
243
+ if (!input.statement || !input.evidenceQuote) return { ok: false, error: 'update 需要 statement 和当前用户原话 evidenceQuote' };
244
+ const invalidEvidence = requireCurrentEvidence(ctx, input.evidenceQuote);
245
+ if (invalidEvidence) return invalidEvidence;
246
+ } else if (!input.reason || (action === 'confirm' && !input.statement)) {
247
+ return { ok: false, error: 'review 需要 reason;confirm 需要 reason 和 statement' };
248
+ }
233
249
  if (input.scope === 'workspace' && !ctx.workspaceId) {
234
250
  return {
235
251
  ok: false,
@@ -238,7 +254,7 @@ export const updateMemoryTool: Tool<z.infer<typeof updateSchema>> = {
238
254
  }
239
255
  if (
240
256
  !(await memoryService.settings()).autoRemember &&
241
- !EXPLICIT_MEMORY_INTENT.test(current)
257
+ !EXPLICIT_MEMORY_INTENT.test(current) && action !== 'confirm'
242
258
  ) {
243
259
  return {
244
260
  ok: false,
@@ -251,23 +267,49 @@ export const updateMemoryTool: Tool<z.infer<typeof updateSchema>> = {
251
267
  input.scope === 'workspace' ? ctx.workspaceId : undefined,
252
268
  );
253
269
  if (!currentRecord) return { ok: false, error: '未找到该记忆,未做修改' };
254
- const sensitivity = input.sensitivity ?? currentRecord.record.sensitivity ?? 'normal';
270
+ const expectedVersion = input.expectedVersion ?? currentRecord.version;
271
+ if (expectedVersion !== currentRecord.version) {
272
+ return {
273
+ ok: false,
274
+ error:
275
+ '记忆已被手工或并发修改。未应用本次更新;保留以下当前记录的并发修改后可直接重试,不存在时重新定位。\n'
276
+ + JSON.stringify({ current: currentRecord }),
277
+ };
278
+ }
279
+ if (action === 'review') {
280
+ signal?.throwIfAborted();
281
+ try {
282
+ const reviewed = await memoryService.updateWithVersion(input.id, input.scope, expectedVersion,
283
+ { status: 'needs_review', confirmationContext: `待复核原因(非确认事实):${input.reason}` },
284
+ input.scope === 'workspace' ? ctx.workspaceId : undefined);
285
+ return reviewed ? { ok: true, content: `已标记待复核,原事实和证据未改写。id=${input.id}\nversion=${reviewed.version}` }
286
+ : { ok: false, error: '记录已不存在,未做修改' };
287
+ } catch (error) {
288
+ if ((error as Error).message === 'MEMORY_VERSION_CONFLICT') return { ok: false, error: '记忆并发变更,请重新读取;未标记待复核' };
289
+ throw error;
290
+ }
291
+ }
292
+ const statement = input.statement!;
293
+ const confirmed = action === 'confirm' && await confirmMemoryCorrection(currentRecord.record.subject,
294
+ currentRecord.record.statement, statement, input.reason!, signal);
295
+ if (action === 'confirm' && !confirmed) return { ok: false, error: '未获得用户确认(拒绝或宿主无法交互),原记忆未改写;可标记待复核,不要重复请求确认' };
296
+ const evidenceQuote = confirmed ? `用户通过纠正记忆确认:${statement}` : input.evidenceQuote!;
297
+ const sensitivity = currentRecord.record.sensitivity === 'sensitive' ? 'sensitive' : input.sensitivity ?? 'normal';
255
298
  if (
256
299
  sensitivity === 'sensitive' &&
257
- input.statement.trim() === input.evidenceQuote.trim()
300
+ !confirmed && statement.trim() === evidenceQuote.trim()
258
301
  ) {
259
302
  return {
260
303
  ok: false,
261
304
  error: '敏感内容不得逐字更新;请先概括、去除不必要细节后再请求确认',
262
305
  };
263
306
  }
264
- const evidence = await confirmSensitiveMemory(
265
- input.statement,
307
+ const evidence = confirmed ? 'user_confirmed' : await confirmSensitiveMemory(
308
+ statement,
266
309
  sensitivity,
267
310
  signal,
268
311
  );
269
312
  if (!evidence) return { ok: false, error: '敏感记忆更新未获确认' };
270
- const expectedVersion = input.expectedVersion ?? currentRecord.version;
271
313
  let updated: Awaited<ReturnType<typeof memoryService.updateWithVersion>>;
272
314
  try {
273
315
  updated = await memoryService.updateWithVersion(
@@ -279,9 +321,11 @@ export const updateMemoryTool: Tool<z.infer<typeof updateSchema>> = {
279
321
  ? { category: input.category as MemoryCategory }
280
322
  : {}),
281
323
  ...(input.subject ? { subject: input.subject } : {}),
282
- statement: input.statement,
324
+ statement,
325
+ status: 'active',
283
326
  evidence,
284
- evidenceQuote: input.evidenceQuote,
327
+ evidenceQuote,
328
+ confirmationContext: confirmed ? `宿主确认纠正:${input.reason}` : '',
285
329
  sourceSessionId: ctx.sessionId ?? 'unknown',
286
330
  sourceMessageId: ctx.userMessageId ?? 'unknown',
287
331
  sensitivity,
@@ -25,9 +25,24 @@ export function requireCurrentEvidence(ctx, evidenceQuote) {
25
25
  ? null
26
26
  : {
27
27
  ok: false,
28
- error: 'evidenceQuote 不是当前用户消息的逐字片段,拒绝写入推断性记忆',
28
+ error: 'evidenceQuote 不是当前用户消息的逐字片段,未写入。不要改写或伪造引用重试:用户事实可用 UpdateMemory(action=confirm) 请求确认,或 action=review 标记待复核;项目知识/经验使用 MemoryMaintenance,不把工具证据当用户原话。',
29
29
  };
30
30
  }
31
+ export async function confirmMemoryCorrection(subject, previous, proposed, reason, signal) {
32
+ if (!hasAskHandler())
33
+ return false;
34
+ signal?.throwIfAborted();
35
+ const [answer] = await requestUserChoice([{
36
+ header: '纠正记忆',
37
+ question: `是否更新“${subject}”?\n原记录:${previous}\n拟更新为:${proposed}\n纠正依据:${reason}`,
38
+ options: [
39
+ { label: '暂不更新', description: '保留原记录,不把建议视为事实。' },
40
+ { label: '确认更新', description: '确认拟更新内容准确,并保存为本机长期记忆。' },
41
+ ],
42
+ }], signal);
43
+ signal?.throwIfAborted();
44
+ return answer?.selected.length === 1 && answer.selected[0] === '确认更新';
45
+ }
31
46
  export async function confirmSensitiveMemory(statement, sensitivity, signal) {
32
47
  if (sensitivity === 'normal')
33
48
  return 'user_stated';