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,10 +1,11 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { open, readFile, readdir, stat } from 'node:fs/promises';
2
+ import { open, readFile, readdir, stat, unlink } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
4
 
5
5
  import { agentStateDir } from '../context/agentHome.js';
6
6
  import { containsForbiddenSecret, sha256 } from './codec.js';
7
7
  import {
8
+ acquireFileLock,
8
9
  appendPrivateLine,
9
10
  atomicWriteFile,
10
11
  ensurePrivateDir,
@@ -29,6 +30,16 @@ const MAX_EPISODE_CHARS = 8_000;
29
30
  const MAX_SEARCH_FILES = 200;
30
31
  const MAX_SEARCH_LINES = 20_000;
31
32
  const MAX_SNIPPET_CHARS = 500;
33
+ /**
34
+ * 已解析 episode 常驻内存的上限。
35
+ *
36
+ * ⚠️ `episodeFileCache` 缓存的是**整个文件解析出的全部 episode**,而日志本身是只增不减的
37
+ * (每条 user/assistant 消息 ≤8000 字符)。没有上限时,Desktop 这种长驻进程搜一次历史就把
38
+ * 几十 MB 对话原文永久钉在堆上。按 episode 条数做 LRU,逐出最久未用的文件。
39
+ */
40
+ const MAX_CACHED_EPISODES = 20_000;
41
+ /** 两次保留清理之间的最小间隔——清理是尽力而为的后台动作,不该每条消息都扫盘。 */
42
+ const RETENTION_SWEEP_INTERVAL_MS = 5 * 60_000;
32
43
 
33
44
  interface CachedEpisodeFile {
34
45
  ino: number;
@@ -36,6 +47,8 @@ interface CachedEpisodeFile {
36
47
  mtimeMs: number;
37
48
  trailing: string;
38
49
  episodes: ConversationEpisode[];
50
+ /** 单调递增的使用序号,用于 LRU 逐出(不用时间戳,避免同毫秒并列)。 */
51
+ usedAt: number;
39
52
  }
40
53
 
41
54
  interface CachedEpisodeIndex {
@@ -50,6 +63,8 @@ const episodeDiagnostics = {
50
63
  fullFileReads: 0,
51
64
  incrementalFileReads: 0,
52
65
  unchangedFileHits: 0,
66
+ cacheEvictions: 0,
67
+ retentionSweeps: 0,
53
68
  };
54
69
 
55
70
  export function episodeSearchDiagnostics(): Readonly<typeof episodeDiagnostics> {
@@ -60,8 +75,40 @@ export function resetEpisodeSearchDiagnostics(): void {
60
75
  episodeDiagnostics.fullFileReads = 0;
61
76
  episodeDiagnostics.incrementalFileReads = 0;
62
77
  episodeDiagnostics.unchangedFileHits = 0;
78
+ episodeDiagnostics.cacheEvictions = 0;
79
+ episodeDiagnostics.retentionSweeps = 0;
63
80
  episodeFileCache.clear();
64
81
  episodeIndexCache.clear();
82
+ cacheClock = 0;
83
+ retentionSweepStates.clear();
84
+ }
85
+
86
+ let cacheClock = 0;
87
+ interface RetentionSweepState {
88
+ lastStartedAt: number;
89
+ lastUsedAt: number;
90
+ inFlight: boolean;
91
+ }
92
+ const retentionSweepStates = new Map<string, RetentionSweepState>();
93
+ const MAX_RETENTION_SWEEP_STATES = 128;
94
+
95
+ function cachedEpisodeCount(): number {
96
+ let total = 0;
97
+ for (const entry of episodeFileCache.values()) total += entry.episodes.length;
98
+ return total;
99
+ }
100
+
101
+ /** 超出 MAX_CACHED_EPISODES 就按最久未用逐出整份文件缓存,直到回到上限之内。 */
102
+ function evictEpisodeCache(): void {
103
+ if (cachedEpisodeCount() <= MAX_CACHED_EPISODES) return;
104
+ const byAge = [...episodeFileCache.entries()].sort((a, b) => a[1].usedAt - b[1].usedAt);
105
+ for (const [path] of byAge) {
106
+ if (cachedEpisodeCount() <= MAX_CACHED_EPISODES) break;
107
+ episodeFileCache.delete(path);
108
+ episodeDiagnostics.cacheEvictions += 1;
109
+ }
110
+ // 文件缓存变了,基于它建的 BM25 语料签名也就失效了。
111
+ episodeIndexCache.clear();
65
112
  }
66
113
 
67
114
  function episodeRoot(): string {
@@ -89,6 +136,10 @@ function backfillMarkerPath(workspaceId: string): string {
89
136
  return join(episodeRoot(), `.backfill-${validPart(workspaceId)}.json`);
90
137
  }
91
138
 
139
+ function workspaceJournalLockPath(workspaceId: string): string {
140
+ return join(episodeRoot(), `.workspace-${validPart(workspaceId)}.journal`);
141
+ }
142
+
92
143
  export function sanitizeEpisodeContent(content: string): string | null {
93
144
  const normalized = content
94
145
  .replace(/data:[^;\s]+;base64,[A-Za-z0-9+/=]{80,}/g, '[binary omitted]')
@@ -130,6 +181,243 @@ export async function appendConversationEpisode(input: {
130
181
  return episode;
131
182
  }
132
183
 
184
+ /** 删除某工作区(缺省为全部)的会话原文日志。供 Desktop「清空记忆」一并调用。 */
185
+ export async function clearConversationEpisodes(workspaceId?: string): Promise<number> {
186
+ const release = workspaceId
187
+ ? await acquireFileLock(workspaceJournalLockPath(workspaceId))
188
+ : null;
189
+ try {
190
+ return await clearConversationEpisodesUnlocked(workspaceId);
191
+ } finally {
192
+ await release?.();
193
+ }
194
+ }
195
+
196
+ async function clearConversationEpisodesUnlocked(workspaceId?: string): Promise<number> {
197
+ const files = await episodeFiles(workspaceId).catch(() => []);
198
+ let removed = 0;
199
+ for (const path of files) {
200
+ const result = await mutateEpisodeFile(path, () => ({ kind: 'delete' })).catch(() => null);
201
+ if (result?.changed) removed += 1;
202
+ }
203
+ // ⚠️ backfill 标记必须**留下(没有就补写)**,绝不能删。
204
+ // backfillLegacyHistory 会从 `sessions/*.json` 一次性导入旧会话;标记一旦消失,
205
+ // 下一次 SearchConversations 就会把刚刚清掉的对话原文整批重新导回来 —— 用户点了
206
+ // "永久清空",转头又能搜到,等于清空是假的。
207
+ if (workspaceId) {
208
+ await atomicWriteFile(
209
+ backfillMarkerPath(workspaceId),
210
+ `${JSON.stringify(
211
+ { version: 1, workspaceId, sources: 0, completedAt: Date.now(), reason: 'cleared-by-user' },
212
+ null,
213
+ 2,
214
+ )}\n`,
215
+ ).catch(() => undefined);
216
+ }
217
+ episodeIndexCache.clear();
218
+ return removed;
219
+ }
220
+
221
+ interface StableEpisodeFile {
222
+ raw: string;
223
+ ino: number;
224
+ size: number;
225
+ mtimeMs: number;
226
+ }
227
+
228
+ type EpisodeFileMutation =
229
+ | { kind: 'unchanged' }
230
+ | { kind: 'write'; content: string }
231
+ | { kind: 'delete' };
232
+
233
+ interface EpisodeFileMutationResult {
234
+ changed: boolean;
235
+ bytes: number;
236
+ }
237
+
238
+ function sameEpisodeFile(
239
+ left: Pick<StableEpisodeFile, 'ino' | 'size' | 'mtimeMs'>,
240
+ right: Pick<StableEpisodeFile, 'ino' | 'size' | 'mtimeMs'>,
241
+ ): boolean {
242
+ return left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
243
+ }
244
+
245
+ async function readStableEpisodeFile(path: string): Promise<StableEpisodeFile | null> {
246
+ for (let attempt = 0; attempt < 2; attempt += 1) {
247
+ const before = await stat(path).catch((error) => {
248
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
249
+ throw error;
250
+ });
251
+ if (!before) return null;
252
+ const raw = await readFile(path, 'utf8');
253
+ const after = await stat(path).catch(() => null);
254
+ if (
255
+ after
256
+ && before.ino === after.ino
257
+ && before.size === after.size
258
+ && before.mtimeMs === after.mtimeMs
259
+ && Buffer.byteLength(raw, 'utf8') === after.size
260
+ ) {
261
+ return { raw, ino: after.ino, size: after.size, mtimeMs: after.mtimeMs };
262
+ }
263
+ }
264
+ return null;
265
+ }
266
+
267
+ /**
268
+ * 与 appendPrivateLine 共用同一把 `<episode>.lock`。大文件的解析在锁外完成,
269
+ * 获锁后只验证文件代际并提交;如果这期间有追加,就重读重算,绝不覆盖新行。
270
+ */
271
+ async function mutateEpisodeFile(
272
+ path: string,
273
+ plan: (raw: string) => EpisodeFileMutation,
274
+ ): Promise<EpisodeFileMutationResult> {
275
+ for (let attempt = 0; attempt < 3; attempt += 1) {
276
+ const source = await readStableEpisodeFile(path);
277
+ if (!source) return { changed: false, bytes: 0 };
278
+ const mutation = plan(source.raw);
279
+ if (mutation.kind === 'unchanged') return { changed: false, bytes: source.size };
280
+ const release = await acquireFileLock(path);
281
+ try {
282
+ const live = await stat(path).catch(() => null);
283
+ if (!live || !sameEpisodeFile(source, live)) continue;
284
+ if (mutation.kind === 'delete') await unlink(path);
285
+ else await atomicWriteFile(path, mutation.content);
286
+ episodeFileCache.delete(path);
287
+ episodeIndexCache.clear();
288
+ return {
289
+ changed: true,
290
+ bytes: mutation.kind === 'write' ? Buffer.byteLength(mutation.content, 'utf8') : 0,
291
+ };
292
+ } finally {
293
+ await release();
294
+ }
295
+ }
296
+ // 文件持续忙时跳过本轮维护;下次保留扫描会再处理。
297
+ const current = await stat(path).catch(() => null);
298
+ return { changed: false, bytes: current?.size ?? 0 };
299
+ }
300
+
301
+ /** 列出某工作区(或全部)的会话原文日志文件。 */
302
+ async function episodeFiles(workspaceId?: string): Promise<string[]> {
303
+ const prefix = workspaceId ? `workspace-${validPart(workspaceId)}--` : 'workspace-';
304
+ try {
305
+ return (await readdir(episodeRoot(), { withFileTypes: true }))
306
+ .filter((entry) =>
307
+ entry.isFile() && entry.name.endsWith('.jsonl') && entry.name.startsWith(prefix))
308
+ .map((entry) => join(episodeRoot(), entry.name));
309
+ } catch (error) {
310
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
311
+ throw error;
312
+ }
313
+ }
314
+
315
+ /** 按龄清理一个文件;返回清理后的字节数(文件被清空则删除并返回 0)。 */
316
+ async function pruneFileByAge(path: string, cutoff: number): Promise<number> {
317
+ const result = await mutateEpisodeFile(path, (raw) => {
318
+ const lines = raw.split('\n').filter((line) => line.trim().length > 0);
319
+ const kept = lines.filter((line) => {
320
+ const episode = parseEpisode(line);
321
+ // 解析不出来的行按"保留"处理:清理是维护动作,不该顺手吞掉看不懂的数据。
322
+ return episode ? episode.createdAt >= cutoff : true;
323
+ });
324
+ if (kept.length === lines.length) return { kind: 'unchanged' };
325
+ if (kept.length === 0) return { kind: 'delete' };
326
+ return { kind: 'write', content: `${kept.join('\n')}\n` };
327
+ });
328
+ return result.bytes;
329
+ }
330
+
331
+ /**
332
+ * 会话原文保留策略:先按龄清理,仍超体积上限就从**最旧的文件**整份删起。
333
+ *
334
+ * 全程 best-effort:清理失败绝不能影响正在进行的对话(这是维护动作,不是功能)。
335
+ * 与 records 的删除语义严格区分——records 只有用户能删,这里删的是为检索而留的对话副本。
336
+ */
337
+ export async function applyEpisodeRetention(
338
+ workspaceId: string,
339
+ retention: { episodeDays: number; episodeMaxMegabytes: number },
340
+ now = Date.now(),
341
+ ): Promise<void> {
342
+ const files = await episodeFiles(workspaceId).catch(() => []);
343
+ if (!files.length) return;
344
+ episodeDiagnostics.retentionSweeps += 1;
345
+ const cutoff = now - retention.episodeDays * 86_400_000;
346
+ const sized: Array<{ path: string; bytes: number; mtimeMs: number }> = [];
347
+ for (const path of files) {
348
+ const bytes = await pruneFileByAge(path, cutoff).catch(() => -1);
349
+ if (bytes <= 0) continue;
350
+ const info = await stat(path).catch(() => null);
351
+ sized.push({ path, bytes, mtimeMs: info?.mtimeMs ?? 0 });
352
+ }
353
+ const limit = retention.episodeMaxMegabytes * 1024 * 1024;
354
+ let total = sized.reduce((sum, item) => sum + item.bytes, 0);
355
+ if (total <= limit) return;
356
+ // 从最旧的文件开始按**条目**裁剪,而不是整份删除:
357
+ // 一个工作区常常只有一个长期会话文件,整份删会让全部历史在某次追加后突然消失(悬崖),
358
+ // 而按条目裁剪的结果是"最旧的对话先被丢掉",与按龄清理的语义一致。
359
+ for (const item of sized.sort((a, b) => a.mtimeMs - b.mtimeMs)) {
360
+ if (total <= limit) break;
361
+ const keepBytes = Math.max(0, item.bytes - (total - limit));
362
+ const remaining = await trimFileToNewestBytes(item.path, keepBytes).catch(() => item.bytes);
363
+ total -= item.bytes - remaining;
364
+ }
365
+ episodeIndexCache.clear();
366
+ }
367
+
368
+ /** 只保留文件末尾(最新)能放进 keepBytes 的整行;keepBytes 为 0 时删除文件。返回剩余字节数。 */
369
+ async function trimFileToNewestBytes(path: string, keepBytes: number): Promise<number> {
370
+ const result = await mutateEpisodeFile(path, (raw) => {
371
+ if (keepBytes <= 0) return { kind: 'delete' };
372
+ const lines = raw.split('\n').filter((line) => line.trim().length > 0);
373
+ const kept: string[] = [];
374
+ let bytes = 0;
375
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
376
+ const size = Buffer.byteLength(`${lines[index]}\n`, 'utf8');
377
+ if (bytes + size > keepBytes) break;
378
+ kept.unshift(lines[index]!);
379
+ bytes += size;
380
+ }
381
+ if (!kept.length) return { kind: 'delete' };
382
+ if (kept.length === lines.length) return { kind: 'unchanged' };
383
+ return { kind: 'write', content: `${kept.join('\n')}\n` };
384
+ });
385
+ return result.bytes;
386
+ }
387
+
388
+ /**
389
+ * 是否该跑一次保留清理(同一进程内每 RETENTION_SWEEP_INTERVAL_MS 最多一次)。
390
+ *
391
+ * 本模块**不自己去读设置**:那需要 `import('./memoryService.js')`,而 memoryService 正好
392
+ * 静态 import 本模块,绕一圈会变成动态 import 的循环依赖——dev 下能跑,但 `bun --compile`
393
+ * 的绿色版打包期解析动态 import 的行为另算,不值得赌(见 GREEN-BUILD 相关教训)。
394
+ * 由知道设置的那一层(turnJournal)来问「该扫了吗」,本模块只保留纯粹的存储职责。
395
+ */
396
+ export function shouldSweepRetention(workspaceId: string, now = Date.now()): boolean {
397
+ const current = retentionSweepStates.get(workspaceId);
398
+ if (current?.inFlight || (current && now - current.lastStartedAt < RETENTION_SWEEP_INTERVAL_MS)) {
399
+ if (current) current.lastUsedAt = now;
400
+ return false;
401
+ }
402
+ retentionSweepStates.set(workspaceId, { lastStartedAt: now, lastUsedAt: now, inFlight: true });
403
+ if (retentionSweepStates.size > MAX_RETENTION_SWEEP_STATES) {
404
+ const victims = [...retentionSweepStates.entries()]
405
+ .filter(([, state]) => !state.inFlight)
406
+ .sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt)
407
+ .slice(0, retentionSweepStates.size - MAX_RETENTION_SWEEP_STATES);
408
+ for (const [key] of victims) retentionSweepStates.delete(key);
409
+ }
410
+ return true;
411
+ }
412
+
413
+ export function finishRetentionSweep(workspaceId: string): void {
414
+ const state = retentionSweepStates.get(workspaceId);
415
+ if (state) {
416
+ state.inFlight = false;
417
+ state.lastUsedAt = Date.now();
418
+ }
419
+ }
420
+
133
421
  function parseEpisode(line: string): ConversationEpisode | null {
134
422
  try {
135
423
  const value = JSON.parse(line) as Partial<ConversationEpisode>;
@@ -181,12 +469,14 @@ async function readEpisodeBytes(path: string, offset: number, length: number): P
181
469
  async function loadEpisodeFile(path: string): Promise<CachedEpisodeFile> {
182
470
  const info = await stat(path);
183
471
  const cached = episodeFileCache.get(path);
472
+ cacheClock += 1;
184
473
  if (
185
474
  cached
186
475
  && cached.ino === info.ino
187
476
  && cached.size === info.size
188
477
  && cached.mtimeMs === info.mtimeMs
189
478
  ) {
479
+ cached.usedAt = cacheClock;
190
480
  episodeDiagnostics.unchangedFileHits += 1;
191
481
  return cached;
192
482
  }
@@ -199,9 +489,11 @@ async function loadEpisodeFile(path: string): Promise<CachedEpisodeFile> {
199
489
  mtimeMs: info.mtimeMs,
200
490
  trailing: parsed.trailing,
201
491
  episodes: [...cached.episodes, ...parsed.episodes],
492
+ usedAt: cacheClock,
202
493
  };
203
494
  episodeFileCache.set(path, next);
204
495
  episodeDiagnostics.incrementalFileReads += 1;
496
+ evictEpisodeCache();
205
497
  return next;
206
498
  }
207
499
  const raw = await readFile(path, 'utf8');
@@ -212,9 +504,11 @@ async function loadEpisodeFile(path: string): Promise<CachedEpisodeFile> {
212
504
  mtimeMs: info.mtimeMs,
213
505
  trailing: parsed.trailing,
214
506
  episodes: parsed.episodes,
507
+ usedAt: cacheClock,
215
508
  };
216
509
  episodeFileCache.set(path, next);
217
510
  episodeDiagnostics.fullFileReads += 1;
511
+ evictEpisodeCache();
218
512
  return next;
219
513
  }
220
514
 
@@ -289,6 +583,15 @@ async function workspaceArchiveDirs(workspaceId: string): Promise<string[]> {
289
583
  }
290
584
 
291
585
  async function backfillLegacyHistory(workspaceId: string): Promise<void> {
586
+ const release = await acquireFileLock(workspaceJournalLockPath(workspaceId));
587
+ try {
588
+ await backfillLegacyHistoryUnlocked(workspaceId);
589
+ } finally {
590
+ await release();
591
+ }
592
+ }
593
+
594
+ async function backfillLegacyHistoryUnlocked(workspaceId: string): Promise<void> {
292
595
  const marker = backfillMarkerPath(workspaceId);
293
596
  if (await pathExists(marker)) return;
294
597
  const sources: Array<{ path: string; sessionId: string }> = [];
@@ -507,4 +810,6 @@ function conversationResult(
507
810
  export const episodicMemoryService = {
508
811
  append: appendConversationEpisode,
509
812
  search: searchConversationEpisodes,
813
+ clear: clearConversationEpisodes,
814
+ applyRetention: applyEpisodeRetention,
510
815
  };
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { chmod, mkdir, open, readFile, rename, rm, unlink, writeFile, } from 'node:fs/promises';
2
+ import { chmod, mkdir, open, readFile, rename, rm, stat, unlink, writeFile, } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { agentStateDir } from '../context/agentHome.js';
5
5
  import { CATEGORY_DIR, MEMORY_ID_PATTERN } from './constants.js';
@@ -67,8 +67,11 @@ export async function acquireFileLock(path) {
67
67
  throw error;
68
68
  const raw = await readFile(lock, 'utf8').catch(() => '');
69
69
  const createdAt = Number(raw.split(':')[1]);
70
- if (!Number.isFinite(createdAt) ||
71
- Date.now() - createdAt > LOCK_STALE_MS) {
70
+ const lockInfo = await stat(lock).catch(() => null);
71
+ if ((Number.isFinite(createdAt) && Date.now() - createdAt > LOCK_STALE_MS) ||
72
+ (!Number.isFinite(createdAt)
73
+ && lockInfo !== null
74
+ && Date.now() - lockInfo.mtimeMs > LOCK_STALE_MS)) {
72
75
  await unlink(lock).catch(() => { });
73
76
  continue;
74
77
  }
@@ -109,10 +112,12 @@ export function enqueueMemoryMutation(operation) {
109
112
  });
110
113
  return result;
111
114
  }
112
- export async function appendPrivateLine(path, line) {
115
+ export async function appendPrivateLine(path, line, options) {
113
116
  const release = await acquireFileLock(path);
114
117
  try {
115
118
  await ensurePrivateDir(dirname(path));
119
+ if (options?.maxBytes)
120
+ await rotateIfLarge(path, options.maxBytes);
116
121
  const handle = await open(path, 'a', 0o600);
117
122
  try {
118
123
  await handle.writeFile(`${line.replace(/[\r\n]+$/g, '')}\n`);
@@ -126,3 +131,18 @@ export async function appendPrivateLine(path, line) {
126
131
  await release();
127
132
  }
128
133
  }
134
+ /**
135
+ * 超限就把当前文件挪成 `.1`(覆盖上一代),与 `vision/telemetry.ts::rotateIfLarge` 同款。
136
+ * 失败静默——轮转不了也要能继续写,日志不该反过来拖垮功能。
137
+ */
138
+ async function rotateIfLarge(path, maxBytes) {
139
+ try {
140
+ const info = await stat(path);
141
+ if (info.size < maxBytes)
142
+ return;
143
+ await rename(path, `${path}.1`);
144
+ }
145
+ catch {
146
+ /* 文件不存在或挪不动,都不影响追加 */
147
+ }
148
+ }
@@ -6,6 +6,7 @@ import {
6
6
  readFile,
7
7
  rename,
8
8
  rm,
9
+ stat,
9
10
  unlink,
10
11
  writeFile,
11
12
  } from 'node:fs/promises';
@@ -95,9 +96,12 @@ export async function acquireFileLock(
95
96
  if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
96
97
  const raw = await readFile(lock, 'utf8').catch(() => '');
97
98
  const createdAt = Number(raw.split(':')[1]);
99
+ const lockInfo = await stat(lock).catch(() => null);
98
100
  if (
99
- !Number.isFinite(createdAt) ||
100
- Date.now() - createdAt > LOCK_STALE_MS
101
+ (Number.isFinite(createdAt) && Date.now() - createdAt > LOCK_STALE_MS) ||
102
+ (!Number.isFinite(createdAt)
103
+ && lockInfo !== null
104
+ && Date.now() - lockInfo.mtimeMs > LOCK_STALE_MS)
101
105
  ) {
102
106
  await unlink(lock).catch(() => {});
103
107
  continue;
@@ -147,10 +151,12 @@ export function enqueueMemoryMutation<T>(
147
151
  export async function appendPrivateLine(
148
152
  path: string,
149
153
  line: string,
154
+ options?: { maxBytes?: number },
150
155
  ): Promise<void> {
151
156
  const release = await acquireFileLock(path);
152
157
  try {
153
158
  await ensurePrivateDir(dirname(path));
159
+ if (options?.maxBytes) await rotateIfLarge(path, options.maxBytes);
154
160
  const handle = await open(path, 'a', 0o600);
155
161
  try {
156
162
  await handle.writeFile(`${line.replace(/[\r\n]+$/g, '')}\n`);
@@ -162,3 +168,17 @@ export async function appendPrivateLine(
162
168
  await release();
163
169
  }
164
170
  }
171
+
172
+ /**
173
+ * 超限就把当前文件挪成 `.1`(覆盖上一代),与 `vision/telemetry.ts::rotateIfLarge` 同款。
174
+ * 失败静默——轮转不了也要能继续写,日志不该反过来拖垮功能。
175
+ */
176
+ async function rotateIfLarge(path: string, maxBytes: number): Promise<void> {
177
+ try {
178
+ const info = await stat(path);
179
+ if (info.size < maxBytes) return;
180
+ await rename(path, `${path}.1`);
181
+ } catch {
182
+ /* 文件不存在或挪不动,都不影响追加 */
183
+ }
184
+ }
@@ -3,15 +3,16 @@ import { copyFile, readFile, unlink } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { countTextTokens } from '../context/tokenCounter.js';
5
5
  import { containsForbiddenSecret, deriveTopicKey, hydrateRecord, parsePersona, serializePersona, sha256, validateMemoryInput, } from './codec.js';
6
- import { DEFAULT_SETTINGS, PERSONA_MAX_CHARS } from './constants.js';
7
- import { episodicMemoryService } from './episodicMemory.js';
6
+ import { DEFAULT_RETENTION, DEFAULT_SETTINGS, MEMORY_RETENTION_DAYS_MAX, MEMORY_RETENTION_DAYS_MIN, MEMORY_RETENTION_MB_MAX, MEMORY_RETENTION_MB_MIN, PERSONA_MAX_CHARS, } from './constants.js';
7
+ import { clearConversationEpisodes, episodicMemoryService, } from './episodicMemory.js';
8
8
  import { acquireFileLock, appendPrivateLine, atomicWriteFile, auditFilePath, enqueueMemoryMutation, personaFilePath, scopeDirectory, settingsFilePath, } from './fileStore.js';
9
9
  import { ensureMemoryMigrated } from './migration.js';
10
- import { readVersionedMemory, removeVersionedMemory, replaceMemoryBatchVersioned, scanMemoryScope, writeNewMemory, } from './repository.js';
10
+ import { clearScopeTransactionQuarantine, readVersionedMemory, refreshTopicView, removeVersionedMemory, replaceMemoryBatchVersioned, scanMemoryScope, writeNewMemory, } from './repository.js';
11
11
  import { isReviewDue, recallFromSnapshot, takeTokenBudget, truncateTokenBudget, } from './retrieval.js';
12
12
  export { containsForbiddenSecret } from './codec.js';
13
13
  function mergeSettings(raw) {
14
14
  const review = raw.reviewAfterDays;
15
+ const retention = raw.retention;
15
16
  return {
16
17
  autoRemember: typeof raw.autoRemember === 'boolean'
17
18
  ? raw.autoRemember
@@ -24,16 +25,35 @@ function mergeSettings(raw) {
24
25
  project: review?.project ?? DEFAULT_SETTINGS.reviewAfterDays.project,
25
26
  decision: review?.decision ?? DEFAULT_SETTINGS.reviewAfterDays.decision,
26
27
  },
28
+ retention: {
29
+ episodeDays: retention?.episodeDays ?? DEFAULT_RETENTION.episodeDays,
30
+ episodeMaxMegabytes: retention?.episodeMaxMegabytes ?? DEFAULT_RETENTION.episodeMaxMegabytes,
31
+ },
27
32
  };
28
33
  }
29
34
  function validateSettingsPatch(patch) {
30
- if (patch.reviewAfterDays === undefined)
31
- return;
32
- for (const [category, days] of Object.entries(patch.reviewAfterDays)) {
33
- if (!Number.isInteger(days) || days < 7 || days > 3_650) {
34
- throw new Error(`复核周期 ${category} 必须是 7-3650 之间的整数天`);
35
+ if (patch.reviewAfterDays !== undefined) {
36
+ for (const [category, days] of Object.entries(patch.reviewAfterDays)) {
37
+ if (!Number.isInteger(days) || days < 7 || days > 3_650) {
38
+ throw new Error(`复核周期 ${category} 必须是 7-3650 之间的整数天`);
39
+ }
35
40
  }
36
41
  }
42
+ if (patch.retention === undefined)
43
+ return;
44
+ const { episodeDays, episodeMaxMegabytes } = patch.retention;
45
+ if (episodeDays !== undefined
46
+ && (!Number.isInteger(episodeDays)
47
+ || episodeDays < MEMORY_RETENTION_DAYS_MIN
48
+ || episodeDays > MEMORY_RETENTION_DAYS_MAX)) {
49
+ throw new Error(`会话原文保留天数必须是 ${MEMORY_RETENTION_DAYS_MIN}-${MEMORY_RETENTION_DAYS_MAX} 之间的整数`);
50
+ }
51
+ if (episodeMaxMegabytes !== undefined
52
+ && (!Number.isInteger(episodeMaxMegabytes)
53
+ || episodeMaxMegabytes < MEMORY_RETENTION_MB_MIN
54
+ || episodeMaxMegabytes > MEMORY_RETENTION_MB_MAX)) {
55
+ throw new Error(`会话原文体积上限必须是 ${MEMORY_RETENTION_MB_MIN}-${MEMORY_RETENTION_MB_MAX} 之间的整数 MB`);
56
+ }
37
57
  }
38
58
  async function settingsDocument() {
39
59
  try {
@@ -52,6 +72,8 @@ async function settingsDocument() {
52
72
  return { settings, version: sha256(`${JSON.stringify(settings, null, 2)}\n`) };
53
73
  }
54
74
  }
75
+ /** 审计日志单代上限;超限挪成 `.1`,保留最近两代。 */
76
+ const AUDIT_MAX_BYTES = 4 * 1024 * 1024;
55
77
  async function appendAudit(input) {
56
78
  await appendPrivateLine(auditFilePath(), JSON.stringify({
57
79
  timestamp: Date.now(),
@@ -62,23 +84,55 @@ async function appendAudit(input) {
62
84
  revision: input.record?.revision,
63
85
  version: input.version,
64
86
  result: input.result,
65
- })).catch(() => undefined);
87
+ }),
88
+ // 审计日志同样是 append-only,必须轮转——它此前是仓库里第二个只增不减的存储。
89
+ { maxBytes: AUDIT_MAX_BYTES }).catch(() => undefined);
66
90
  }
91
+ const EMPTY_SCOPE_SNAPSHOT = Object.freeze({
92
+ records: [],
93
+ versions: {},
94
+ diagnostics: [],
95
+ createdAt: 0,
96
+ });
97
+ /**
98
+ * 合并快照的记忆化。
99
+ *
100
+ * ⚠️ 这不是"顺手加个缓存",是修一个死缓存:`retrieval.ts` 用
101
+ * `WeakMap<MemorySnapshot, Bm25Index>` 按快照对象缓存 BM25 索引,而本函数以前**每次都
102
+ * 返回全新对象字面量** —— 于是 recall / buildContextBundle 这两条热路径上 WeakMap 永远
103
+ * miss,每轮对话都要重建整个索引,`recordSearchIndexCacheHit()` 一次都不会触发(诊断计数
104
+ * 器因此说谎)。单测直接对同一个 snapshot 对象调两次 recallFromSnapshot,机制是对的,
105
+ * 但生产集成从不复用对象,属于"测试通过、生产死代码"。
106
+ *
107
+ * 用底层两个 scope 快照的**对象标识**做键:scanMemoryScope 在缓存命中时返回同一个对象,
108
+ * 有变更时才换新对象——比时间戳更精确,也不怕时钟碰撞。
109
+ */
110
+ let combinedSnapshotCache = null;
67
111
  async function combinedSnapshot(workspaceId) {
68
112
  await ensureMemoryMigrated();
69
113
  const personal = await scanMemoryScope('personal');
70
114
  const workspace = workspaceId
71
115
  ? await scanMemoryScope('workspace', workspaceId)
72
- : { records: [], versions: {}, diagnostics: [], createdAt: Date.now() };
73
- return {
116
+ : EMPTY_SCOPE_SNAPSHOT;
117
+ const cached = combinedSnapshotCache;
118
+ if (cached
119
+ && cached.personal === personal
120
+ && cached.workspace === workspace
121
+ && cached.workspaceId === workspaceId) {
122
+ return cached.combined;
123
+ }
124
+ const combined = {
74
125
  records: [...personal.records, ...workspace.records],
75
126
  versions: { ...personal.versions, ...workspace.versions },
76
127
  diagnostics: [...personal.diagnostics, ...workspace.diagnostics],
77
128
  createdAt: Date.now(),
78
129
  };
130
+ combinedSnapshotCache = { personal, workspace, workspaceId, combined };
131
+ return combined;
79
132
  }
80
133
  function refreshTopicViewAfterMutation(scope, workspaceId) {
81
- void scanMemoryScope(scope, workspaceId).catch(() => undefined);
134
+ combinedSnapshotCache = null;
135
+ void refreshTopicView(scope, workspaceId).catch(() => undefined);
82
136
  }
83
137
  function sameTopic(a, b) {
84
138
  const topicKey = b.topicKey || deriveTopicKey(b);
@@ -348,6 +402,19 @@ export const memoryService = {
348
402
  await removeVersionedMemory({ record, version }, version);
349
403
  await appendAudit({ action: 'clear', record, version, result: 'ok' });
350
404
  }
405
+ // ★ 会话原文日志也要清掉。
406
+ // Desktop「永久清空」以前只删 records,而同一个记忆中心页面还提供
407
+ // ConversationHistorySearch —— 用户"清空记忆"后全部对话原文依然可搜,
408
+ // 与按钮承诺严重不符。工作区范围才有对应的 episodes 文件。
409
+ if (scope === 'workspace' && workspaceId) {
410
+ const removed = await clearConversationEpisodes(workspaceId).catch(() => 0);
411
+ if (removed) {
412
+ await appendAudit({ action: 'clear-episodes', result: 'ok' });
413
+ }
414
+ }
415
+ // 只有用户明确清空整个 scope 才表示隔离事务的复核现场也不再需要。
416
+ // 普通新增/更新记忆不得清除这类诊断。
417
+ await clearScopeTransactionQuarantine(scope, workspaceId);
351
418
  refreshTopicViewAfterMutation(scope, workspaceId);
352
419
  });
353
420
  },