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.
- package/package.json +4 -1
- package/plugins/workflow-runner/plugin.js +27 -15
- package/plugins/workflow-runner/plugin.ts +34 -20
- package/plugins/workflow-runner/src/agentCommon.js +13 -12
- package/plugins/workflow-runner/src/agentCommon.ts +22 -13
- package/plugins/workflow-runner/src/index.js +5 -3
- package/plugins/workflow-runner/src/index.ts +31 -13
- package/plugins/workflow-runner/src/multimodal.js +2 -1
- package/plugins/workflow-runner/src/multimodal.ts +2 -1
- package/plugins/workflow-runner/src/stepExecutors/skill.js +5 -6
- package/plugins/workflow-runner/src/stepExecutors/skill.ts +12 -7
- package/plugins/workflow-runner/src/stepExecutors/tool.js +68 -57
- package/plugins/workflow-runner/src/stepExecutors/tool.ts +88 -66
- package/plugins/workflow-runner/src/toolRuntime.js +48 -0
- package/plugins/workflow-runner/src/toolRuntime.ts +64 -0
- package/plugins/workflow-runner/src/types.ts +26 -7
- package/plugins/workflow-runner/test/context-files.test.ts +47 -16
- package/plugins/workflow-runner/test/hostCapability.test.ts +9 -3
- package/plugins/workflow-runner/test/multimodal.test.ts +61 -10
- package/plugins/workflow-runner/test/toolRuntimeIntegration.test.ts +168 -0
- package/src/cli/print.js +19 -4
- package/src/cli/print.ts +17 -4
- package/src/cli/streamJson.js +19 -4
- package/src/cli/streamJson.ts +17 -4
- package/src/config/configDefaults.js +25 -0
- package/src/config/configDefaults.ts +33 -0
- package/src/config/configFile.js +2 -21
- package/src/config/configFile.ts +11 -24
- package/src/config/configManagement.js +1 -1
- package/src/config/configManagement.ts +2 -2
- package/src/context/compact.js +4 -0
- package/src/context/compact.ts +4 -0
- package/src/context/contextBundle.js +38 -13
- package/src/context/contextBundle.ts +47 -13
- package/src/harness/stallGuard.js +19 -4
- package/src/harness/stallGuard.ts +30 -7
- package/src/loop.js +95 -11
- package/src/loop.ts +125 -16
- package/src/main.js +8 -1
- package/src/main.tsx +7 -1
- package/src/memory/constants.js +10 -0
- package/src/memory/constants.ts +13 -0
- package/src/memory/episodicMemory.js +274 -2
- package/src/memory/episodicMemory.ts +306 -1
- package/src/memory/fileStore.js +24 -4
- package/src/memory/fileStore.ts +22 -2
- package/src/memory/memoryService.js +79 -12
- package/src/memory/memoryService.ts +108 -9
- package/src/memory/repository.js +415 -85
- package/src/memory/repository.ts +437 -86
- package/src/memory/turnJournal.js +95 -0
- package/src/memory/turnJournal.ts +120 -0
- package/src/memory/types.ts +16 -0
- package/src/plugins/pluginApi.ts +30 -6
- package/src/plugins/pluginRunner.js +6 -2
- package/src/plugins/pluginRunner.ts +12 -7
- package/src/prompts/policyRegistry.js +27 -0
- package/src/prompts/policyRegistry.ts +27 -0
- package/src/prompts/promptAssembly.js +39 -7
- package/src/prompts/promptAssembly.ts +145 -59
- package/src/session/runAgentTurn.js +43 -21
- package/src/session/runAgentTurn.ts +68 -26
- package/src/session/runTurn.js +11 -46
- package/src/session/runTurn.ts +12 -46
- package/src/session/runtimeStore.js +149 -0
- package/src/session/runtimeStore.ts +197 -0
- package/src/session/sessionState.js +5 -0
- package/src/session/sessionState.ts +14 -0
- package/src/tools/agent/agent.js +143 -14
- package/src/tools/agent/agent.ts +149 -14
- package/src/tools/agent/agentTaskService.js +60 -0
- package/src/tools/agent/agentTaskService.ts +113 -0
- package/src/tools/agent/agentTypes.js +5 -0
- package/src/tools/agent/agentTypes.ts +5 -0
- package/src/tools/bash/bash.js +31 -11
- package/src/tools/bash/bash.ts +29 -10
- package/src/tools/capabilities.js +5 -0
- package/src/tools/capabilities.ts +11 -1
- package/src/tools/edit/edit.js +7 -4
- package/src/tools/edit/edit.ts +7 -4
- package/src/tools/edit/multi-edit.js +4 -2
- package/src/tools/edit/multi-edit.ts +4 -2
- package/src/tools/glob/glob.js +156 -40
- package/src/tools/glob/glob.ts +185 -46
- package/src/tools/index.js +35 -17
- package/src/tools/index.ts +81 -20
- package/src/tools/lsp/lsp.js +165 -22
- package/src/tools/lsp/lsp.ts +206 -36
- package/src/tools/powershell/powershell.js +116 -26
- package/src/tools/powershell/powershell.ts +155 -30
- package/src/tools/presentresult/presentresult.js +10 -0
- package/src/tools/presentresult/presentresult.ts +10 -0
- package/src/tools/read/read.js +244 -29
- package/src/tools/read/read.ts +391 -59
- package/src/tools/shared/diffSummary.js +44 -0
- package/src/tools/shared/diffSummary.ts +63 -0
- package/src/tools/shared/fileActivity.js +2 -1
- package/src/tools/shared/fileActivity.ts +3 -0
- package/src/tools/shared/permanentDeleteGuard.js +26 -0
- package/src/tools/shared/permanentDeleteGuard.ts +34 -0
- package/src/tools/shared/toolOutputStore.js +97 -0
- package/src/tools/shared/toolOutputStore.ts +110 -0
- package/src/tools/skill/skill.js +86 -0
- package/src/tools/skill/skill.ts +94 -0
- package/src/tools/snip/snip.js +67 -0
- package/src/tools/snip/snip.ts +80 -0
- package/src/tools/tasks/backgroundTaskService.js +79 -28
- package/src/tools/tasks/backgroundTaskService.ts +165 -49
- package/src/tools/tasks/taskManagement.js +163 -0
- package/src/tools/tasks/taskManagement.ts +180 -0
- package/src/tools/tasks/taskOutput.js +64 -14
- package/src/tools/tasks/taskOutput.ts +102 -23
- package/src/tools/tasks/taskStop.js +48 -8
- package/src/tools/tasks/taskStop.ts +69 -9
- package/src/tools/tasks/taskStore.js +46 -0
- package/src/tools/tasks/taskStore.ts +77 -0
- package/src/tools/terminal/terminalCapture.js +167 -0
- package/src/tools/terminal/terminalCapture.ts +179 -0
- package/src/tools/trash/trash.js +233 -0
- package/src/tools/trash/trash.ts +283 -0
- package/src/tools/worktree/sessionWorktree.js +128 -0
- package/src/tools/worktree/sessionWorktree.ts +158 -0
- package/src/tools/write/write.js +6 -2
- package/src/tools/write/write.ts +6 -2
- package/src/types.ts +12 -0
- package/src/ui/hooks/useChat.js +31 -11
- package/src/ui/hooks/useChat.ts +31 -13
- 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
|
};
|