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,21 +1,33 @@
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
  import { agentStateDir } from '../context/agentHome.js';
5
5
  import { containsForbiddenSecret, sha256 } from './codec.js';
6
- import { appendPrivateLine, atomicWriteFile, ensurePrivateDir, pathExists, } from './fileStore.js';
6
+ import { acquireFileLock, appendPrivateLine, atomicWriteFile, ensurePrivateDir, pathExists, } from './fileStore.js';
7
7
  import { memoryTerms } from './retrieval.js';
8
8
  import { BM25_DOCUMENT_THRESHOLD, Bm25Index, recordSearchIndexBuild, recordSearchIndexCacheHit, recordSearchIndexFallback, recordSearchStrategy, } from './searchIndex.js';
9
9
  const MAX_EPISODE_CHARS = 8_000;
10
10
  const MAX_SEARCH_FILES = 200;
11
11
  const MAX_SEARCH_LINES = 20_000;
12
12
  const MAX_SNIPPET_CHARS = 500;
13
+ /**
14
+ * 已解析 episode 常驻内存的上限。
15
+ *
16
+ * ⚠️ `episodeFileCache` 缓存的是**整个文件解析出的全部 episode**,而日志本身是只增不减的
17
+ * (每条 user/assistant 消息 ≤8000 字符)。没有上限时,Desktop 这种长驻进程搜一次历史就把
18
+ * 几十 MB 对话原文永久钉在堆上。按 episode 条数做 LRU,逐出最久未用的文件。
19
+ */
20
+ const MAX_CACHED_EPISODES = 20_000;
21
+ /** 两次保留清理之间的最小间隔——清理是尽力而为的后台动作,不该每条消息都扫盘。 */
22
+ const RETENTION_SWEEP_INTERVAL_MS = 5 * 60_000;
13
23
  const episodeFileCache = new Map();
14
24
  const episodeIndexCache = new Map();
15
25
  const episodeDiagnostics = {
16
26
  fullFileReads: 0,
17
27
  incrementalFileReads: 0,
18
28
  unchangedFileHits: 0,
29
+ cacheEvictions: 0,
30
+ retentionSweeps: 0,
19
31
  };
20
32
  export function episodeSearchDiagnostics() {
21
33
  return { ...episodeDiagnostics };
@@ -24,8 +36,35 @@ export function resetEpisodeSearchDiagnostics() {
24
36
  episodeDiagnostics.fullFileReads = 0;
25
37
  episodeDiagnostics.incrementalFileReads = 0;
26
38
  episodeDiagnostics.unchangedFileHits = 0;
39
+ episodeDiagnostics.cacheEvictions = 0;
40
+ episodeDiagnostics.retentionSweeps = 0;
27
41
  episodeFileCache.clear();
28
42
  episodeIndexCache.clear();
43
+ cacheClock = 0;
44
+ retentionSweepStates.clear();
45
+ }
46
+ let cacheClock = 0;
47
+ const retentionSweepStates = new Map();
48
+ const MAX_RETENTION_SWEEP_STATES = 128;
49
+ function cachedEpisodeCount() {
50
+ let total = 0;
51
+ for (const entry of episodeFileCache.values())
52
+ total += entry.episodes.length;
53
+ return total;
54
+ }
55
+ /** 超出 MAX_CACHED_EPISODES 就按最久未用逐出整份文件缓存,直到回到上限之内。 */
56
+ function evictEpisodeCache() {
57
+ if (cachedEpisodeCount() <= MAX_CACHED_EPISODES)
58
+ return;
59
+ const byAge = [...episodeFileCache.entries()].sort((a, b) => a[1].usedAt - b[1].usedAt);
60
+ for (const [path] of byAge) {
61
+ if (cachedEpisodeCount() <= MAX_CACHED_EPISODES)
62
+ break;
63
+ episodeFileCache.delete(path);
64
+ episodeDiagnostics.cacheEvictions += 1;
65
+ }
66
+ // 文件缓存变了,基于它建的 BM25 语料签名也就失效了。
67
+ episodeIndexCache.clear();
29
68
  }
30
69
  function episodeRoot() {
31
70
  return join(agentStateDir(), 'episodes');
@@ -43,6 +82,9 @@ function episodePath(workspaceId, sessionId) {
43
82
  function backfillMarkerPath(workspaceId) {
44
83
  return join(episodeRoot(), `.backfill-${validPart(workspaceId)}.json`);
45
84
  }
85
+ function workspaceJournalLockPath(workspaceId) {
86
+ return join(episodeRoot(), `.workspace-${validPart(workspaceId)}.journal`);
87
+ }
46
88
  export function sanitizeEpisodeContent(content) {
47
89
  const normalized = content
48
90
  .replace(/data:[^;\s]+;base64,[A-Za-z0-9+/=]{80,}/g, '[binary omitted]')
@@ -74,6 +116,219 @@ export async function appendConversationEpisode(input) {
74
116
  await appendPrivateLine(path, JSON.stringify(episode));
75
117
  return episode;
76
118
  }
119
+ /** 删除某工作区(缺省为全部)的会话原文日志。供 Desktop「清空记忆」一并调用。 */
120
+ export async function clearConversationEpisodes(workspaceId) {
121
+ const release = workspaceId
122
+ ? await acquireFileLock(workspaceJournalLockPath(workspaceId))
123
+ : null;
124
+ try {
125
+ return await clearConversationEpisodesUnlocked(workspaceId);
126
+ }
127
+ finally {
128
+ await release?.();
129
+ }
130
+ }
131
+ async function clearConversationEpisodesUnlocked(workspaceId) {
132
+ const files = await episodeFiles(workspaceId).catch(() => []);
133
+ let removed = 0;
134
+ for (const path of files) {
135
+ const result = await mutateEpisodeFile(path, () => ({ kind: 'delete' })).catch(() => null);
136
+ if (result?.changed)
137
+ removed += 1;
138
+ }
139
+ // ⚠️ backfill 标记必须**留下(没有就补写)**,绝不能删。
140
+ // backfillLegacyHistory 会从 `sessions/*.json` 一次性导入旧会话;标记一旦消失,
141
+ // 下一次 SearchConversations 就会把刚刚清掉的对话原文整批重新导回来 —— 用户点了
142
+ // "永久清空",转头又能搜到,等于清空是假的。
143
+ if (workspaceId) {
144
+ await atomicWriteFile(backfillMarkerPath(workspaceId), `${JSON.stringify({ version: 1, workspaceId, sources: 0, completedAt: Date.now(), reason: 'cleared-by-user' }, null, 2)}\n`).catch(() => undefined);
145
+ }
146
+ episodeIndexCache.clear();
147
+ return removed;
148
+ }
149
+ function sameEpisodeFile(left, right) {
150
+ return left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs;
151
+ }
152
+ async function readStableEpisodeFile(path) {
153
+ for (let attempt = 0; attempt < 2; attempt += 1) {
154
+ const before = await stat(path).catch((error) => {
155
+ if (error.code === 'ENOENT')
156
+ return null;
157
+ throw error;
158
+ });
159
+ if (!before)
160
+ return null;
161
+ const raw = await readFile(path, 'utf8');
162
+ const after = await stat(path).catch(() => null);
163
+ if (after
164
+ && before.ino === after.ino
165
+ && before.size === after.size
166
+ && before.mtimeMs === after.mtimeMs
167
+ && Buffer.byteLength(raw, 'utf8') === after.size) {
168
+ return { raw, ino: after.ino, size: after.size, mtimeMs: after.mtimeMs };
169
+ }
170
+ }
171
+ return null;
172
+ }
173
+ /**
174
+ * 与 appendPrivateLine 共用同一把 `<episode>.lock`。大文件的解析在锁外完成,
175
+ * 获锁后只验证文件代际并提交;如果这期间有追加,就重读重算,绝不覆盖新行。
176
+ */
177
+ async function mutateEpisodeFile(path, plan) {
178
+ for (let attempt = 0; attempt < 3; attempt += 1) {
179
+ const source = await readStableEpisodeFile(path);
180
+ if (!source)
181
+ return { changed: false, bytes: 0 };
182
+ const mutation = plan(source.raw);
183
+ if (mutation.kind === 'unchanged')
184
+ return { changed: false, bytes: source.size };
185
+ const release = await acquireFileLock(path);
186
+ try {
187
+ const live = await stat(path).catch(() => null);
188
+ if (!live || !sameEpisodeFile(source, live))
189
+ continue;
190
+ if (mutation.kind === 'delete')
191
+ await unlink(path);
192
+ else
193
+ await atomicWriteFile(path, mutation.content);
194
+ episodeFileCache.delete(path);
195
+ episodeIndexCache.clear();
196
+ return {
197
+ changed: true,
198
+ bytes: mutation.kind === 'write' ? Buffer.byteLength(mutation.content, 'utf8') : 0,
199
+ };
200
+ }
201
+ finally {
202
+ await release();
203
+ }
204
+ }
205
+ // 文件持续忙时跳过本轮维护;下次保留扫描会再处理。
206
+ const current = await stat(path).catch(() => null);
207
+ return { changed: false, bytes: current?.size ?? 0 };
208
+ }
209
+ /** 列出某工作区(或全部)的会话原文日志文件。 */
210
+ async function episodeFiles(workspaceId) {
211
+ const prefix = workspaceId ? `workspace-${validPart(workspaceId)}--` : 'workspace-';
212
+ try {
213
+ return (await readdir(episodeRoot(), { withFileTypes: true }))
214
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl') && entry.name.startsWith(prefix))
215
+ .map((entry) => join(episodeRoot(), entry.name));
216
+ }
217
+ catch (error) {
218
+ if (error.code === 'ENOENT')
219
+ return [];
220
+ throw error;
221
+ }
222
+ }
223
+ /** 按龄清理一个文件;返回清理后的字节数(文件被清空则删除并返回 0)。 */
224
+ async function pruneFileByAge(path, cutoff) {
225
+ const result = await mutateEpisodeFile(path, (raw) => {
226
+ const lines = raw.split('\n').filter((line) => line.trim().length > 0);
227
+ const kept = lines.filter((line) => {
228
+ const episode = parseEpisode(line);
229
+ // 解析不出来的行按"保留"处理:清理是维护动作,不该顺手吞掉看不懂的数据。
230
+ return episode ? episode.createdAt >= cutoff : true;
231
+ });
232
+ if (kept.length === lines.length)
233
+ return { kind: 'unchanged' };
234
+ if (kept.length === 0)
235
+ return { kind: 'delete' };
236
+ return { kind: 'write', content: `${kept.join('\n')}\n` };
237
+ });
238
+ return result.bytes;
239
+ }
240
+ /**
241
+ * 会话原文保留策略:先按龄清理,仍超体积上限就从**最旧的文件**整份删起。
242
+ *
243
+ * 全程 best-effort:清理失败绝不能影响正在进行的对话(这是维护动作,不是功能)。
244
+ * 与 records 的删除语义严格区分——records 只有用户能删,这里删的是为检索而留的对话副本。
245
+ */
246
+ export async function applyEpisodeRetention(workspaceId, retention, now = Date.now()) {
247
+ const files = await episodeFiles(workspaceId).catch(() => []);
248
+ if (!files.length)
249
+ return;
250
+ episodeDiagnostics.retentionSweeps += 1;
251
+ const cutoff = now - retention.episodeDays * 86_400_000;
252
+ const sized = [];
253
+ for (const path of files) {
254
+ const bytes = await pruneFileByAge(path, cutoff).catch(() => -1);
255
+ if (bytes <= 0)
256
+ continue;
257
+ const info = await stat(path).catch(() => null);
258
+ sized.push({ path, bytes, mtimeMs: info?.mtimeMs ?? 0 });
259
+ }
260
+ const limit = retention.episodeMaxMegabytes * 1024 * 1024;
261
+ let total = sized.reduce((sum, item) => sum + item.bytes, 0);
262
+ if (total <= limit)
263
+ return;
264
+ // 从最旧的文件开始按**条目**裁剪,而不是整份删除:
265
+ // 一个工作区常常只有一个长期会话文件,整份删会让全部历史在某次追加后突然消失(悬崖),
266
+ // 而按条目裁剪的结果是"最旧的对话先被丢掉",与按龄清理的语义一致。
267
+ for (const item of sized.sort((a, b) => a.mtimeMs - b.mtimeMs)) {
268
+ if (total <= limit)
269
+ break;
270
+ const keepBytes = Math.max(0, item.bytes - (total - limit));
271
+ const remaining = await trimFileToNewestBytes(item.path, keepBytes).catch(() => item.bytes);
272
+ total -= item.bytes - remaining;
273
+ }
274
+ episodeIndexCache.clear();
275
+ }
276
+ /** 只保留文件末尾(最新)能放进 keepBytes 的整行;keepBytes 为 0 时删除文件。返回剩余字节数。 */
277
+ async function trimFileToNewestBytes(path, keepBytes) {
278
+ const result = await mutateEpisodeFile(path, (raw) => {
279
+ if (keepBytes <= 0)
280
+ return { kind: 'delete' };
281
+ const lines = raw.split('\n').filter((line) => line.trim().length > 0);
282
+ const kept = [];
283
+ let bytes = 0;
284
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
285
+ const size = Buffer.byteLength(`${lines[index]}\n`, 'utf8');
286
+ if (bytes + size > keepBytes)
287
+ break;
288
+ kept.unshift(lines[index]);
289
+ bytes += size;
290
+ }
291
+ if (!kept.length)
292
+ return { kind: 'delete' };
293
+ if (kept.length === lines.length)
294
+ return { kind: 'unchanged' };
295
+ return { kind: 'write', content: `${kept.join('\n')}\n` };
296
+ });
297
+ return result.bytes;
298
+ }
299
+ /**
300
+ * 是否该跑一次保留清理(同一进程内每 RETENTION_SWEEP_INTERVAL_MS 最多一次)。
301
+ *
302
+ * 本模块**不自己去读设置**:那需要 `import('./memoryService.js')`,而 memoryService 正好
303
+ * 静态 import 本模块,绕一圈会变成动态 import 的循环依赖——dev 下能跑,但 `bun --compile`
304
+ * 的绿色版打包期解析动态 import 的行为另算,不值得赌(见 GREEN-BUILD 相关教训)。
305
+ * 由知道设置的那一层(turnJournal)来问「该扫了吗」,本模块只保留纯粹的存储职责。
306
+ */
307
+ export function shouldSweepRetention(workspaceId, now = Date.now()) {
308
+ const current = retentionSweepStates.get(workspaceId);
309
+ if (current?.inFlight || (current && now - current.lastStartedAt < RETENTION_SWEEP_INTERVAL_MS)) {
310
+ if (current)
311
+ current.lastUsedAt = now;
312
+ return false;
313
+ }
314
+ retentionSweepStates.set(workspaceId, { lastStartedAt: now, lastUsedAt: now, inFlight: true });
315
+ if (retentionSweepStates.size > MAX_RETENTION_SWEEP_STATES) {
316
+ const victims = [...retentionSweepStates.entries()]
317
+ .filter(([, state]) => !state.inFlight)
318
+ .sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt)
319
+ .slice(0, retentionSweepStates.size - MAX_RETENTION_SWEEP_STATES);
320
+ for (const [key] of victims)
321
+ retentionSweepStates.delete(key);
322
+ }
323
+ return true;
324
+ }
325
+ export function finishRetentionSweep(workspaceId) {
326
+ const state = retentionSweepStates.get(workspaceId);
327
+ if (state) {
328
+ state.inFlight = false;
329
+ state.lastUsedAt = Date.now();
330
+ }
331
+ }
77
332
  function parseEpisode(line) {
78
333
  try {
79
334
  const value = JSON.parse(line);
@@ -120,10 +375,12 @@ async function readEpisodeBytes(path, offset, length) {
120
375
  async function loadEpisodeFile(path) {
121
376
  const info = await stat(path);
122
377
  const cached = episodeFileCache.get(path);
378
+ cacheClock += 1;
123
379
  if (cached
124
380
  && cached.ino === info.ino
125
381
  && cached.size === info.size
126
382
  && cached.mtimeMs === info.mtimeMs) {
383
+ cached.usedAt = cacheClock;
127
384
  episodeDiagnostics.unchangedFileHits += 1;
128
385
  return cached;
129
386
  }
@@ -136,9 +393,11 @@ async function loadEpisodeFile(path) {
136
393
  mtimeMs: info.mtimeMs,
137
394
  trailing: parsed.trailing,
138
395
  episodes: [...cached.episodes, ...parsed.episodes],
396
+ usedAt: cacheClock,
139
397
  };
140
398
  episodeFileCache.set(path, next);
141
399
  episodeDiagnostics.incrementalFileReads += 1;
400
+ evictEpisodeCache();
142
401
  return next;
143
402
  }
144
403
  const raw = await readFile(path, 'utf8');
@@ -149,9 +408,11 @@ async function loadEpisodeFile(path) {
149
408
  mtimeMs: info.mtimeMs,
150
409
  trailing: parsed.trailing,
151
410
  episodes: parsed.episodes,
411
+ usedAt: cacheClock,
152
412
  };
153
413
  episodeFileCache.set(path, next);
154
414
  episodeDiagnostics.fullFileReads += 1;
415
+ evictEpisodeCache();
155
416
  return next;
156
417
  }
157
418
  function persistedMessageText(message) {
@@ -201,6 +462,15 @@ async function workspaceArchiveDirs(workspaceId) {
201
462
  }
202
463
  }
203
464
  async function backfillLegacyHistory(workspaceId) {
465
+ const release = await acquireFileLock(workspaceJournalLockPath(workspaceId));
466
+ try {
467
+ await backfillLegacyHistoryUnlocked(workspaceId);
468
+ }
469
+ finally {
470
+ await release();
471
+ }
472
+ }
473
+ async function backfillLegacyHistoryUnlocked(workspaceId) {
204
474
  const marker = backfillMarkerPath(workspaceId);
205
475
  if (await pathExists(marker))
206
476
  return;
@@ -409,4 +679,6 @@ function conversationResult(episode, score) {
409
679
  export const episodicMemoryService = {
410
680
  append: appendConversationEpisode,
411
681
  search: searchConversationEpisodes,
682
+ clear: clearConversationEpisodes,
683
+ applyRetention: applyEpisodeRetention,
412
684
  };