minimal-agent 0.11.0 → 0.12.0

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 (128) hide show
  1. package/package.json +4 -1
  2. package/plugins/workflow-runner/plugin.js +27 -15
  3. package/plugins/workflow-runner/plugin.ts +34 -20
  4. package/plugins/workflow-runner/src/agentCommon.js +13 -12
  5. package/plugins/workflow-runner/src/agentCommon.ts +22 -13
  6. package/plugins/workflow-runner/src/index.js +5 -3
  7. package/plugins/workflow-runner/src/index.ts +31 -13
  8. package/plugins/workflow-runner/src/multimodal.js +2 -1
  9. package/plugins/workflow-runner/src/multimodal.ts +2 -1
  10. package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
  11. package/plugins/workflow-runner/src/stepExecutors/skill.ts +12 -7
  12. package/plugins/workflow-runner/src/stepExecutors/tool.js +68 -57
  13. package/plugins/workflow-runner/src/stepExecutors/tool.ts +88 -66
  14. package/plugins/workflow-runner/src/toolRuntime.js +48 -0
  15. package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
  16. package/plugins/workflow-runner/src/types.ts +26 -7
  17. package/plugins/workflow-runner/test/context-files.test.ts +47 -16
  18. package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
  19. package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
  20. package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
  21. package/src/cli/print.js +19 -4
  22. package/src/cli/print.ts +17 -4
  23. package/src/cli/streamJson.js +19 -4
  24. package/src/cli/streamJson.ts +17 -4
  25. package/src/config/configDefaults.js +25 -0
  26. package/src/config/configDefaults.ts +33 -0
  27. package/src/config/configFile.js +2 -21
  28. package/src/config/configFile.ts +11 -24
  29. package/src/config/configManagement.js +1 -1
  30. package/src/config/configManagement.ts +2 -2
  31. package/src/context/compact.js +4 -0
  32. package/src/context/compact.ts +4 -0
  33. package/src/context/contextBundle.js +38 -13
  34. package/src/context/contextBundle.ts +47 -13
  35. package/src/harness/stallGuard.js +19 -4
  36. package/src/harness/stallGuard.ts +30 -7
  37. package/src/loop.js +95 -11
  38. package/src/loop.ts +125 -16
  39. package/src/main.js +8 -1
  40. package/src/main.tsx +7 -1
  41. package/src/memory/constants.js +10 -0
  42. package/src/memory/constants.ts +13 -0
  43. package/src/memory/episodicMemory.js +274 -2
  44. package/src/memory/episodicMemory.ts +306 -1
  45. package/src/memory/fileStore.js +24 -4
  46. package/src/memory/fileStore.ts +22 -2
  47. package/src/memory/memoryService.js +79 -12
  48. package/src/memory/memoryService.ts +108 -9
  49. package/src/memory/repository.js +415 -85
  50. package/src/memory/repository.ts +437 -86
  51. package/src/memory/turnJournal.js +95 -0
  52. package/src/memory/turnJournal.ts +120 -0
  53. package/src/memory/types.ts +16 -0
  54. package/src/plugins/pluginApi.ts +30 -6
  55. package/src/plugins/pluginRunner.js +6 -2
  56. package/src/plugins/pluginRunner.ts +12 -7
  57. package/src/prompts/policyRegistry.js +27 -0
  58. package/src/prompts/policyRegistry.ts +27 -0
  59. package/src/prompts/promptAssembly.js +39 -7
  60. package/src/prompts/promptAssembly.ts +145 -59
  61. package/src/session/runAgentTurn.js +43 -21
  62. package/src/session/runAgentTurn.ts +68 -26
  63. package/src/session/runTurn.js +11 -46
  64. package/src/session/runTurn.ts +12 -46
  65. package/src/session/runtimeStore.js +149 -0
  66. package/src/session/runtimeStore.ts +197 -0
  67. package/src/session/sessionState.js +5 -0
  68. package/src/session/sessionState.ts +14 -0
  69. package/src/tools/agent/agent.js +143 -14
  70. package/src/tools/agent/agent.ts +149 -14
  71. package/src/tools/agent/agentTaskService.js +60 -0
  72. package/src/tools/agent/agentTaskService.ts +113 -0
  73. package/src/tools/agent/agentTypes.js +5 -0
  74. package/src/tools/agent/agentTypes.ts +5 -0
  75. package/src/tools/bash/bash.js +31 -11
  76. package/src/tools/bash/bash.ts +29 -10
  77. package/src/tools/capabilities.js +5 -0
  78. package/src/tools/capabilities.ts +11 -1
  79. package/src/tools/edit/edit.js +7 -4
  80. package/src/tools/edit/edit.ts +7 -4
  81. package/src/tools/edit/multi-edit.js +4 -2
  82. package/src/tools/edit/multi-edit.ts +4 -2
  83. package/src/tools/glob/glob.js +156 -40
  84. package/src/tools/glob/glob.ts +185 -46
  85. package/src/tools/index.js +35 -17
  86. package/src/tools/index.ts +81 -20
  87. package/src/tools/lsp/lsp.js +165 -22
  88. package/src/tools/lsp/lsp.ts +206 -36
  89. package/src/tools/powershell/powershell.js +116 -26
  90. package/src/tools/powershell/powershell.ts +155 -30
  91. package/src/tools/presentresult/presentresult.js +10 -0
  92. package/src/tools/presentresult/presentresult.ts +10 -0
  93. package/src/tools/read/read.js +244 -29
  94. package/src/tools/read/read.ts +391 -59
  95. package/src/tools/shared/diffSummary.js +44 -0
  96. package/src/tools/shared/diffSummary.ts +63 -0
  97. package/src/tools/shared/fileActivity.js +2 -1
  98. package/src/tools/shared/fileActivity.ts +3 -0
  99. package/src/tools/shared/permanentDeleteGuard.js +26 -0
  100. package/src/tools/shared/permanentDeleteGuard.ts +34 -0
  101. package/src/tools/shared/toolOutputStore.js +97 -0
  102. package/src/tools/shared/toolOutputStore.ts +110 -0
  103. package/src/tools/skill/skill.js +86 -0
  104. package/src/tools/skill/skill.ts +94 -0
  105. package/src/tools/snip/snip.js +67 -0
  106. package/src/tools/snip/snip.ts +80 -0
  107. package/src/tools/tasks/backgroundTaskService.js +79 -28
  108. package/src/tools/tasks/backgroundTaskService.ts +165 -49
  109. package/src/tools/tasks/taskManagement.js +163 -0
  110. package/src/tools/tasks/taskManagement.ts +180 -0
  111. package/src/tools/tasks/taskOutput.js +64 -14
  112. package/src/tools/tasks/taskOutput.ts +102 -23
  113. package/src/tools/tasks/taskStop.js +48 -8
  114. package/src/tools/tasks/taskStop.ts +69 -9
  115. package/src/tools/tasks/taskStore.js +46 -0
  116. package/src/tools/tasks/taskStore.ts +77 -0
  117. package/src/tools/terminal/terminalCapture.js +167 -0
  118. package/src/tools/terminal/terminalCapture.ts +179 -0
  119. package/src/tools/trash/trash.js +233 -0
  120. package/src/tools/trash/trash.ts +283 -0
  121. package/src/tools/worktree/sessionWorktree.js +128 -0
  122. package/src/tools/worktree/sessionWorktree.ts +158 -0
  123. package/src/tools/write/write.js +6 -2
  124. package/src/tools/write/write.ts +6 -2
  125. package/src/types.ts +12 -0
  126. package/src/ui/hooks/useChat.js +31 -11
  127. package/src/ui/hooks/useChat.ts +31 -13
  128. package/src/web/protocol.ts +78 -3
@@ -1,8 +1,10 @@
1
1
  import { watch, type FSWatcher } from 'node:fs';
2
2
  import { mkdir, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
3
- import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ import { dirname, join, resolve } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
 
6
+ import { pathContains } from '../utils/pathContains.js';
7
+
6
8
  import { parseMemoryRecord, serializeMemoryRecord, sha256 } from './codec.js';
7
9
  import { ALL_CATEGORIES, CATEGORY_DIR } from './constants.js';
8
10
  import {
@@ -30,8 +32,10 @@ interface CachedFile {
30
32
  }
31
33
 
32
34
  const fileCache = new Map<string, CachedFile>();
33
- const SCOPE_FALLBACK_TTL_MS = 1_000;
34
- const SCOPE_WATCH_AUDIT_MS = 30_000;
35
+ /** 兜底审计的最小间隔:watcher 漏掉的变更最迟这么久后会被 stat 审计发现。 */
36
+ const SCOPE_AUDIT_TTL_MS = 1_000;
37
+ /** 「扫描期间被改动 → 重扫」的重试上限,防止持续写入把读拖成活锁。 */
38
+ const MAX_SCAN_RETRIES = 3;
35
39
  const MAX_SCOPE_CACHE_ENTRIES = 128;
36
40
 
37
41
  const repositoryDiagnostics = {
@@ -52,18 +56,69 @@ export function resetMemoryRepositoryDiagnostics(): void {
52
56
  repositoryDiagnostics.fullScans = 0;
53
57
  }
54
58
 
55
- interface CachedScope {
59
+ interface ScopeCacheEntry {
56
60
  snapshot: MemorySnapshot;
57
- checkedAt: number;
58
61
  dirty: boolean;
59
- watcherActive: boolean;
60
62
  directoryFingerprint: string;
61
63
  lastAuditAt: number;
64
+ }
65
+
66
+ interface ScopeRuntimeState {
67
+ cache?: ScopeCacheEntry;
68
+ watchers: FSWatcher[];
69
+ watcherComplete: boolean;
70
+ watcherRefreshRequired: boolean;
71
+ epoch: number;
62
72
  lastUsedAt: number;
73
+ lastDrainAt: number;
74
+ suppressDirtyUntil: number;
75
+ }
76
+
77
+ const scopeStates = new Map<string, ScopeRuntimeState>();
78
+
79
+ function scopeState(key: string): ScopeRuntimeState {
80
+ let state = scopeStates.get(key);
81
+ if (!state) {
82
+ state = {
83
+ watchers: [],
84
+ watcherComplete: false,
85
+ watcherRefreshRequired: false,
86
+ epoch: 0,
87
+ lastUsedAt: 0,
88
+ lastDrainAt: 0,
89
+ suppressDirtyUntil: 0,
90
+ };
91
+ scopeStates.set(key, state);
92
+ }
93
+ return state;
63
94
  }
64
95
 
65
- const scopeCache = new Map<string, CachedScope>();
66
- const scopeWatchers = new Map<string, FSWatcher[]>();
96
+ /** scope 目录下由本模块自己生成、不该被当成「用户改了记忆」的条目。 */
97
+ const GENERATED_SCOPE_ENTRIES: ReadonlySet<string> = new Set([
98
+ 'views',
99
+ '.transactions',
100
+ '.records.lock',
101
+ ]);
102
+ const CATEGORY_SCOPE_ENTRIES = new Set(Object.values(CATEGORY_DIR));
103
+ const WATCHER_SETTLE_REUSE_MS = 50;
104
+ const WATCHER_SETTLE_WAIT_MS = 20;
105
+ const INTERNAL_RECORD_EVENT_TTL_MS = 1_000;
106
+ const internalRecordEvents = new Map<string, number>();
107
+
108
+ function markInternalRecordEvent(path: string): void {
109
+ internalRecordEvents.set(path, Date.now() + INTERNAL_RECORD_EVENT_TTL_MS);
110
+ }
111
+
112
+ function consumeInternalRecordEvent(path: string): boolean {
113
+ const expiresAt = internalRecordEvents.get(path);
114
+ if (!expiresAt) return false;
115
+ if (expiresAt < Date.now()) {
116
+ internalRecordEvents.delete(path);
117
+ return false;
118
+ }
119
+ internalRecordEvents.delete(path);
120
+ return true;
121
+ }
67
122
 
68
123
  interface MemoryTransactionItem {
69
124
  id: string;
@@ -84,9 +139,13 @@ function transactionRoot(base: string): string { return join(base, '.transaction
84
139
  function transactionManifestPath(base: string): string { return join(transactionRoot(base), 'current.json'); }
85
140
  function generationPath(base: string): string { return join(transactionRoot(base), 'generation'); }
86
141
 
142
+ /**
143
+ * 「这个路径在不在那个目录里」只有一份实现:`src/utils/pathContains.ts`。
144
+ * 这里只负责把两边解析成绝对路径(manifest 里的路径来自 JSON,可能是相对的),
145
+ * 判据本身不再手写第五遍——那个 util 的头注释专门警告过手写副本会各自漂移。
146
+ */
87
147
  function pathInside(base: string, path: string): boolean {
88
- const rel = relative(resolve(base), resolve(path));
89
- return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
148
+ return pathContains(resolve(base), resolve(path));
90
149
  }
91
150
 
92
151
  async function readGeneration(base: string): Promise<string> {
@@ -99,7 +158,12 @@ async function readTransactionManifest(base: string): Promise<MemoryTransactionM
99
158
  throw error;
100
159
  });
101
160
  if (raw === null) return null;
102
- const parsed = JSON.parse(raw) as MemoryTransactionManifest;
161
+ let parsed: MemoryTransactionManifest;
162
+ try {
163
+ parsed = JSON.parse(raw) as MemoryTransactionManifest;
164
+ } catch {
165
+ throw new Error('MEMORY_TRANSACTION_CORRUPT');
166
+ }
103
167
  if (parsed.schemaVersion !== 1 || !parsed.id || !Array.isArray(parsed.items)) {
104
168
  throw new Error('MEMORY_TRANSACTION_CORRUPT');
105
169
  }
@@ -129,6 +193,8 @@ async function rollForwardTransactionUnlocked(base: string): Promise<void> {
129
193
  throw new Error('MEMORY_TRANSACTION_CONFLICT');
130
194
  }
131
195
  await mkdir(dirname(item.nextPath), { recursive: true, mode: 0o700 });
196
+ markInternalRecordEvent(item.nextPath);
197
+ if (item.oldPath !== item.nextPath) markInternalRecordEvent(item.oldPath);
132
198
  await rename(item.stagePath, item.nextPath);
133
199
  if (item.oldPath !== item.nextPath) await unlink(item.oldPath).catch(() => {});
134
200
  }
@@ -137,91 +203,332 @@ async function rollForwardTransactionUnlocked(base: string): Promise<void> {
137
203
  await rm(join(transactionRoot(base), manifest.id), { recursive: true, force: true });
138
204
  }
139
205
 
140
- async function recoverScopeTransaction(base: string): Promise<boolean> {
141
- const manifest = await readTransactionManifest(base);
142
- if (!manifest) return false;
206
+ const TRANSACTION_POISON_ERRORS = new Set([
207
+ 'MEMORY_TRANSACTION_CORRUPT',
208
+ 'MEMORY_TRANSACTION_PATH_INVALID',
209
+ 'MEMORY_TRANSACTION_CONFLICT',
210
+ ]);
211
+
212
+ interface ScopeRecovery {
213
+ recovered: boolean;
214
+ }
215
+
216
+ interface QuarantineMetadata {
217
+ schemaVersion: 1;
218
+ quarantinedAt: number;
219
+ reason: string;
220
+ originalManifestPath: string;
221
+ }
222
+
223
+ async function quarantineDiagnosticsFor(base: string): Promise<MemoryDiagnostic[]> {
224
+ const root = transactionRoot(base);
225
+ let names: string[];
226
+ try {
227
+ names = (await readdir(root, { withFileTypes: true }))
228
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('quarantine-'))
229
+ .map((entry) => entry.name);
230
+ } catch (error) {
231
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
232
+ throw error;
233
+ }
234
+ const diagnostics: MemoryDiagnostic[] = [];
235
+ for (const name of names.sort()) {
236
+ const target = join(root, name);
237
+ let metadata: QuarantineMetadata | null = null;
238
+ try {
239
+ metadata = JSON.parse(await readFile(join(target, 'diagnostic.json'), 'utf8')) as QuarantineMetadata;
240
+ } catch {
241
+ // 旧版本隔离目录没有元数据时仍需告警,不能因升级让诊断沉默。
242
+ }
243
+ const reason = metadata?.reason ?? 'MEMORY_TRANSACTION_QUARANTINED';
244
+ diagnostics.push({
245
+ path: metadata?.originalManifestPath ?? join(target, 'current.json'),
246
+ message:
247
+ `记忆事务无法前滚(${reason}),已隔离到 ${target} 并继续读取现有记录;`
248
+ + '该批次的改动可能只应用了一部分,请复核相关记忆。',
249
+ });
250
+ }
251
+ return diagnostics;
252
+ }
253
+
254
+ export async function clearScopeTransactionQuarantine(
255
+ scope: MemoryScope,
256
+ workspaceId?: string,
257
+ ): Promise<void> {
258
+ const root = transactionRoot(scopeDirectory(scope, workspaceId));
259
+ let names: string[] = [];
260
+ try {
261
+ names = (await readdir(root, { withFileTypes: true }))
262
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('quarantine-'))
263
+ .map((entry) => entry.name);
264
+ } catch (error) {
265
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
266
+ }
267
+ await Promise.all(names.map(async (name) => {
268
+ const quarantine = join(root, name);
269
+ try {
270
+ const manifest = JSON.parse(await readFile(join(quarantine, 'current.json'), 'utf8')) as { id?: unknown };
271
+ if (typeof manifest.id === 'string' && /^[a-f0-9-]{16,}$/i.test(manifest.id)) {
272
+ await rm(join(root, manifest.id), { recursive: true, force: true });
273
+ }
274
+ } catch {
275
+ // 损坏清单本来就可能无法解析;清理隔离目录仍继续。
276
+ }
277
+ await rm(quarantine, { recursive: true, force: true });
278
+ }));
279
+ invalidateScope(scope, workspaceId);
280
+ }
281
+
282
+ /** 隔离操作的专用锁路径(实际锁文件是 `<它>.lock`)。 */
283
+ function quarantineLockPath(base: string): string {
284
+ return join(transactionRoot(base), '.quarantine');
285
+ }
286
+
287
+ /**
288
+ * 把无法前滚的事务清单挪进 `.transactions/quarantine-<ts>/`,让读路径继续可用。
289
+ *
290
+ * ⚠️ 这是**读路径**独有的策略;写路径(replaceMemoryBatchVersioned)仍然照常抛错——
291
+ * 写入方必须看到冲突,不能替它把别人已提交的事务悄悄扔掉。
292
+ *
293
+ * ⚠️ **必须持 `.quarantine` 锁调用**(见 quarantineTransaction)。它是
294
+ * `mkdir + rename + unlink + 写元数据` 的多步变更,无锁并发会各建一个隔离目录。
295
+ */
296
+ async function quarantineTransactionLocked(base: string, reason: string): Promise<void> {
297
+ const manifestPath = transactionManifestPath(base);
298
+ const manifestRaw = await readFile(manifestPath, 'utf8').catch(() => null);
299
+ // 拿到锁后清单已经不在 = 另一个读者/进程刚刚隔离过同一份。直接返回:
300
+ // 不重复建目录、不重复告警。这一条取代了以前那个只覆盖 ENOENT 交错的特判。
301
+ if (manifestRaw === null) return;
302
+
303
+ const target = join(
304
+ transactionRoot(base),
305
+ `quarantine-${Date.now()}-${randomUUID().slice(0, 8)}`,
306
+ );
307
+ await mkdir(target, { recursive: true, mode: 0o700 });
308
+ let claimed = true;
309
+ try {
310
+ await rename(manifestPath, join(target, 'current.json'));
311
+ } catch {
312
+ // 跨卷或某些网络盘上 rename 可能失败。必须先保存并校验副本,再移除原清单;
313
+ // 不能为了让读路径继续就丢掉唯一的恢复证据。
314
+ const preserved = join(target, 'current.json');
315
+ await atomicWriteFile(preserved, manifestRaw).catch(() => undefined);
316
+ const verified = await readFile(preserved, 'utf8')
317
+ .then((value) => sha256(value) === sha256(manifestRaw))
318
+ .catch(() => false);
319
+ claimed = verified
320
+ ? await unlink(manifestPath).then(() => true).catch(() => false)
321
+ : false;
322
+ }
323
+ if (!claimed) {
324
+ // 证据没保住就别留下空目录——它会被 quarantineDiagnosticsFor 当成一次真实隔离,
325
+ // 报出「已隔离到 XXX」却什么都恢复不了。
326
+ await rm(target, { recursive: true, force: true }).catch(() => undefined);
327
+ return;
328
+ }
329
+ const metadata: QuarantineMetadata = {
330
+ schemaVersion: 1,
331
+ quarantinedAt: Date.now(),
332
+ reason,
333
+ originalManifestPath: manifestPath,
334
+ };
335
+ await atomicWriteFile(join(target, 'diagnostic.json'), `${JSON.stringify(metadata, null, 2)}\n`)
336
+ .catch(() => undefined);
337
+ }
338
+
339
+ /**
340
+ * 加锁版入口。用**独立**的 `.quarantine` 锁而不是 `.records`:
341
+ * 需要互斥的只有「另一个隔离尝试」——合法的在飞事务其清单由 atomicWriteFile 写出,
342
+ * 永远是完整可解析的,不会被误隔离,所以无需与记录写入互斥。
343
+ * 而且 `memoryService.remember()` 是**持着 `.records` 锁**调用 scanMemoryScope 的,
344
+ * 这里若复用 `.records` 会自锁(文件锁不可重入)直接等到超时。
345
+ */
346
+ async function quarantineTransaction(base: string, reason: string): Promise<void> {
347
+ const release = await acquireFileLock(quarantineLockPath(base));
348
+ try {
349
+ await quarantineTransactionLocked(base, reason);
350
+ } finally {
351
+ await release();
352
+ }
353
+ }
354
+
355
+ async function recoverScopeTransaction(base: string): Promise<ScopeRecovery> {
356
+ let manifestExists: boolean;
357
+ try {
358
+ manifestExists = (await readTransactionManifest(base)) !== null;
359
+ } catch (error) {
360
+ const reason = (error as Error).message;
361
+ if (!TRANSACTION_POISON_ERRORS.has(reason)) throw error;
362
+ // 清单读不出来(结构损坏 / JSON 语法错 / 路径越界)→ 隔离。
363
+ // ⚠️ 这里曾写着「锁都不必拿,直接隔离」并真的裸调 —— 那是错的:隔离是多步文件变更,
364
+ // 两个并发读者(典型触发者:一次 remember() 后台刷新视图的扫描 + 用户这次读)
365
+ // 会各建一个隔离目录,其中一个还留不下 current.json,却照样报「已隔离到 XXX」。
366
+ // 同一函数在下面 roll-forward 失败处本来就是持锁调用的,这个不对称就是缺陷本身。
367
+ await quarantineTransaction(base, reason);
368
+ return { recovered: true };
369
+ }
370
+ if (!manifestExists) return { recovered: false };
143
371
  const release = await acquireFileLock(join(base, '.records'));
144
372
  try {
145
373
  await rollForwardTransactionUnlocked(base);
374
+ } catch (error) {
375
+ const reason = (error as Error).message;
376
+ if (!TRANSACTION_POISON_ERRORS.has(reason)) throw error;
377
+ await quarantineTransaction(base, reason);
378
+ return { recovered: true };
146
379
  } finally {
147
380
  await release();
148
381
  }
149
- return true;
382
+ return { recovered: true };
150
383
  }
151
384
 
152
385
  function scopeKey(scope: MemoryScope, workspaceId?: string): string {
153
386
  return scopeDirectory(scope, workspaceId);
154
387
  }
155
388
 
156
- async function withTopicView(
157
- snapshot: MemorySnapshot,
389
+ /**
390
+ * 重建 `views/topics.md`(人类可读视图,不是权威数据)。
391
+ *
392
+ * 只在两种时刻调用:**全量扫描之后**,以及**记忆变更之后**。读路径不再触发它——
393
+ * 否则每次 recall / 建上下文包都要 readFile 一次,连「缓存命中」都带磁盘 IO,
394
+ * 而且 read-only 会话也会写盘。
395
+ */
396
+ export async function refreshTopicView(
158
397
  scope: MemoryScope,
159
398
  workspaceId?: string,
160
- ): Promise<MemorySnapshot> {
161
- await ensureTopicView(snapshot, scope, workspaceId).catch(() => undefined);
162
- return snapshot;
399
+ ): Promise<void> {
400
+ const snapshot = await scanMemoryScope(scope, workspaceId);
401
+ await ensureTopicView(snapshot, scope, workspaceId);
402
+ }
403
+
404
+ function currentEpoch(key: string): number {
405
+ return scopeState(key).epoch;
163
406
  }
164
407
 
165
408
  function invalidateScope(scope: MemoryScope, workspaceId?: string): void {
166
- const cached = scopeCache.get(scopeKey(scope, workspaceId));
167
- if (cached) {
168
- cached.dirty = true;
409
+ const key = scopeKey(scope, workspaceId);
410
+ const state = scopeState(key);
411
+ state.epoch += 1;
412
+ if (state.cache && !state.cache.dirty) {
413
+ state.cache.dirty = true;
169
414
  repositoryDiagnostics.watchInvalidations += 1;
170
415
  }
171
416
  }
172
417
 
418
+ function closeScopeWatchers(state: ScopeRuntimeState): void {
419
+ for (const watcher of state.watchers) watcher.close();
420
+ state.watchers = [];
421
+ state.watcherComplete = false;
422
+ }
423
+
173
424
  function ensureScopeWatcher(
174
425
  base: string,
175
426
  scope: MemoryScope,
176
427
  workspaceId?: string,
177
428
  ): boolean {
178
429
  const key = scopeKey(scope, workspaceId);
179
- if (scopeWatchers.has(key)) return true;
430
+ const state = scopeState(key);
431
+ if (state.watchers.length && !state.watcherRefreshRequired) return state.watcherComplete;
432
+ closeScopeWatchers(state);
433
+ state.watcherRefreshRequired = false;
180
434
  try {
181
435
  const watchers: FSWatcher[] = [];
182
- const watchPath = (path: string): void => {
183
- const watcher = watch(path, () => invalidateScope(scope, workspaceId));
436
+ const rootWatcher = watch(base, (event, filename) => {
437
+ if (typeof filename !== 'string') {
438
+ // 根目录的无名事件无法判定来源,不直接打脏;让下次读立即做轻量审计。
439
+ if (state.cache) state.cache.lastAuditAt = 0;
440
+ return;
441
+ }
442
+ if (GENERATED_SCOPE_ENTRIES.has(filename)) return;
443
+ if (event !== 'rename' || !CATEGORY_SCOPE_ENTRIES.has(filename)) return;
444
+ state.watcherRefreshRequired = true;
445
+ invalidateScope(scope, workspaceId);
446
+ });
447
+ rootWatcher.unref();
448
+ watchers.push(rootWatcher);
449
+
450
+ const watchCategory = (path: string): void => {
451
+ const watcher = watch(path, (_event, filename) => {
452
+ if (typeof filename === 'string' && !filename.endsWith('.md')) return;
453
+ if (typeof filename === 'string' && consumeInternalRecordEvent(join(path, filename))) return;
454
+ // 全量扫描刚结束时,部分平台会延迟送达「已被该快照包含」的旧事件。
455
+ // 这个短窗口内不盲目打脏,而是强制下次读做版本/目录审计;因此真实外部
456
+ // 修改仍会被发现,而迟到的旧事件不会导致无意义全量重扫。
457
+ if (Date.now() < state.suppressDirtyUntil) {
458
+ if (state.cache) state.cache.lastAuditAt = 0;
459
+ return;
460
+ }
461
+ invalidateScope(scope, workspaceId);
462
+ });
184
463
  watcher.unref();
185
464
  watchers.push(watcher);
186
465
  };
187
- watchPath(base);
466
+ // ★ 覆盖必须**完整**才敢自称 watcherActive,否则读路径不能享受 1 秒信任窗(见下)。
467
+ // 实测(Windows,`fs.watch` 非递归):手工编辑 `<base>/profile/x.md` 只触发**分类目录**
468
+ // 上的 watcher(`change x.md`),base 上的 watcher 一声不吭——所以分类目录的 watcher
469
+ // 是「改动能不能被发现」的唯一依据。
470
+ // ⚠️ 「不存在的分类目录」不算覆盖缺口:它装不下任何记录,而且它**被创建**时 base 上的
471
+ // watcher 会收到 `rename <category>`(同样实测过),照样能作废缓存。只有目录确实存在
472
+ // 却挂不上 watcher(句柄耗尽、权限、网络盘…)才是真缺口,那时必须每次读都做审计。
473
+ let complete = true;
188
474
  for (const category of ALL_CATEGORIES) {
189
- try { watchPath(join(base, CATEGORY_DIR[category])); } catch { /* absent category */ }
475
+ try {
476
+ watchCategory(join(base, CATEGORY_DIR[category]));
477
+ } catch (error) {
478
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') complete = false;
479
+ }
190
480
  }
191
481
  const fallback = () => {
192
- for (const watcher of watchers) watcher.close();
193
- scopeWatchers.delete(key);
194
- const cached = scopeCache.get(key);
195
- if (cached) watcherActiveFallback(cached);
482
+ closeScopeWatchers(state);
483
+ state.watcherRefreshRequired = true;
484
+ if (state.cache) watcherActiveFallback(state.cache);
196
485
  };
197
486
  for (const watcher of watchers) watcher.on('error', fallback);
198
- scopeWatchers.set(key, watchers);
199
- return true;
487
+ state.watchers = watchers;
488
+ state.watcherComplete = complete;
489
+ return complete;
200
490
  } catch {
491
+ closeScopeWatchers(state);
201
492
  return false;
202
493
  }
203
494
  }
204
495
 
205
- function watcherActiveFallback(cached: CachedScope): void {
206
- cached.watcherActive = false;
496
+ function watcherActiveFallback(cached: ScopeCacheEntry): void {
207
497
  cached.dirty = true;
208
498
  }
209
499
 
210
500
  function evictScopeCaches(): void {
211
- if (scopeCache.size <= MAX_SCOPE_CACHE_ENTRIES) return;
212
- const victims = [...scopeCache.entries()]
501
+ if (scopeStates.size <= MAX_SCOPE_CACHE_ENTRIES) return;
502
+ const victims = [...scopeStates.entries()]
213
503
  .sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt)
214
- .slice(0, scopeCache.size - MAX_SCOPE_CACHE_ENTRIES);
504
+ .slice(0, scopeStates.size - MAX_SCOPE_CACHE_ENTRIES);
215
505
  for (const [key] of victims) {
216
- scopeCache.delete(key);
217
- for (const watcher of scopeWatchers.get(key) ?? []) watcher.close();
218
- scopeWatchers.delete(key);
506
+ const state = scopeStates.get(key);
507
+ if (state) closeScopeWatchers(state);
508
+ scopeStates.delete(key);
509
+ // ⚠️ 不能写 `path.startsWith(`${key}/`)`:Windows 上分隔符是 `\`,该条件恒为 false,
510
+ // 被淘汰 scope 的文件缓存永远清不掉 → fileCache 无上限增长。
219
511
  for (const path of fileCache.keys()) {
220
- if (path.startsWith(`${key}/`)) fileCache.delete(path);
512
+ if (pathContains(key, path)) fileCache.delete(path);
221
513
  }
222
514
  }
223
515
  }
224
516
 
517
+ async function settleRecentWatcherEvents(state: ScopeRuntimeState): Promise<boolean> {
518
+ await new Promise<void>((resolvePromise) => { setImmediate(resolvePromise); });
519
+ if (state.cache?.dirty) return false;
520
+ const now = Date.now();
521
+ if (now - state.lastDrainAt >= WATCHER_SETTLE_REUSE_MS) {
522
+ await new Promise<void>((resolvePromise) => {
523
+ setTimeout(resolvePromise, WATCHER_SETTLE_WAIT_MS);
524
+ });
525
+ state.lastDrainAt = Date.now();
526
+ }
527
+ // 正常 watcher 在上面的 settle 窗口中会直接打脏;扫描结束后的抑制窗口只把
528
+ // lastAuditAt 清零,故仅这一种情况需要逐文件核验。稳定缓存不做 stat 扫描。
529
+ return state.cache?.lastAuditAt === 0 || Date.now() < state.suppressDirtyUntil;
530
+ }
531
+
225
532
  async function cachedFilesUnchanged(snapshot: MemorySnapshot): Promise<boolean> {
226
533
  const checks = await Promise.all(snapshot.records.map(async (record) => {
227
534
  const path = memoryRecordPath(record);
@@ -282,50 +589,82 @@ async function readCached(path: string): Promise<CachedFile> {
282
589
  export async function scanMemoryScope(
283
590
  scope: MemoryScope,
284
591
  workspaceId?: string,
592
+ attempt = 0,
285
593
  ): Promise<MemorySnapshot> {
286
594
  const base = scopeDirectory(scope, workspaceId);
287
595
  const key = scopeKey(scope, workspaceId);
288
- const recovered = await recoverScopeTransaction(base);
289
- let cachedScope = scopeCache.get(key);
290
- if (recovered && cachedScope) cachedScope.dirty = true;
596
+ const state = scopeState(key);
597
+ const recovery = await recoverScopeTransaction(base);
598
+ let cachedScope = state.cache;
599
+ if (recovery.recovered && cachedScope) cachedScope.dirty = true;
291
600
  const generationBefore = await readGeneration(base);
292
601
  const now = Date.now();
293
- // A hand edit may complete immediately before this call while fs.watch has
294
- // already queued (but not dispatched) its callback. Yield one event-loop turn
295
- // so the watcher can invalidate without falling back to O(record-count) stats.
296
- if (
297
- cachedScope?.watcherActive
298
- && !cachedScope.dirty
299
- && now - cachedScope.lastUsedAt < 50
300
- ) {
301
- await new Promise<void>((resolvePromise) => setTimeout(resolvePromise, 20));
302
- cachedScope = scopeCache.get(key);
303
- }
602
+ // ★ 读路径保持纯读:缓存命中直接返回快照,不再顺手 readFile / 写 views/topics.md。
603
+ // 旧实现把 ensureTopicView 挂在**所有**返回分支上,于是「缓存命中」仍有磁盘 IO,
604
+ // 而且 memoryAccess:'read-only' 的会话也会写盘——仓储的读 API 不该有副作用。
605
+ // 视图改由「全量扫描后」与「变更后」触发(见 refreshTopicView)。
606
+ // ★ 新鲜度两条腿,缺一不可:
607
+ //
608
+ // ① **排空一次事件循环**再判 dirty。`fs.watch` 的回调是排队派发的,「watcher 已挂上」
609
+ // 不等于「它已经把刚才那次改动告诉我了」。实测(Windows):改完文件后只要让出
610
+ // 一个 setImmediate tick,事件就已送达。这一步零 IO,却把「刚改完立刻读」这个
611
+ // 最常见的竞态消掉了。旧代码也想干这事,但把它锁在「距上次使用 <50ms」的条件里,
612
+ // 两次读之间只要多做点别的(哪怕只是写一次设置)就绕过去了。
613
+ //
614
+ // ② watcher 覆盖不全或压根没触发时,用 stat 审计兜底,节流到每秒最多一次。
615
+ // 审计是 O(记录数) 次 stat——实测 300 条记录约 40ms,放在每次读上太贵
616
+ // (每回合 recall + buildContextBundle 各一次),所以必须节流;但 30 秒的信任窗
617
+ // 又太松,手编要等半分钟才生效。1 秒是两边都能接受的点。
618
+ //
619
+ // ⚠️ 别再让读路径去写 `views/topics.md` 来「顺便」作废缓存——那是以前手编能被发现的
620
+ // 真实原因,纯属巧合,代价是每次读都做磁盘 IO。
621
+ let verifyAfterSettle = false;
304
622
  if (cachedScope && !cachedScope.dirty) {
305
- cachedScope.lastUsedAt = now;
306
- if (cachedScope.watcherActive && now - cachedScope.lastAuditAt < SCOPE_WATCH_AUDIT_MS) {
307
- repositoryDiagnostics.snapshotHits += 1;
308
- return withTopicView(cachedScope.snapshot, scope, workspaceId);
623
+ verifyAfterSettle = await settleRecentWatcherEvents(state);
624
+ cachedScope = state.cache;
625
+ }
626
+ if (cachedScope && !cachedScope.dirty && verifyAfterSettle) {
627
+ repositoryDiagnostics.fallbackAudits += 1;
628
+ if (
629
+ !await cachedFilesUnchanged(cachedScope.snapshot)
630
+ || cachedScope.directoryFingerprint !== await directoryFingerprint(base)
631
+ ) {
632
+ cachedScope.dirty = true;
633
+ } else {
634
+ cachedScope.lastAuditAt = Date.now();
309
635
  }
310
- if (!cachedScope.watcherActive && now - cachedScope.checkedAt < SCOPE_FALLBACK_TTL_MS) {
636
+ }
637
+ if (cachedScope && !cachedScope.dirty) {
638
+ state.lastUsedAt = now;
639
+ // 只有 watcher 覆盖完整时才敢跳过审计——覆盖有缺口就等于没人会告诉我们变更,
640
+ // 那个 1 秒信任窗就失去依据,必须每次读都审计。
641
+ if (state.watcherComplete && now - cachedScope.lastAuditAt < SCOPE_AUDIT_TTL_MS) {
311
642
  repositoryDiagnostics.snapshotHits += 1;
312
- return withTopicView(cachedScope.snapshot, scope, workspaceId);
643
+ return cachedScope.snapshot;
313
644
  }
314
645
  repositoryDiagnostics.fallbackAudits += 1;
315
646
  if (
316
647
  await cachedFilesUnchanged(cachedScope.snapshot)
317
648
  && cachedScope.directoryFingerprint === await directoryFingerprint(base)
318
649
  ) {
319
- cachedScope.checkedAt = now;
320
650
  cachedScope.lastAuditAt = now;
321
651
  repositoryDiagnostics.snapshotHits += 1;
322
- return withTopicView(cachedScope.snapshot, scope, workspaceId);
652
+ return cachedScope.snapshot;
323
653
  }
324
654
  cachedScope.dirty = true;
325
655
  }
326
656
  repositoryDiagnostics.fullScans += 1;
657
+ // ★ 先挂 watcher 再读文件,并记下代际号。
658
+ // 旧顺序是「先把记录全读完,最后才挂 watcher」,中间那段窗口里发生的改动**两头都漏**:
659
+ // 读的时候还没改,watcher 又还没挂上——扫完却把这份已经过时的快照标记为干净缓存,
660
+ // 于是直到下一次兜底审计(现在是 1 秒)之前,谁都发现不了。这是原设计里的 TOCTOU。
661
+ // 现在:先挂 watcher,扫完再比对代际号,期间有任何改动就重扫。
662
+ const watcherActive = ensureScopeWatcher(base, scope, workspaceId);
663
+ const epochBefore = currentEpoch(key);
327
664
  const records: MemoryRecordV2[] = [];
328
- const diagnostics: MemoryDiagnostic[] = [];
665
+ // 隔离事务留下的告警要跟着本次快照走完,不能只写日志然后消失——
666
+ // diagnostics 是既有的、Desktop 与 diagnosticSuffix() 都会展示的通道。
667
+ const diagnostics: MemoryDiagnostic[] = await quarantineDiagnosticsFor(base);
329
668
  const versions: Record<string, MemoryVersion> = {};
330
669
  const seen = new Set<string>();
331
670
  for (const category of ALL_CATEGORIES) {
@@ -369,35 +708,45 @@ export async function scanMemoryScope(
369
708
  }
370
709
  }
371
710
 
711
+ // 同上:硬编码 `/` 在 Windows 上永不命中,已删除记录的缓存条目会永久滞留。
372
712
  for (const path of fileCache.keys()) {
373
- if (
374
- path.startsWith(`${base}/`) &&
375
- path.endsWith('.md') &&
376
- !seen.has(path)
377
- ) {
713
+ if (path.endsWith('.md') && !seen.has(path) && pathContains(base, path)) {
378
714
  fileCache.delete(path);
379
715
  }
380
716
  }
381
717
  records.sort((a, b) => b.updatedAt - a.updatedAt);
382
718
  const snapshot = { records, versions, diagnostics, createdAt: Date.now() };
383
- if (await recoverScopeTransaction(base) || generationBefore !== await readGeneration(base)) {
719
+ const tailRecovery = await recoverScopeTransaction(base);
720
+ if (
721
+ (tailRecovery.recovered || generationBefore !== await readGeneration(base))
722
+ && attempt < MAX_SCAN_RETRIES
723
+ ) {
384
724
  invalidateScope(scope, workspaceId);
385
- return scanMemoryScope(scope, workspaceId);
725
+ return scanMemoryScope(scope, workspaceId, attempt + 1);
386
726
  }
387
- for (const watcher of scopeWatchers.get(key) ?? []) watcher.close();
388
- scopeWatchers.delete(key);
389
- const watcherActive = ensureScopeWatcher(base, scope, workspaceId);
390
- scopeCache.set(key, {
727
+ // 让排队中的 watcher 回调先送达,再判断这次扫描期间有没有人改过东西。
728
+ await new Promise<void>((resolvePromise) => { setImmediate(resolvePromise); });
729
+ // ⚠️ 重试必须有上限:持续写入的场景(cron 批量、另一个宿主导入、迁移)下每次重扫都会
730
+ // 看到新的代际号,无上限就是**活锁——读永远不返回**(还每轮重开 7 个 watcher)。
731
+ // 撞顶就照常缓存,让 1 秒兜底审计去纠正:最坏是几毫秒的陈旧快照且会自愈,
732
+ // 远好过一次挂死的 recall。
733
+ const changedDuringScan = currentEpoch(key) !== epochBefore;
734
+ if (changedDuringScan && attempt < MAX_SCAN_RETRIES) {
735
+ return scanMemoryScope(scope, workspaceId, attempt + 1);
736
+ }
737
+ state.cache = {
391
738
  snapshot,
392
- checkedAt: Date.now(),
393
739
  dirty: false,
394
- watcherActive,
395
740
  directoryFingerprint: await directoryFingerprint(base),
396
- lastAuditAt: Date.now(),
397
- lastUsedAt: Date.now(),
398
- });
741
+ // 撞了重试上限才落库的快照是**已知可能陈旧**的,不能再送它一个完整的 1 秒信任窗——
742
+ // 把审计时间戳清零,下一次读立刻走 stat 审计纠正。
743
+ lastAuditAt: changedDuringScan ? 0 : Date.now(),
744
+ };
745
+ state.watcherComplete = watcherActive;
746
+ state.lastUsedAt = Date.now();
747
+ state.suppressDirtyUntil = Date.now() + 100;
399
748
  evictScopeCaches();
400
- return withTopicView(snapshot, scope, workspaceId);
749
+ return snapshot;
401
750
  }
402
751
 
403
752
  export async function readVersionedMemory(
@@ -415,6 +764,7 @@ export async function writeNewMemory(
415
764
  ): Promise<MemoryVersion> {
416
765
  const path = memoryRecordPath(record);
417
766
  const content = serializeMemoryRecord(record);
767
+ markInternalRecordEvent(path);
418
768
  await atomicWriteFile(path, content);
419
769
  fileCache.delete(path);
420
770
  invalidateScope(record.scope, record.workspaceId);
@@ -521,6 +871,7 @@ export async function removeVersionedMemory(
521
871
  throw new Error('MEMORY_VERSION_CONFLICT');
522
872
  }
523
873
  try {
874
+ markInternalRecordEvent(path);
524
875
  await unlink(path);
525
876
  fileCache.delete(path);
526
877
  invalidateScope(current.record.scope, current.record.workspaceId);
@@ -536,7 +887,7 @@ export async function removeVersionedMemory(
536
887
 
537
888
  export function clearMemoryIndex(): void {
538
889
  fileCache.clear();
539
- scopeCache.clear();
540
- for (const watchers of scopeWatchers.values()) for (const watcher of watchers) watcher.close();
541
- scopeWatchers.clear();
890
+ internalRecordEvents.clear();
891
+ for (const state of scopeStates.values()) closeScopeWatchers(state);
892
+ scopeStates.clear();
542
893
  }