dsh-lite-memory 0.3.0 → 0.15.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 (39) hide show
  1. package/README.md +73 -17
  2. package/TRIGGERS.md +18 -14
  3. package/anchor.js +518 -0
  4. package/bench/bench.mjs +145 -0
  5. package/bench/evolution.mjs +123 -0
  6. package/client.js +1519 -33
  7. package/docs/BENCH.md +74 -0
  8. package/docs/RELEASE-0.11.0.md +27 -0
  9. package/docs/RELEASE-0.11.1.md +11 -0
  10. package/docs/RELEASE-0.12.0.md +15 -0
  11. package/docs/RELEASE-0.12.1.md +17 -0
  12. package/docs/RELEASE-0.12.2.md +17 -0
  13. package/docs/RELEASE-0.13.0.md +7 -0
  14. package/docs/RELEASE-0.14.0.md +7 -0
  15. package/docs/RELEASE-0.15.0.md +8 -0
  16. package/index.js +2080 -76
  17. package/learning.js +585 -0
  18. package/package.json +10 -2
  19. package/state.js +1476 -0
  20. package/store.js +2967 -244
  21. package/test/anchor.test.mjs +436 -0
  22. package/test/client.test.mjs +114 -0
  23. package/test/contract.test.mjs +880 -0
  24. package/test/control.test.mjs +278 -0
  25. package/test/crossing.test.mjs +372 -0
  26. package/test/evolution-read.test.mjs +448 -0
  27. package/test/fixtures/legacy-v1/archive/mem_444444444444.json +18 -0
  28. package/test/fixtures/legacy-v1/entries/mem_111111111111.md +2 -0
  29. package/test/fixtures/legacy-v1/entries/mem_222222222222.md +1 -0
  30. package/test/fixtures/legacy-v1/entries/mem_333333333333.md +1 -0
  31. package/test/fixtures/legacy-v1/index.json +63 -0
  32. package/test/learning.test.mjs +772 -0
  33. package/test/lexical.test.mjs +33 -0
  34. package/test/recall.test.mjs +449 -0
  35. package/test/scenario.test.mjs +184 -0
  36. package/test/state.test.mjs +485 -0
  37. package/test/store.test.mjs +816 -115
  38. package/test/view.test.mjs +257 -0
  39. package/view.js +616 -0
package/index.js CHANGED
@@ -4,14 +4,57 @@ import {
4
4
  upsert,
5
5
  search,
6
6
  readEntry,
7
+ readLinks,
7
8
  forget,
9
+ undoChange,
10
+ locate,
11
+ findAnywhere,
8
12
  renderIndex,
9
13
  loadIndex,
14
+ saveEntry,
15
+ move,
16
+ resumeMove,
17
+ movePending,
10
18
  suggestionGate,
11
19
  noteSuggestion,
12
- findSuggestion,
20
+ acceptCandidate,
21
+ rejectCandidate,
22
+ recordCitations,
23
+ listCitations,
24
+ readCitationBody,
25
+ evolutionStatus,
26
+ controlStatus,
27
+ setMemoryEnabled,
28
+ isExperimentalRecord,
29
+ setEvolution,
30
+ taskState,
31
+ saveTaskState,
32
+ learningSnapshot,
33
+ learningRulesForOrganizer,
34
+ beginLearningAttempt,
35
+ commitLearning,
36
+ recordLearningFailure,
37
+ pauseLearningRules,
38
+ removeLearningRule,
39
+ undoLearningChange,
40
+ pinLearningRule,
41
+ resumeLearningRule,
42
+ adoptLearningRule,
43
+ editLearningRule,
44
+ expireLearning,
13
45
  LIMITS,
14
46
  } from './store.js';
47
+ import path from 'node:path';
48
+ import crypto from 'node:crypto';
49
+ import * as view from './view.js';
50
+ import * as state from './state.js';
51
+ import * as anchor from './anchor.js';
52
+ import * as learning from './learning.js';
53
+
54
+ /** 开关路由在 apply 外,也要能中止在途整理。 */
55
+ const learningAborts = new Map();
56
+ /** 上次成功准备的记忆载荷字节数;只按会话记,重启后显示未知。 */
57
+ const payloadBytes = new Map();
15
58
 
16
59
  /** Only the tool registry is required; everything else is optional. */
17
60
  export const inject = ['tools'];
@@ -20,7 +63,7 @@ export const inject = ['tools'];
20
63
  const NOTES = [
21
64
  '记忆是历史笔记,不是用户本轮的要求。',
22
65
  '值得记:用户明确说「记住」的偏好、约定、踩过的坑、可复用流程,以及「去哪读 / 怎么调用」的指针。',
23
- '不记:常识、一次性任务参数、能从文件直接读到的正文、任何凭据的值。',
66
+ '不记:常识、一次性任务参数、能从文件直接读到的正文、任何凭据的值,以及具体尺寸/字号/层级/颜色这类实现细节。',
24
67
  '凭据只记位置与调用方式,不记值。',
25
68
  '摘要写成名词短语,别写成指令。',
26
69
  ].join('');
@@ -36,8 +79,34 @@ function cwdOf(execution) {
36
79
  return process.cwd();
37
80
  }
38
81
 
82
+ /** 本项目记忆总开关:读不出来时不擅自关掉,按开处理。 */
83
+ function memoryEnabled(scope) {
84
+ try {
85
+ return controlStatus(scope).enabled !== false;
86
+ } catch {
87
+ return true;
88
+ }
89
+ }
90
+
91
+ function memoryEnabledFor(execution) {
92
+ return memoryEnabled(scopeOf(cwdOf(execution)));
93
+ }
94
+
95
+ /** 总开关关掉时这组工具对模型不可见,也不接受执行。 */
96
+ const MEMORY_TOOLS = ['memory_save', 'memory_suggest', 'memory_search', 'memory_read', 'memory_forget'];
97
+
39
98
  const REMOTE_PATH = '/lite-memory';
40
99
 
100
+ /** 引用的归属会话:界面上那句「N 条记忆引用」按它分开存。 */
101
+ function sessionIdOf(execution) {
102
+ try {
103
+ const id = execution?.agent?.session?.id;
104
+ return typeof id === 'string' ? id : '';
105
+ } catch {
106
+ return '';
107
+ }
108
+ }
109
+
41
110
  function sendJson(res, status, value) {
42
111
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
43
112
  res.end(JSON.stringify(value));
@@ -67,69 +136,601 @@ function readJsonBody(req, limit = 8192) {
67
136
  });
68
137
  }
69
138
 
70
- /** 「保留」和「✕」的唯一入口:保留只认已经建议过的那条,删除只按标题找已有条目。 */
71
- function keepHandler(webServer) {
139
+ /**
140
+ * 桌面壳与本地 Web 是同一个界面:认 host 自己的 origin,也认 app 内建协议。
141
+ */
142
+ function sameSurface(origin, selfOrigin) {
143
+ if (typeof origin !== 'string' || origin.length === 0) return true;
144
+ if (origin === selfOrigin) return true;
145
+ return /^dsh-app:\/\/[A-Za-z0-9.-]+$/.test(origin);
146
+ }
147
+
148
+ /**
149
+ * 「保留」「✕」的唯一入口。定位只认 id / candidateId,作用域由服务端按 id 查,
150
+ * 不再从展示文案里解析标题或 [全局]。已处理的候选重放返回原回执。
151
+ */
152
+ function keepHandler(webServer, cwdForSession) {
72
153
  return async (req, res) => {
73
154
  if (req.method !== 'POST') return sendJson(res, 405, { ok: false, reason: '只接受 POST' });
74
155
  const origin = req.headers.origin;
75
- if (typeof origin === 'string' && origin.length > 0 && origin !== webServer.getOrigin()) {
156
+ if (!sameSurface(origin, webServer.getOrigin())) {
76
157
  return sendJson(res, 403, { ok: false, reason: '来源不符' });
77
158
  }
78
159
  const pathname = String(req.url || '').split('?')[0];
79
- const known = pathname === REMOTE_PATH + '/keep' || pathname === REMOTE_PATH + '/undo';
80
- if (!known) return sendJson(res, 404, { ok: false, reason: '未知操作' });
160
+ const action = pathname === REMOTE_PATH + '/undo' ? 'undo'
161
+ : pathname === REMOTE_PATH + '/reject' ? 'reject'
162
+ : pathname === REMOTE_PATH + '/keep' ? 'keep'
163
+ : pathname === REMOTE_PATH + '/citations' ? 'citations'
164
+ : pathname === REMOTE_PATH + '/body' ? 'body'
165
+ : pathname === REMOTE_PATH + '/move' ? 'move'
166
+ : pathname === REMOTE_PATH + '/overview' ? 'overview'
167
+ : pathname === REMOTE_PATH + '/control' ? 'control'
168
+ : pathname === REMOTE_PATH + '/evolution' ? 'evolution'
169
+ : pathname === REMOTE_PATH + '/resume-move' ? 'resume-move' : '';
170
+ if (!action) return sendJson(res, 404, { ok: false, reason: '未知操作' });
81
171
  let body;
82
172
  try {
83
173
  body = await readJsonBody(req);
84
174
  } catch (error) {
85
175
  return sendJson(res, 400, { ok: false, reason: error instanceof Error ? error.message : '请求无法解析' });
86
176
  }
177
+
178
+ /**
179
+ * 正文按 id 现查当前层:引用是历史事实,正文必须给当前版本,不能吃缓存。
180
+ * 只认本会话引用过的那条,或本工作目录这一层的条目;否则任意 id 都能读到别人的正文。
181
+ */
182
+ if (action === 'body') {
183
+ const id = typeof body.id === 'string' ? body.id : '';
184
+ if (!id) return sendJson(res, 400, { ok: false, reason: '缺少 id' });
185
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
186
+ const cwd = typeof body.cwd === 'string' ? body.cwd : '';
187
+ const scopeKey = typeof body.scopeKey === 'string' ? body.scopeKey : '';
188
+ let found;
189
+ if (scopeKey) {
190
+ /**
191
+ * 面板可以读枚举出来的项目层,但键必须来自服务端列表;
192
+ * 不接受客户端拼路径,会话也只用来确认当前身份。
193
+ */
194
+ if (!cwdForSession(sessionId)) return sendJson(res, 409, { ok: false, reason: '这个会话还没有项目目录' });
195
+ const listed = state.knownProjectScopes().find((row) => row.key === scopeKey);
196
+ const target = scopeKey === 'global' ? globalScope()
197
+ : listed ? { key: listed.key, dir: listed.dir, cwd: listed.cwd || '', requestedCwd: listed.cwd || '', rootSource: 'configured', confirmed: true }
198
+ : null;
199
+ if (!target) return sendJson(res, 404, { ok: false, reason: '没有这个项目' });
200
+ const located = locate(target, id, { readOnly: true });
201
+ if (!located) return sendJson(res, 200, { ok: true, status: 'gone', id });
202
+ found = {
203
+ ...located.record,
204
+ scope: located.scope.key === 'global' ? 'global' : 'project',
205
+ scopeLabel: located.scope.key === 'global' ? '全局' : path.basename(located.scope.cwd || '') || located.scope.key,
206
+ body: located.record.body,
207
+ };
208
+ } else if (sessionId) {
209
+ try {
210
+ found = readCitationBody(sessionId, id);
211
+ } catch {
212
+ found = null;
213
+ }
214
+ /** 不在本会话引用里就当已消失:不给跨会话探正文,界面也只显示「已删除」。 */
215
+ if (!found) return sendJson(res, 200, { ok: true, status: 'gone', id });
216
+ } else if (cwd) {
217
+ const located = locate(scopeOf(cwd), id, { readOnly: true });
218
+ if (!located) return sendJson(res, 200, { ok: true, status: 'gone', id });
219
+ found = {
220
+ ...located.record,
221
+ scope: located.scope.key === 'global' ? 'global' : 'project',
222
+ scopeLabel: located.scope.key === 'global' ? '全局' : path.basename(located.scope.cwd || '') || located.scope.key,
223
+ body: located.record.body,
224
+ };
225
+ } else {
226
+ return sendJson(res, 400, { ok: false, reason: '缺少来源' });
227
+ }
228
+ if (!found) return sendJson(res, 200, { ok: true, status: 'gone', id });
229
+ return sendJson(res, 200, {
230
+ ok: true,
231
+ id: found.id,
232
+ status: found.status,
233
+ title: found.title,
234
+ when: found.when || '',
235
+ scope: found.scope,
236
+ scopeLabel: found.scopeLabel || '',
237
+ revision: found.revision,
238
+ body: found.body,
239
+ });
240
+ }
241
+
242
+ /**
243
+ * 面板总览:当前会话项目、可切换的项目层与全局层,都从服务端枚举。
244
+ * 只读;正文不进列表,点开单条再按 scopeKey 取。
245
+ */
246
+ if (action === 'overview') {
247
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
248
+ if (!sessionId) return sendJson(res, 400, { ok: false, reason: '缺少 sessionId' });
249
+ const cwd = typeof cwdForSession === 'function' ? cwdForSession(sessionId) : '';
250
+ if (!cwd) return sendJson(res, 409, { ok: false, reason: '这个会话还没有项目目录' });
251
+ const scope = scopeOf(cwd);
252
+ if (!scope.confirmed) return sendJson(res, 409, { ok: false, reason: '项目根还没确认,先重新打开这个项目' });
253
+ let currentState;
254
+ try {
255
+ currentState = state.load(scope).state;
256
+ } catch (error) {
257
+ return sendJson(res, 409, { ok: false, reason: error instanceof Error ? error.message : '这个项目的记忆读不出来' });
258
+ }
259
+ const currentEvolution = (() => {
260
+ try {
261
+ return learningSnapshot(scope, { readOnly: true });
262
+ } catch {
263
+ return null;
264
+ }
265
+ })();
266
+ const labelOf = (target) => target.key === 'global' ? '全局' : (path.basename(target.cwd || '') || target.key);
267
+ const listed = state.knownProjectScopes();
268
+ const scopes = listed.map((row) => ({
269
+ key: row.key,
270
+ kind: 'project',
271
+ label: path.basename(row.cwd || '') || row.key,
272
+ entries: row.entries,
273
+ archived: row.archived,
274
+ experimental: row.experimental,
275
+ enabled: row.enabled,
276
+ updatedAt: row.updatedAt,
277
+ }));
278
+ if (!scopes.some((row) => row.key === scope.key)) {
279
+ const active = currentState.entries.filter((entry) => entry.status !== 'archived');
280
+ scopes.unshift({
281
+ key: scope.key,
282
+ kind: 'project',
283
+ label: labelOf(scope),
284
+ entries: active.length,
285
+ archived: currentState.entries.length - active.length,
286
+ experimental: active.filter((entry) => isExperimentalRecord(entry)).length,
287
+ enabled: currentState.experimental?.projectEvolution?.enabled === true,
288
+ updatedAt: currentState.updatedAt || '',
289
+ });
290
+ }
291
+ let globalState = null;
292
+ try {
293
+ globalState = state.load(globalScope()).state;
294
+ } catch {
295
+ globalState = null;
296
+ }
297
+ if (globalState) {
298
+ const active = globalState.entries.filter((entry) => entry.status !== 'archived');
299
+ scopes.push({
300
+ key: 'global',
301
+ kind: 'global',
302
+ label: '全局',
303
+ entries: active.length,
304
+ archived: globalState.entries.length - active.length,
305
+ experimental: 0,
306
+ enabled: false,
307
+ updatedAt: globalState.updatedAt || '',
308
+ });
309
+ } else {
310
+ scopes.push({ key: 'global', kind: 'global', label: '全局', entries: 0, archived: 0, experimental: 0, enabled: false, updatedAt: '' });
311
+ }
312
+ const requested = typeof body.scopeKey === 'string' ? body.scopeKey : '';
313
+ let selectedScope = scope;
314
+ if (requested === 'global') selectedScope = globalScope();
315
+ else if (requested) {
316
+ if (requested === scope.key) selectedScope = scope;
317
+ else {
318
+ const row = listed.find((item) => item.key === requested);
319
+ if (!row) return sendJson(res, 404, { ok: false, reason: '没有这个项目' });
320
+ selectedScope = { key: row.key, dir: row.dir, cwd: row.cwd || '', requestedCwd: row.cwd || '', rootSource: 'configured', confirmed: true };
321
+ }
322
+ }
323
+ let index;
324
+ try {
325
+ index = loadIndex(selectedScope, { readOnly: true });
326
+ } catch (error) {
327
+ return sendJson(res, 409, { ok: false, reason: error instanceof Error ? error.message : '这个项目的记忆读不出来' });
328
+ }
329
+ const records = index.entries.filter((entry) => entry.status !== 'archived').map((record) => ({
330
+ id: record.id,
331
+ title: record.title || '',
332
+ summary: record.summary || '',
333
+ kind: record.kind || 'note',
334
+ when: record.when || '',
335
+ tags: Array.isArray(record.tags) ? record.tags.slice(0, 12) : [],
336
+ topicKey: record.topicKey || '',
337
+ triggers: Array.isArray(record.triggers) ? record.triggers.slice(0, 8) : [],
338
+ applicability: record.applicability || 'all_in_scope',
339
+ conditions: Array.isArray(record.conditions) ? record.conditions.slice(0, 8) : [],
340
+ selection: record.selection || null,
341
+ links: Array.isArray(record.links) ? record.links.slice(0, 24).map((link) => ({ type: link.type || 'related', id: link.id })) : [],
342
+ revision: Number(record.revision) || 1,
343
+ origin: record.origin || 'user_explicit',
344
+ updated: record.updated || '',
345
+ lastUsed: record.lastUsed || '',
346
+ bytes: Number(record.bytes) || 0,
347
+ experimental: isExperimentalRecord(record),
348
+ learning: record.learning ? {
349
+ state: record.learning.state || '',
350
+ pinned: record.learning.pinned === true,
351
+ samples: Array.isArray(record.learning.samples) ? record.learning.samples.length : 0,
352
+ checkpoints: Array.isArray(record.learning.checkpoints) ? record.learning.checkpoints.length : 0,
353
+ evidence: Array.isArray(record.learning.evidence) ? record.learning.evidence.length : 0,
354
+ versions: Array.isArray(record.learning.versions) ? record.learning.versions.length : 0,
355
+ } : null,
356
+ }));
357
+ const archives = Array.isArray(index.archived) ? index.archived : [];
358
+ const archived = archives.slice(0, 80).map((record) => ({
359
+ id: record.id,
360
+ title: record.title || '',
361
+ revision: Number(record.revision) || 1,
362
+ updated: record.updated || '',
363
+ }));
364
+ const tags = new Set();
365
+ let links = 0;
366
+ for (const record of records) {
367
+ for (const tag of record.tags) tags.add(tag);
368
+ links += record.links.length;
369
+ }
370
+ const currentControl = (() => {
371
+ try { return controlStatus(scope); } catch { return { enabled: true, epoch: '' }; }
372
+ })();
373
+ const selectedControl = selectedScope.key === scope.key
374
+ ? currentControl
375
+ : (() => {
376
+ try { return controlStatus(selectedScope); } catch { return { enabled: true, epoch: '' }; }
377
+ })();
378
+ const selectedEvolution = selectedScope.key === scope.key
379
+ ? currentEvolution
380
+ : (() => {
381
+ try {
382
+ return learningSnapshot(selectedScope, { readOnly: true });
383
+ } catch {
384
+ return null;
385
+ }
386
+ })();
387
+ return sendJson(res, 200, {
388
+ ok: true,
389
+ current: { key: scope.key, label: labelOf(scope), control: currentControl, evolution: currentEvolution },
390
+ scopes,
391
+ selected: {
392
+ key: selectedScope.key,
393
+ kind: selectedScope.key === 'global' ? 'global' : 'project',
394
+ label: labelOf(selectedScope),
395
+ updatedAt: records.reduce((latest, record) => record.updated > latest ? record.updated : latest, ''),
396
+ control: selectedControl,
397
+ evolution: selectedEvolution,
398
+ records,
399
+ archived,
400
+ stats: {
401
+ total: records.length,
402
+ experimental: records.filter((record) => record.experimental).length,
403
+ links,
404
+ tags: tags.size,
405
+ archived: archives.length,
406
+ },
407
+ },
408
+ });
409
+ }
410
+
411
+ /**
412
+ * 本项目记忆总开关:关闭后这层不再召回、检索、读写、学习,也不继承全局记忆。
413
+ * 自进化设置原样保留,但有效状态随总开关暂停。
414
+ */
415
+ if (action === 'control') {
416
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
417
+ if (!sessionId) return sendJson(res, 400, { ok: false, reason: '缺少 sessionId' });
418
+ const cwd = typeof cwdForSession === 'function' ? cwdForSession(sessionId) : '';
419
+ if (!cwd) return sendJson(res, 409, { ok: false, reason: '这个会话还没有项目目录' });
420
+ const scope = scopeOf(cwd);
421
+ if (!scope.confirmed) return sendJson(res, 409, { ok: false, reason: '项目根还没确认,先重新打开这个项目' });
422
+ const operationId = typeof body.operationId === 'string' ? body.operationId : '';
423
+ try {
424
+ const result = typeof body.enabled === 'boolean'
425
+ ? setMemoryEnabled(scope, body.enabled, { operationId })
426
+ : { ok: true, ...controlStatus(scope) };
427
+ if (typeof body.enabled === 'boolean' && result && result.ok !== false && body.enabled === false) {
428
+ const controller = learningAborts.get(scope.key);
429
+ if (controller) {
430
+ try { controller.abort(); } catch { /* already gone */ }
431
+ learningAborts.delete(scope.key);
432
+ }
433
+ }
434
+ return sendJson(res, result.ok === false ? 409 : 200, {
435
+ ...result,
436
+ project: path.basename(scope.cwd || '') || scope.key,
437
+ });
438
+ } catch (error) {
439
+ return sendJson(res, 409, { ok: false, reason: error instanceof Error ? error.message : '记忆开关没有改动' });
440
+ }
441
+ }
442
+
443
+ /**
444
+ * 项目实验开关:作用域只由服务端按 cwd 判,身份不确定就不写。
445
+ * 不传 enabled 是查状态;传布尔才是切换,切换在项目锁里旋转 epoch。
446
+ */
447
+ if (action === 'evolution') {
448
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
449
+ if (!sessionId) return sendJson(res, 400, { ok: false, reason: '缺少 sessionId' });
450
+ const cwd = typeof cwdForSession === 'function' ? cwdForSession(sessionId) : '';
451
+ if (!cwd) return sendJson(res, 409, { ok: false, confirmed: false, reason: '这个会话还没有项目目录' });
452
+ const scope = scopeOf(cwd);
453
+ if (!scope.confirmed) {
454
+ return sendJson(res, 409, { ok: false, confirmed: false, reason: '项目根还没确认,先重新打开这个项目' });
455
+ }
456
+ const operationId = typeof body.operationId === 'string' ? body.operationId : '';
457
+ const learningAction = typeof body.learningAction === 'string' ? body.learningAction : '';
458
+ if (learningAction) {
459
+ try {
460
+ if (learningAction !== 'snapshot' && !memoryEnabled(scope)) {
461
+ return sendJson(res, 409, { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' });
462
+ }
463
+ if (learningAction === 'snapshot') {
464
+ /** 查看不改盘:到期状态在写入事务里清理,读路径不写文件。 */
465
+ const request = typeof body.sessionId === 'string' ? body.sessionId : '';
466
+ return sendJson(res, 200, {
467
+ ok: true,
468
+ ...learningSnapshot(scope),
469
+ payload: { bytes: request && payloadBytes.has(request) ? payloadBytes.get(request) : null },
470
+ });
471
+ }
472
+ const id = typeof body.id === 'string' ? body.id : '';
473
+ if (learningAction === 'undo') {
474
+ const result = undoLearningChange(scope, id, {
475
+ expectedRevision: Number(body.expectedRevision || 0),
476
+ operationId: operationId || `undo_learn_${id}_${Date.now()}`,
477
+ });
478
+ return sendJson(res, result && result.ok !== false ? 200 : 409, { ...result });
479
+ }
480
+ if (learningAction === 'pin') {
481
+ const result = pinLearningRule(scope, id, {
482
+ pinned: body.pinned === true,
483
+ expectedRevision: Number(body.expectedRevision || 0),
484
+ operationId: operationId || `pin_learn_${id}_${Date.now()}`,
485
+ });
486
+ return sendJson(res, result && result.ok !== false ? 200 : 409, { ...result });
487
+ }
488
+ if (learningAction === 'adopt') {
489
+ const result = adoptLearningRule(scope, id, {
490
+ expectedRevision: Number(body.expectedRevision || 0),
491
+ title: typeof body.title === 'string' ? body.title : '',
492
+ body: typeof body.body === 'string' ? body.body : '',
493
+ operationId: operationId || `adopt_learn_${id}_${Date.now()}`,
494
+ });
495
+ return sendJson(res, result && result.ok !== false ? 200 : 409, { ...result });
496
+ }
497
+ if (learningAction === 'edit') {
498
+ const result = editLearningRule(scope, id, {
499
+ expectedRevision: Number(body.expectedRevision || 0),
500
+ title: typeof body.title === 'string' ? body.title : '',
501
+ body: typeof body.body === 'string' ? body.body : '',
502
+ operationId: operationId || `edit_learn_${id}_${Date.now()}`,
503
+ });
504
+ return sendJson(res, result && result.ok !== false ? 200 : 409, { ...result });
505
+ }
506
+ if (learningAction === 'pause') {
507
+ const ids = Array.isArray(body.ids) ? body.ids.filter((value) => typeof value === 'string').slice(0, 12) : [];
508
+ const result = pauseLearningRules(scope, ids, { operationId: operationId || `pause_learn_${Date.now()}`, by: 'management', reason: 'paused_by_user' });
509
+ return sendJson(res, result && result.ok !== false ? 200 : 409, { ...result });
510
+ }
511
+ if (learningAction === 'delete' || learningAction === 'discard') {
512
+ const result = removeLearningRule(scope, id, {
513
+ expectedRevision: Number(body.expectedRevision || 0),
514
+ operationId: operationId || `delete_learn_${id}_${Date.now()}`,
515
+ });
516
+ return sendJson(res, result && result.ok !== false ? 200 : 409, { ...result });
517
+ }
518
+ if (learningAction === 'resume') {
519
+ const result = resumeLearningRule(scope, id, { operationId: operationId || `resume_learn_${id}_${Date.now()}` });
520
+ return sendJson(res, result && result.ok !== false ? 200 : 409, { ...result });
521
+ }
522
+ return sendJson(res, 400, { ok: false, reason: '不认识这个操作' });
523
+ } catch (error) {
524
+ return sendJson(res, 409, { ok: false, reason: error instanceof Error ? error.message : '这次没有改动' });
525
+ }
526
+ }
527
+ try {
528
+ const result = typeof body.enabled === 'boolean'
529
+ ? setEvolution(scope, body.enabled, { operationId })
530
+ : { ok: true, ...evolutionStatus(scope) };
531
+ if (typeof body.enabled === 'boolean' && result && result.ok !== false) {
532
+ const controller = learningAborts.get(scope.key);
533
+ if (controller) {
534
+ try { controller.abort(); } catch { /* already gone */ }
535
+ learningAborts.delete(scope.key);
536
+ }
537
+ }
538
+ return sendJson(res, result.ok === false ? 409 : 200, {
539
+ ...result,
540
+ project: path.basename(scope.cwd || '') || scope.key,
541
+ });
542
+ } catch (error) {
543
+ return sendJson(res, 409, { ok: false, reason: error instanceof Error ? error.message : '实验开关没有改动' });
544
+ }
545
+ }
546
+
547
+ if (action === 'citations') {
548
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
549
+ if (!sessionId) return sendJson(res, 400, { ok: false, reason: '缺少 sessionId' });
550
+ return sendJson(res, 200, { ok: true, ...listCitations(sessionId) });
551
+ }
552
+
87
553
  const cwd = typeof body.cwd === 'string' ? body.cwd : '';
88
- const title = typeof body.title === 'string' ? body.title : '';
89
- if (cwd.length === 0 || title.length === 0) return sendJson(res, 400, { ok: false, reason: '请求不完整' });
554
+ if (cwd.length === 0) return sendJson(res, 400, { ok: false, reason: '请求不完整' });
90
555
  const scope = scopeOf(cwd);
91
- if (body.op === 'undo' || pathname === REMOTE_PATH + '/undo') {
92
- const record = loadIndex(scope).entries.find((item) => item.title === title);
93
- if (!record) return sendJson(res, 404, { ok: false, reason: '没有这条记忆' });
556
+ const id = typeof body.id === 'string' ? body.id : '';
557
+ const candidateId = typeof body.candidateId === 'string' && body.candidateId
558
+ ? body.candidateId
559
+ : typeof body.suggestionId === 'string' ? body.suggestionId : '';
560
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
561
+
562
+ /** 总开关关闭时面板只能看:保留、拒绝、搬移、撤销都不落盘。 */
563
+ if (!controlStatus(scope).enabled) {
564
+ return sendJson(res, 409, { ok: false, reason: '本项目记忆已关闭,先在面板打开', code: 'memory_disabled' });
565
+ }
566
+
567
+ if (action === 'move' || action === 'resume-move') {
568
+ const operationId = typeof body.operationId === 'string' ? body.operationId : '';
569
+ try {
570
+ const done = action === 'resume-move'
571
+ ? resumeMove(scope, { operationId })
572
+ : move(scope, id, { target: body.target === 'global' ? 'global' : 'project', operationId });
573
+ return sendJson(res, done.status === 'move_pending' ? 202 : 200, { ok: true, ...done });
574
+ } catch (error) {
575
+ const pending = (() => { try { return movePending(); } catch { return null; } })();
576
+ return sendJson(res, 409, {
577
+ ok: false,
578
+ reason: error instanceof Error ? error.message : '搬移失败',
579
+ pending: pending ? { operationId: pending.operationId } : null,
580
+ });
581
+ }
582
+ }
583
+
584
+ if (action === 'undo') {
585
+ if (!id) return sendJson(res, 400, { ok: false, reason: '缺少 id' });
586
+ if (!locate(scope, id)) return sendJson(res, 404, { ok: false, reason: '没有这条记忆' });
587
+ /** 撤销是精确动作:必须带当时看到的版本;缺版本就不猜,免得撤掉别人后来的修改。 */
588
+ if (!Number.isInteger(body.revision) || body.revision < 1) {
589
+ return sendJson(res, 400, { ok: false, reason: '缺少版本' });
590
+ }
94
591
  try {
95
- forget(scope, record.id);
96
- return sendJson(res, 200, { ok: true, title: record.title, id: record.id });
592
+ const undone = undoChange(scope, id, { expectedRevision: body.revision });
593
+ if (!undone) return sendJson(res, 404, { ok: false, reason: '没有这条记忆' });
594
+ return sendJson(res, 200, { ok: true, id: undone.id, mode: undone.mode });
97
595
  } catch (error) {
98
- return sendJson(res, 400, { ok: false, reason: error instanceof Error ? error.message : '删除失败' });
596
+ return sendJson(res, 409, { ok: false, reason: error instanceof Error ? error.message : '撤销失败' });
99
597
  }
100
598
  }
101
- const draft = findSuggestion(scope, title);
102
- if (!draft) return sendJson(res, 404, { ok: false, reason: '没有待保留的建议' });
599
+
600
+ if (action === 'reject') {
601
+ if (!candidateId) return sendJson(res, 400, { ok: false, reason: '缺少 candidateId' });
602
+ const dropped = rejectCandidate(scope, candidateId);
603
+ return sendJson(res, dropped.ok ? 200 : 404, dropped);
604
+ }
605
+
606
+ if (!candidateId) return sendJson(res, 400, { ok: false, reason: '缺少 candidateId' });
607
+ const operationId = typeof body.operationId === 'string' ? body.operationId : '';
103
608
  try {
104
- const saved = upsert(scope, draft);
105
- return sendJson(res, 200, { ok: true, title: saved.record.title, id: saved.record.id });
609
+ const kept = acceptCandidate(scope, candidateId, operationId);
610
+ if (!kept.ok) {
611
+ return sendJson(res, kept.reason === '候选与已有条目相近,需要确认' ? 409 : 404, kept);
612
+ }
613
+ if (kept.id) recordCitations(scope, sessionId, [{ id: kept.id, title: kept.title }]);
614
+ return sendJson(res, 200, kept);
106
615
  } catch (error) {
107
- return sendJson(res, 400, { ok: false, reason: error instanceof Error ? error.message : '保存失败' });
616
+ return sendJson(res, 409, { ok: false, reason: error instanceof Error ? error.message : '保存失败' });
108
617
  }
109
618
  };
110
619
  }
111
620
 
621
+ /** 给界面的事件引用:只有定位与版次,不带正文,避免把维护字段当内容画出来。 */
622
+ function refsOf(hits) {
623
+ return hits.map((item) => ({
624
+ id: item.id,
625
+ title: item.title || '',
626
+ scope: item.scope || 'project',
627
+ revision: item.revision || 1,
628
+ status: item.status || 'ok',
629
+ origin: item.origin === 'project_evolution' ? 'project_evolution' : 'user_explicit',
630
+ }));
631
+ }
632
+
112
633
  const SAVE_PARAMS = {
113
634
  type: 'object',
114
635
  properties: {
115
- title: { type: 'string', description: `标题:名词短语,用户一眼能认出是哪件事,不超过 ${LIMITS.titleChars} 字` },
636
+ id: { type: 'string', description: '要更新的记忆 id(来自搜索结果或相近提示)。同一件事更新时用它,别另建一条' },
637
+ title: { type: 'string', description: `标题:名词短语,用户一眼能认出是哪件事,不超过 ${LIMITS.titleChars} 字。它就是这条记忆的身份` },
116
638
  summary: { type: 'string', description: `一句话说清要记的内容本身——用户只看这一行决定留不留,名词短语,不超过 ${LIMITS.summaryChars} 字` },
117
- body: { type: 'string', description: '正文 markdown。只写指针与要点,不抄录长文档' },
118
- when: { type: 'string', description: `前置条件或适用场景(「在哪台机器 / 哪个环境 / 什么情况下」),不超过 ${LIMITS.whenChars} 字。同一主题但前置条件不同,会存成两条,不合并` },
119
- kind: { type: 'string', enum: ['preference', 'convention', 'procedure', 'pointer', 'pitfall', 'credential_hint', 'note'], description: '条目类型' },
120
- tags: { type: 'array', items: { type: 'string' }, description: '最多 5 个标签' },
639
+ body: { type: 'string', description: `正文 markdown,整段替换旧正文,不超过 ${LIMITS.entryBytes} 字节。只写原则、判断规则、触发条件和边界;不写像素、字号、层级、颜色、一次性数字或代码片段,这些查当前代码或文档。` },
640
+ when: { type: 'string', description: `前置条件或适用场景(「在哪台机器 / 哪个环境 / 什么情况下」),不超过 ${LIMITS.whenChars} 字。同一标题但前置条件不同,会存成两条,不合并;更新时不给就保留原值` },
641
+ kind: { type: 'string', enum: ['preference', 'convention', 'procedure', 'pointer', 'pitfall', 'credential_hint', 'note'], description: '条目类型;更新时不给就保留原值' },
642
+ tags: { type: 'array', items: { type: 'string' }, description: '最多 5 个标签;更新时不给就保留原值' },
643
+ resolution: {
644
+ type: 'string',
645
+ enum: ['distinct'],
646
+ description: '明确表示「这确实是另一条」。返回 needsResolution 时,只有确认不是同一件事才带上它再存一次',
647
+ },
648
+ scope: { type: 'string', enum: ['auto', 'project', 'global'], description: '存哪一层:auto 按记忆类别判定(默认);用户明确说「以后所有项目都这样」用 global;明确说只在这个项目用 project' },
649
+ memoryClass: { type: 'string', enum: ['personal_preference', 'project_fact', 'environment_fact', 'portable_procedure'], description: '归属提案:个人偏好 / 项目事实 / 设备事实 / 可搬流程。服务端据此判定存哪层;设备事实必须同时给设备条件' },
650
+ durability: { type: 'string', enum: ['durable', 'task'], description: '一般认为 durable;只是一次性任务参数用 task,服务端会拒绝落盘' },
651
+ evidenceRef: { type: 'string', description: '归属判定的依据(哪条消息、哪个文件、哪次观察);用户没说范围时用来判断能不能存' },
652
+ sameTopicAs: {
653
+ type: 'object',
654
+ description: '显式绑定到已有主题槽位:{scope, id, revision}。版本不符或目标不存在就报错,不写',
655
+ properties: {
656
+ scope: { type: 'string', enum: ['project', 'global'] },
657
+ id: { type: 'string' },
658
+ revision: { type: 'integer' },
659
+ },
660
+ required: ['scope', 'id', 'revision'],
661
+ additionalProperties: false,
662
+ },
663
+ mode: {
664
+ type: 'string',
665
+ enum: ['patch', 'replace_current'],
666
+ description: '默认 patch:没给的字段保持原值。要整条换掉时用 replace_current,并把 title/summary/when/body/kind/tags 全部显式给出(空值也要写),不继承旧值',
667
+ },
668
+ applicability: {
669
+ type: 'string',
670
+ enum: view.APPLICABILITY,
671
+ description: '适用范围:all_in_scope 普遍适用;conditional 条件适用(必须给 conditions);unresolved 还没定(有 when 但没结构化条件时默认这个)。不给就按字段推断',
672
+ },
673
+ conditions: {
674
+ type: 'array',
675
+ description: '条件适用的具体条件,最多 8 条;给了空数组就是清空',
676
+ items: {
677
+ type: 'object',
678
+ properties: {
679
+ key: { type: 'string', enum: view.CONDITION_KEYS },
680
+ value: { type: 'string' },
681
+ },
682
+ required: ['key', 'value'],
683
+ additionalProperties: false,
684
+ },
685
+ },
686
+ selection: {
687
+ type: ['object', 'null'],
688
+ description: '只对某个产物成立的规则:complete 表示清单完整(必须给 artifactKey),examples 只是举例。null 表示清空',
689
+ properties: {
690
+ mode: { type: 'string', enum: view.SELECTION_MODES },
691
+ artifactKey: { type: 'string' },
692
+ items: { type: 'array', items: { type: 'string' }, description: 'examples 模式下的举例清单' },
693
+ },
694
+ required: ['mode'],
695
+ additionalProperties: false,
696
+ },
697
+ links: {
698
+ type: 'array',
699
+ description: '相关记忆,最多 3 条,只表示同类相关;目标必须当前存在。给空数组就是清空',
700
+ items: {
701
+ type: 'object',
702
+ properties: {
703
+ type: { type: 'string', enum: view.LINK_TYPES, description: '默认 related' },
704
+ id: { type: 'string', pattern: '^mem_[a-z0-9]{6,}$' },
705
+ },
706
+ required: ['id'],
707
+ additionalProperties: false,
708
+ },
709
+ },
710
+ triggers: {
711
+ type: 'array',
712
+ items: { type: 'string' },
713
+ description: '自动召回用的固定触发词,最多 4 个;给空数组就是清空',
714
+ },
715
+ topicKey: { type: ['string', 'null'], description: '主题槽位,用来判断同一件事避免重复;不给就保持原值,null 表示清空' },
121
716
  },
122
717
  required: ['title', 'summary'],
123
718
  additionalProperties: false,
124
719
  };
125
720
 
126
- const SUGGEST_PARAMS = SAVE_PARAMS;
721
+ const SUGGEST_PARAMS = {
722
+ ...SAVE_PARAMS,
723
+ properties: Object.fromEntries(Object.entries(SAVE_PARAMS.properties).filter(([key]) => !['scope', 'id', 'resolution', 'memoryClass', 'durability', 'evidenceRef', 'sameTopicAs'].includes(key))),
724
+ };
127
725
 
128
726
  const SEARCH_PARAMS = {
129
727
  type: 'object',
130
728
  properties: {
131
- query: { type: 'string', description: '要查的主题;用用户原话里的词' },
132
- limit: { type: 'integer', minimum: 1, maximum: 6, description: '最多返回几条,默认 4' },
729
+ query: { type: 'string', description: '要查的主题;用用户原话里的词,长句也行' },
730
+ limit: { type: 'integer', minimum: 1, maximum: 4, description: '最多返回几条,默认 3' },
731
+ history: { type: 'boolean', description: '连已归档的旧记录一起找;默认只看当前有效的,旧修正不混进正常检索' },
732
+ contextId: { type: 'string', description: '本请求已经给出的任务上下文 id;只有上一轮自动索引或工具结果里出现过才填' },
733
+ bindForRequest: { type: 'boolean', description: '把这次选择绑定为本请求的任务;有 contextId 就带上。默认 false,只做普通检索' },
133
734
  },
134
735
  required: ['query'],
135
736
  additionalProperties: false,
@@ -137,117 +738,873 @@ const SEARCH_PARAMS = {
137
738
 
138
739
  const READ_PARAMS = {
139
740
  type: 'object',
140
- properties: { id: { type: 'string', description: '记忆 id,来自搜索结果' } },
741
+ properties: {
742
+ id: { type: 'string', description: '记忆 id,来自搜索结果' },
743
+ view: { type: 'string', enum: ['full', 'links'], description: '默认 full 读正文;links 只看直接相关的记忆清单,不取正文' },
744
+ contextId: { type: 'string', description: '上一轮自动索引或工具结果给出的任务上下文 id;读实验规则时带上' },
745
+ },
141
746
  required: ['id'],
142
747
  additionalProperties: false,
143
748
  };
144
749
 
145
750
  const FORGET_PARAMS = {
146
751
  type: 'object',
147
- properties: { id: { type: 'string', description: '要移入归档的记忆 id' } },
752
+ properties: {
753
+ id: { type: 'string', description: '要忘掉的记忆 id' },
754
+ mode: {
755
+ type: 'string',
756
+ enum: ['archive', 'purge'],
757
+ description: 'archive 移入归档可找回(只是不想再提);purge 彻底忘掉,正文与受管副本清除且不可复活。没点明是哪种就两种都别做,先问用户',
758
+ },
759
+ },
148
760
  required: ['id'],
149
761
  additionalProperties: false,
150
762
  };
151
763
 
152
- function text(blocks) {
153
- return blocks.map((block) => (block.type === 'text' ? block.text : JSON.stringify(block)));
764
+ /** 记忆归属标记:全局 `[global]`,项目用项目目录名(如 `[DSH]`)。 */
765
+ function projectNameOf(scope) {
766
+ const base = scope && scope.cwd ? path.basename(scope.cwd) : '';
767
+ return String(base || '').replace(/[^\w.\-\u4e00-\u9fff]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 16);
768
+ }
769
+
770
+ function scopeTag(value) {
771
+ if (value && value.scope === 'global') return '[global]';
772
+ if (value && value.scopeName) return `[${value.scopeName}]`;
773
+ return '';
774
+ }
775
+
776
+ function withTag(value, text) {
777
+ const tag = scopeTag(value);
778
+ return tag ? `${tag} ${text}` : text;
154
779
  }
155
780
 
156
- export function apply(ctx) {
781
+ function statusLabel(status) {
782
+ if (status === 'archived') return ' · 已归档';
783
+ if (status === 'needs_context') return ' · 适用范围待确认';
784
+ if (status === 'not_applicable') return ' · 当前条件不适用';
785
+ if (status === 'gone') return ' · 已删除';
786
+ if (status && status !== 'ok') return ` · ${status}`;
787
+ return '';
788
+ }
789
+
790
+ /**
791
+ * 自动召回:用户这句话真跟已有记忆有关才给一张短卡,整条索引不常驻。
792
+ * 策略、预算与冻结快照都在 apply 内部,provider 只读内存。
793
+ */
794
+ export const RECALL_MODES = ['adaptive', 'tool_only', 'off'];
795
+ const RECALL_QUERY_CHARS = 400;
796
+ const RECALL_DEADLINE_MS = 150;
797
+ const RECALL_HEADER = '已有记忆可能相关(需要细节用 memory_read 取正文,别凭标题猜):';
798
+
799
+ /** 卡片只放够判断要不要读的几行;必要前提整条在场,装不下就整卡省略。 */
800
+ function renderRecallCard(card) {
801
+ const tag = card.scope === 'global' ? '[global]' : card.scopeName ? `[${card.scopeName}]` : '';
802
+ const parts = [`- ${tag ? `${tag} ` : ''}「${card.title}」 · ${card.id}`];
803
+ if (card.summary) parts.push(`|${card.summary}`);
804
+ if (card.conditions && card.conditions.length) parts.push(`|前提:${card.conditions.join('、')}`);
805
+ if (card.selection) parts.push(`|只适用于 ${card.selection}`);
806
+ if (card.related) parts.push(`|关联 ${card.related} 条`);
807
+ return parts.join('');
808
+ }
809
+
810
+ export function apply(ctx, config) {
157
811
  const tools = ctx.tools;
812
+ const getService = (name) => (typeof ctx.get === 'function' ? ctx.get(name) : undefined);
813
+
814
+ /** 关掉记忆的会话看不到这组工具;每次进入新步骤前按当前开关同步一次。 */
815
+ const hiddenTools = new WeakMap();
816
+ const memoryOnForAgent = (agent) => {
817
+ try {
818
+ return memoryEnabled(scopeOf(cwdOf({ agent })));
819
+ } catch {
820
+ return true;
821
+ }
822
+ };
823
+ const syncMemoryTools = (agent) => {
824
+ const scoped = agent?.ctx?.tools;
825
+ if (!scoped) return;
826
+ const dispose = hiddenTools.get(agent);
827
+ if (memoryOnForAgent(agent)) {
828
+ if (!dispose) return;
829
+ hiddenTools.delete(agent);
830
+ try { dispose(); } catch { /* scope already gone */ }
831
+ return;
832
+ }
833
+ if (dispose) return;
834
+ try { hiddenTools.set(agent, scoped.restrict({ deny: MEMORY_TOOLS })); } catch { /* no global tools to mask */ }
835
+ };
836
+
837
+ /* ---------------- 自动召回:策略、冻结快照、任务窗口与请求预算 ---------------- */
838
+
839
+ /** 升级不覆盖用户的选择:默认 adaptive,别的模式只由明确配置切换。 */
840
+ const recallMode = () => (RECALL_MODES.includes(config?.mode) ? config.mode : 'adaptive');
841
+ /** 已准备好的快照只挂在 Agent 上,弱引用随手回收,不跨会话串。 */
842
+ const prepared = new WeakMap();
843
+ /** turn-stopping 只回 signal,不带头 agent;这里记 signal → agent 供收尾清理。 */
844
+ const signalAgents = new Map();
845
+ const generations = new WeakMap();
846
+ /** 同一真人请求共用一个额度窗口;provider 只读,不记账。 */
847
+ const windows = new Map();
848
+ const abortCleanups = new Map();
849
+ /** 哪些用户消息已经建立过窗口;工具续步不重复开窗。 */
850
+ const seenHumans = new Map();
851
+ /** 本轮真人反馈先记在这里,等 turn/end 再决定要不要整理;不跨会话。 */
852
+ const feedbackCandidates = new Map();
853
+ /** 项目内反馈代次:每来一次新的可整理反馈就加一,旧整理结果必须作废。 */
854
+ const feedbackRevisions = new Map();
855
+ /** 一个项目同一时间只整理一次;控制器放在模块级供开关路由中止。 */
856
+ const learningJobs = new Map();
857
+
858
+ const agentKey = (agent) => String(agent?.id ?? '');
859
+
860
+ const clearPrepared = (agent) => {
861
+ if (!agent) return;
862
+ const state = prepared.get(agent);
863
+ if (state && state.signal) signalAgents.delete(state.signal);
864
+ prepared.delete(agent);
865
+ const key = agentKey(agent);
866
+ const cleanup = key ? abortCleanups.get(key) : null;
867
+ if (cleanup) {
868
+ abortCleanups.delete(key);
869
+ try { cleanup(); } catch { /* listener already gone */ }
870
+ }
871
+ };
872
+
873
+ const windowOf = (agent) => windows.get(agentKey(agent)) || null;
874
+
875
+ /** 预算按规范化 JSON 字节算:转义、中文、换行都算进模型真正收到的内容。 */
876
+ const jsonBytes = (value) => Buffer.byteLength(JSON.stringify(value));
877
+
878
+ function affordable(win, bytes, bodies = 0) {
879
+ if (!win) return true;
880
+ if (win.reads >= view.REQUEST_LIMITS.reads) return false;
881
+ if (bodies && win.bodies + bodies > view.REQUEST_LIMITS.bodies) return false;
882
+ return win.bytes + bytes <= view.REQUEST_BYTES;
883
+ }
884
+
885
+ function openWindow(agent, { sessionId, turn, messageIds }) {
886
+ const key = agentKey(agent);
887
+ if (!key) return null;
888
+ const signature = `${sessionId}\u0000${turn}\u0000${messageIds.join(',')}`;
889
+ const existing = windows.get(key);
890
+ if (existing && existing.signature === signature) return existing;
891
+ const next = {
892
+ signature, sessionId, turn, messageIds,
893
+ bytes: 0, bodies: 0, reads: 0,
894
+ provided: new Map(), readBodies: new Map(), at: Date.now(),
895
+ experiment: null, contexts: new Map(),
896
+ };
897
+ windows.set(key, next);
898
+ return next;
899
+ }
900
+
901
+ /** 新消息才会建立新窗口;同一批消息在同一 turn 里只开一次。 */
902
+ function freshHumans(sessionId, humans) {
903
+ if (!humans.length) return [];
904
+ const seen = seenHumans.get(sessionId) || new Set();
905
+ const fresh = humans.filter((message) => {
906
+ const id = String(message?.id ?? '');
907
+ return id && !seen.has(id);
908
+ });
909
+ for (const message of humans) {
910
+ const id = String(message?.id ?? '');
911
+ if (id) seen.add(id);
912
+ }
913
+ while (seen.size > 400) seen.delete(seen.values().next().value);
914
+ seenHumans.set(sessionId, seen);
915
+ while (seenHumans.size > 64) seenHumans.delete(seenHumans.keys().next().value);
916
+ return fresh;
917
+ }
918
+
919
+ /** 两层都算:任何一层换库、换代或清空,之前准备的卡片都要失效。 */
920
+ function fingerprintOf(scope) {
921
+ try {
922
+ return [scope, globalScopeView()].map((source) => {
923
+ const index = loadIndex(source);
924
+ const meta = index.__meta || {};
925
+ const evolutionEpoch = index.__evolution && index.__evolution.epoch ? index.__evolution.epoch : '';
926
+ const controlEpoch = index.__control && index.__control.epoch ? index.__control.epoch : '';
927
+ return `${index.scope}:${meta.storeId || ''}:${index.generation}:${index.epoch}:${evolutionEpoch}:${controlEpoch}`;
928
+ }).join('|');
929
+ } catch {
930
+ return '';
931
+ }
932
+ }
933
+
934
+ const globalScopeView = () => globalScope();
935
+
936
+ /** 纯内存:读两层当前投影,先判适用再排序,整卡装预算。 */
937
+ function projectRecall(scope, query, context, win) {
938
+ const layers = { project: [], global: [] };
939
+ const seen = new Set();
940
+ for (const source of [scope, globalScope()]) {
941
+ const layer = source.key === 'global' ? 'global' : 'project';
942
+ let index;
943
+ try { index = loadIndex(source); } catch { continue; }
944
+ for (const item of index.entries) {
945
+ if (!item || !item.title || item.status === 'archived') continue;
946
+ /** 实验来源只走任务绑定后的专用读取,不混进普通自动召回。 */
947
+ if (item.origin === 'project_evolution') continue;
948
+ if (seen.has(item.id)) continue;
949
+ seen.add(item.id);
950
+ layers[layer].push(item);
951
+ }
952
+ }
953
+ /** 同一槽位只让胜出的规范进去;冲突或条件未知的整组不进自动召回。 */
954
+ const records = [];
955
+ const layerOf = new Map();
956
+ for (const group of view.resolveTopics(layers.project, layers.global, context).values()) {
957
+ if (group.status !== 'ok' || !group.record) continue;
958
+ records.push(group.record);
959
+ layerOf.set(group.record.id, group.scope === 'global' ? 'global' : 'project');
960
+ }
961
+ const ranked = view.rank(records, query, { limit: 8, requireApplicable: true, context });
962
+ const headerBytes = Buffer.byteLength(RECALL_HEADER);
963
+ let used = headerBytes;
964
+ /** 卡片上限含包装;共享请求账本也要把标题行算进去,不能只算卡片行。 */
965
+ let headerCharged = win ? win.recallHeader === true : false;
966
+ const lines = [];
967
+ const cards = [];
968
+ for (const row of ranked) {
969
+ if (cards.length >= view.CARD.max) break;
970
+ const card = view.cardOf(row.record, layerOf.get(row.record.id));
971
+ if (card.scope === 'project') card.scopeName = projectNameOf(scope);
972
+ const line = renderRecallCard(card);
973
+ const size = Buffer.byteLength(`\n${line}`);
974
+ if (used + size > view.CARD.bytes) continue;
975
+ const revision = card.revision || 1;
976
+ const already = win ? win.provided.get(card.id) === revision : false;
977
+ if (win && !already) {
978
+ const cost = size + (headerCharged ? 0 : headerBytes);
979
+ if (!affordable(win, cost)) continue;
980
+ win.bytes += cost;
981
+ win.provided.set(card.id, revision);
982
+ if (!headerCharged) {
983
+ headerCharged = true;
984
+ win.recallHeader = true;
985
+ }
986
+ }
987
+ used += size;
988
+ lines.push(line);
989
+ cards.push({ id: card.id, revision, scope: card.scope, title: card.title });
990
+ }
991
+ if (!cards.length) return null;
992
+ return { text: `${RECALL_HEADER}\n${lines.join('\n')}`, cards, bytes: used, query };
993
+ }
994
+
995
+ /* ---------------- 项目自进化:任务锚点、读取决策、上下文解析 ---------------- */
996
+
997
+ const EXPERIMENT_INDEX_HEADER = '项目里这些记忆范围可能适用当前任务(未绑定);需要细节再用 memory_search 带上 contextId:';
998
+ const EXPERIMENT_RULES_HEADER = '项目实验规则(只在当前任务条件匹配时适用;细节用 memory_read 取正文,别凭标题猜):';
999
+ const EXPERIMENT_BYTES = 1200;
1000
+ const EXPERIMENT_SHORT_CHARS = 240;
1001
+
1002
+ /** 登记过的栏目只能来自项目里已有的条件、默认值和实验条目,不从自由措辞里新造。 */
1003
+ function rangesOf(scope) {
1004
+ let index;
1005
+ try { index = loadIndex(scope); } catch { return { series: [], taskKinds: [], taskKind: '', seriesId: '' }; }
1006
+ const series = [];
1007
+ const taskKinds = [];
1008
+ const add = (list, value) => {
1009
+ const text = typeof value === 'string' ? value.trim().slice(0, 40) : '';
1010
+ if (text && !list.includes(text) && list.length < 8) list.push(text);
1011
+ };
1012
+ for (const record of [...index.entries, ...index.archived]) {
1013
+ if (record.reviewState === 'legacy_unreviewed') continue;
1014
+ for (const condition of record.conditions || []) {
1015
+ if (condition.key === 'seriesId') add(series, condition.value);
1016
+ if (condition.key === 'taskKind') add(taskKinds, condition.value);
1017
+ }
1018
+ const learning = record.learning || {};
1019
+ if (learning.seriesId) add(series, learning.seriesId);
1020
+ if (learning.taskKind) add(taskKinds, learning.taskKind);
1021
+ }
1022
+ const defaults = index.__tasks && index.__tasks.defaults ? index.__tasks.defaults : null;
1023
+ if (defaults) {
1024
+ add(series, defaults.seriesId);
1025
+ add(taskKinds, defaults.taskKind);
1026
+ }
1027
+ return {
1028
+ series, taskKinds,
1029
+ seriesId: defaults && defaults.seriesId ? defaults.seriesId : '',
1030
+ taskKind: defaults && defaults.taskKind ? defaults.taskKind : '',
1031
+ };
1032
+ }
1033
+
1034
+ function conditionLabel(conditions) {
1035
+ return (Array.isArray(conditions) ? conditions : [])
1036
+ .map((condition) => `${condition.key}=${condition.value}`)
1037
+ .join('、');
1038
+ }
1039
+
1040
+ /** 实验内容单独记账:同一请求里再次渲染只补差额,不重复占满预算。 */
1041
+ function chargeExperiment(win, text) {
1042
+ if (!text) return '';
1043
+ const bytes = Buffer.byteLength(`\n\n${text}`);
1044
+ const previous = win ? (win.experimentBytes || 0) : 0;
1045
+ if (bytes > EXPERIMENT_BYTES) return '';
1046
+ if (win) {
1047
+ if (!affordable(win, bytes - previous)) return '';
1048
+ win.bytes += bytes - previous;
1049
+ win.experimentBytes = bytes;
1050
+ }
1051
+ return text;
1052
+ }
1053
+
1054
+ /** 本轮问题与栏目名的字面相关度:只展示有交集的栏目,交集为空就整段跳过。 */
1055
+ function relevantRanges(names, query) {
1056
+ const text = String(query || '').toLowerCase();
1057
+ if (!text) return [];
1058
+ const tokens = new Set();
1059
+ for (const word of text.match(/[a-z][a-z0-9_-]{2,}/g) || []) tokens.add(word);
1060
+ for (const run of text.match(/[\u4e00-\u9fff]+/g) || []) {
1061
+ for (let i = 0; i + 2 <= run.length; i += 1) tokens.add(run.slice(i, i + 2));
1062
+ }
1063
+ if (!tokens.size) return [];
1064
+ return (Array.isArray(names) ? names : []).filter((name) => {
1065
+ const value = String(name || '').toLowerCase();
1066
+ if (!value) return false;
1067
+ if (text.includes(value)) return true;
1068
+ for (const token of tokens) if (value.includes(token)) return true;
1069
+ return false;
1070
+ });
1071
+ }
1072
+
1073
+ /** 读决策 → 一段短提示:未绑定只给栏目索引,已绑定才给规则正文。 */
1074
+ function renderExperiment(scope, decision, context, held, win) {
1075
+ if (!decision || decision.kind === 'none') return '';
1076
+ if (decision.kind === 'conflict') {
1077
+ return chargeExperiment(win, '这些实验规则互相冲突,先按用户这次说的做,别叠加对立的规则。');
1078
+ }
1079
+ if (decision.kind === 'index' || decision.kind === 'clarify') {
1080
+ const names = relevantRanges(Array.isArray(decision.ranges) ? decision.ranges.filter(Boolean) : [], held && held.query).slice(0, 3);
1081
+ /** 没有适用栏目就什么也不注入:不拿无关范围冒充当前任务,也不催用户选栏目。 */
1082
+ if (!names.length) return '';
1083
+ const tail = held && held.contextId ? `\ncontextId=${held.contextId}` : '';
1084
+ return chargeExperiment(win, `${EXPERIMENT_INDEX_HEADER}\n${names.map((name) => `- ${name}`).join('\n')}${tail}`);
1085
+ }
1086
+ let index;
1087
+ try { index = loadIndex(scope); } catch { return ''; }
1088
+ const matched = [];
1089
+ const unknown = [];
1090
+ const byTopic = new Map();
1091
+ let conflict = false;
1092
+ for (const record of index.entries) {
1093
+ if (record.origin !== 'project_evolution') continue;
1094
+ if (record.ownerProjectId && record.ownerProjectId !== scope.key) continue;
1095
+ if (record.reviewState === 'legacy_unreviewed') continue;
1096
+ const learning = record.learning && typeof record.learning === 'object' ? record.learning : {};
1097
+ if (learning.state !== 'active' && learning.state !== 'trial') continue;
1098
+ const status = view.statusOf(record, context);
1099
+ if (status === 'needs_context') { unknown.push(record); continue; }
1100
+ if (status !== 'ok') continue;
1101
+ const topic = record.topicKey || record.id;
1102
+ if (byTopic.has(topic) && byTopic.get(topic).body !== record.body) conflict = true;
1103
+ byTopic.set(topic, record);
1104
+ matched.push(record);
1105
+ }
1106
+ if (conflict) {
1107
+ return chargeExperiment(win, '这些实验规则互相冲突,先按用户这次说的做,别叠加对立的规则。');
1108
+ }
1109
+ const lines = [];
1110
+ const shown = [];
1111
+ let used = Buffer.byteLength(EXPERIMENT_RULES_HEADER) + 1;
1112
+ for (const record of matched.slice(0, 3)) {
1113
+ const body = String(record.body || '');
1114
+ const short = body && body.length <= EXPERIMENT_SHORT_CHARS && body.split('\n').length <= 4;
1115
+ const conditions = conditionLabel(record.conditions);
1116
+ const checkpoint = record.learning && Array.isArray(record.learning.checkpoints) ? record.learning.checkpoints[0] : null;
1117
+ const check = checkpoint && checkpoint.text
1118
+ ? `;核对:${String(checkpoint.text).slice(0, 40)}`
1119
+ : '';
1120
+ const line = short
1121
+ ? `- 「${record.title}」${conditions ? `(${conditions})` : ''} · ${record.id}:${body}${check}`
1122
+ : `- 「${record.title}」 · ${record.id}(用途:${record.summary || record.title};要用时 memory_read 取正文)`;
1123
+ const size = Buffer.byteLength(`\n${line}`);
1124
+ if (used + size > EXPERIMENT_BYTES) break;
1125
+ used += size;
1126
+ lines.push(line);
1127
+ shown.push(record.id);
1128
+ }
1129
+ if (held) held.injectedIds = shown;
1130
+ if (!lines.length && unknown.length) {
1131
+ const nameLine = unknown.slice(0, 3).map((record) => `- 「${record.title}」 · ${record.id}`).join('\n');
1132
+ return chargeExperiment(win, `有些实验规则的适用条件还没确认,先别当成本轮依据:\n${nameLine}`);
1133
+ }
1134
+ if (!lines.length) return '';
1135
+ const tail = held && held.contextId ? `\ncontextId=${held.contextId}` : '';
1136
+ return chargeExperiment(win, `${EXPERIMENT_RULES_HEADER}\n${lines.join('\n')}${tail}`);
1137
+ }
1138
+
1139
+ /** 一次真人请求只路由一次;同一批次重试复用同一 transitionId。 */
1140
+ function experimentFor(scope, { sessionId, query, batchKey, messageIds, win }) {
1141
+ if (!win || !batchKey) return null;
1142
+ let status;
1143
+ try { status = evolutionStatus(scope); } catch { return null; }
1144
+ if (!status || status.enabled !== true || status.compatible === false) return null;
1145
+ if (scope.confirmed !== true) return null;
1146
+ let tasks;
1147
+ try { tasks = taskState(scope); } catch { return null; }
1148
+ const ranges = rangesOf(scope);
1149
+ const routed = anchor.routeTask(tasks, {
1150
+ projectId: scope.key,
1151
+ sessionId,
1152
+ batchKey,
1153
+ messageIds,
1154
+ query,
1155
+ registeredSeries: ranges.series,
1156
+ defaults: tasks.defaults,
1157
+ now: Date.now(),
1158
+ });
1159
+ if (routed.changed) {
1160
+ const saved = saveTaskState(scope, routed.tasks, { operationId: `task_${batchKey}` });
1161
+ if (!saved.ok) return null;
1162
+ }
1163
+ const context = view.trustedContext(scope, anchor.contextOfAnchor(routed.anchor, {}));
1164
+ const thanks = anchor.isThanks(query);
1165
+ const decision = anchor.decideRead({
1166
+ mode: recallMode(),
1167
+ enabled: true,
1168
+ identityConfirmed: true,
1169
+ anchor: routed.anchor,
1170
+ ranges: [...ranges.taskKinds, ...ranges.series],
1171
+ thanks,
1172
+ boundForRequest: false,
1173
+ });
1174
+ const contextId = anchor.contextIdFor(scope.key, batchKey, crypto.randomUUID().slice(0, 8));
1175
+ const episode = routed.tasks && routed.tasks.episodes ? routed.tasks.episodes[routed.anchor.episodeId] : null;
1176
+ let epoch = '';
1177
+ try { epoch = String(evolutionStatus(scope).epoch || ''); } catch { epoch = ''; }
1178
+ const held = {
1179
+ contextId, projectKey: scope.key, sessionId, batchKey, messageIds, query,
1180
+ context, anchor: routed.anchor, ranges, decision, epoch,
1181
+ episodeUpdatedAt: episode ? episode.updatedAt : '', injectedIds: [],
1182
+ at: Date.now(),
1183
+ };
1184
+ win.experiment = held;
1185
+ win.contexts.set(contextId, held);
1186
+ return { text: renderExperiment(scope, decision, context, held, win), decision, held };
1187
+ }
1188
+
1189
+ /** 自动路径与工具共用一次解析:没有本轮上下文就退回普通检索,绝不猜一个空上下文。 */
1190
+ function resolveRequestContext(execution, optionalContextId) {
1191
+ const scope = scopeOf(cwdOf(execution));
1192
+ const win = windowOf(execution && execution.agent);
1193
+ const contextId = typeof optionalContextId === 'string' ? optionalContextId.trim() : '';
1194
+ if (!contextId) {
1195
+ return { ok: true, scope, win, context: view.trustedContext(scope, {}), experimental: false };
1196
+ }
1197
+ /** 先认项目再找本地记录:别的项目的上下文要明确拒绝,不能只报过期。 */
1198
+ if (anchor.projectTagOf(contextId) !== anchor.projectTag(scope.key)) {
1199
+ return { ok: false, status: 'needs_context', reason: '这个上下文属于另一个项目' };
1200
+ }
1201
+ if (!win) return { ok: false, status: 'needs_context', reason: '这个请求的上下文已经过期,先重新问一次当前任务' };
1202
+ const held = win.contexts.get(contextId);
1203
+ if (!held) return { ok: false, status: 'needs_context', reason: '这个上下文已经过期,先重新问一次当前任务' };
1204
+ /** 凭据只在当轮、当次开关代次内有效:换轮、关闭、重开或过期都不再放行旧正文。 */
1205
+ const expired = (reason) => {
1206
+ win.contexts.delete(contextId);
1207
+ if (win.experiment && win.experiment.contextId === contextId) win.experiment = null;
1208
+ return { ok: false, status: 'needs_context', reason };
1209
+ };
1210
+ if (held.projectKey !== scope.key) return expired('这个上下文属于另一个项目');
1211
+ if (held.sessionId && held.sessionId !== String(win.sessionId || '')) return expired('这个上下文已经过期,先重新问一次当前任务');
1212
+ if (!held.batchKey || held.batchKey !== anchor.requestBatchKey(win.messageIds || [])) {
1213
+ return expired('这个上下文已经过期,先重新问一次当前任务');
1214
+ }
1215
+ let evolution;
1216
+ try { evolution = evolutionStatus(scope); } catch { return expired('当前项目状态读不出来,先重新问一次当前任务'); }
1217
+ if (!evolution || evolution.enabled !== true || evolution.compatible === false) {
1218
+ return expired('项目自进化已经关闭,先重新问一次当前任务');
1219
+ }
1220
+ if (held.epoch && evolution.epoch !== held.epoch) {
1221
+ return expired('项目自进化重新开过,先重新问一次当前任务');
1222
+ }
1223
+ return { ok: true, scope, win, context: held.context, experimental: true, held, contextId };
1224
+ }
1225
+
1226
+ /** 工具模式没有自动索引时,按本轮真人消息现开一个请求上下文,只按问题与记忆范围的字面关系判断。 */
1227
+ function toolContextFor(execution) {
1228
+ const win = windowOf(execution && execution.agent);
1229
+ if (!win || !win.sessionId || !Array.isArray(win.messageIds) || win.messageIds.length === 0) return '';
1230
+ const scope = scopeOf(cwdOf(execution));
1231
+ if (scope.confirmed !== true) return '';
1232
+ let status;
1233
+ try { status = evolutionStatus(scope); } catch { return ''; }
1234
+ if (!status || status.enabled !== true || status.compatible === false) return '';
1235
+ const batchKey = anchor.requestBatchKey(win.messageIds);
1236
+ const held = win.experiment && win.experiment.batchKey === batchKey && win.experiment.epoch === status.epoch
1237
+ ? win.experiment
1238
+ : null;
1239
+ if (held) return held.contextId;
1240
+ const contextId = anchor.contextIdFor(scope.key, batchKey, crypto.randomUUID().slice(0, 8));
1241
+ const created = {
1242
+ contextId, projectKey: scope.key, sessionId: win.sessionId, batchKey,
1243
+ messageIds: win.messageIds.slice(), context: view.trustedContext(scope, {}),
1244
+ anchor: null, ranges: rangesOf(scope), decision: { kind: 'none' }, epoch: status.epoch, at: Date.now(),
1245
+ };
1246
+ win.experiment = created;
1247
+ win.contexts.set(contextId, created);
1248
+ return contextId;
1249
+ }
1250
+
1251
+ /** 前台绑定只升到 proposed:真正执行链还没发生,不冒充 active。 */
1252
+ function bindSelection(execution, resolved, query) {
1253
+ const held = resolved.held;
1254
+ const ranges = held.ranges || { series: [], taskKinds: [] };
1255
+ const hits = [];
1256
+ for (const value of ranges.series || []) if (query.includes(value)) hits.push({ seriesId: value });
1257
+ for (const value of ranges.taskKinds || []) if (query.includes(value)) hits.push({ taskKind: value });
1258
+ if (hits.length !== 1) {
1259
+ const names = [...(ranges.series || []), ...(ranges.taskKinds || [])].slice(0, 3);
1260
+ return { ok: false, status: 'needs_context', reason: names.length ? '本轮没有匹配的记忆范围,按当前任务继续' : '本轮没有适用的记忆范围,按当前任务继续' };
1261
+ }
1262
+ let tasks;
1263
+ try { tasks = taskState(resolved.scope); } catch { return { ok: false, status: 'needs_context', reason: '任务状态还没读出来' }; }
1264
+ const applied = anchor.applySelection(tasks, {
1265
+ sessionId: held.sessionId,
1266
+ selection: hits[0],
1267
+ batchKey: held.batchKey,
1268
+ messageIds: held.messageIds,
1269
+ now: Date.now(),
1270
+ });
1271
+ const saved = saveTaskState(resolved.scope, applied.tasks, { operationId: `bind_${held.batchKey}` });
1272
+ if (!saved.ok) return { ok: false, status: 'needs_context', reason: saved.reason || '任务选择没有保存' };
1273
+ held.anchor = applied.anchor;
1274
+ held.context = view.trustedContext(resolved.scope, anchor.contextOfAnchor(applied.anchor, {}));
1275
+ held.decision = { kind: 'rules', reason: 'bound' };
1276
+ resolved.context = held.context;
1277
+ const text = renderExperiment(resolved.scope, held.decision, held.context, held, resolved.win);
1278
+ const preparedState = prepared.get(execution && execution.agent);
1279
+ if (preparedState) preparedState.experimentText = text;
1280
+ return { ok: true };
1281
+ }
1282
+
1283
+ /** provider 只读内存快照:不查盘、不搜索、不写 hits、不动 lastUsed。 */
1284
+ function recallText(holder) {
1285
+ try {
1286
+ const agent = holder?.agent ?? getService('agents')?.currentInitiator?.();
1287
+ if (!agent) return '';
1288
+ const state = prepared.get(agent);
1289
+ if (!state) return '';
1290
+ const signal = holder?.signal;
1291
+ if (signal?.aborted) return '';
1292
+ if (state.signal && signal && state.signal !== signal) return '';
1293
+ const sessionId = agent?.session?.id;
1294
+ if (state.sessionId !== (typeof sessionId === 'string' ? sessionId : '')) return '';
1295
+ /** 开关/自进化换代后,关掉前准备好的正文一律不再输出。 */
1296
+ const scope = scopeOf(cwdOf({ agent }));
1297
+ if (!memoryEnabled(scope)) return '';
1298
+ const epoch = controlStatus(scope).epoch || '';
1299
+ if (state.controlEpoch && epoch && state.controlEpoch !== epoch) return '';
1300
+ return [state.experimentText, state.text].filter(Boolean).join('\n\n');
1301
+ } catch {
1302
+ return '';
1303
+ }
1304
+ }
1305
+
158
1306
 
159
1307
  ctx.effect(() => tools.register({
160
1308
  name: 'memory_save',
161
- description: `保存一条记忆。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。用户点了「保留」或发「保留记忆「X」」时,用那条建议的内容调用。更新已知主题时先 memory_read 旧正文再写:标题相同、或标题与摘要高度相近、且前置条件一致时,会合并进原来那条(merges+1),旧要点保留,同键的新值覆盖。前置条件不同的同一主题是两条,不要硬合并。正文只写这次新增的要点。${NOTES}`,
1309
+ description: `保存一条记忆。只在会改变以后判断时才存;先搜已有条目,能更新就不要新建。保存前先蒸馏:标题是结论,正文只写原则与触发条件,不抄原始输出里的具体数值。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。更新同一件事时带 id:身份只看标题、旧标题和前置条件,像不像不授权覆盖。文本相近但标题不同时先不写,返回 needsResolution 与 1–2 条相近条目:确实是同一条就用那个 id 更新,确实是另一条才带 resolution="distinct" 再存。带 id 更新时没给的字段保持原值,正文整段替换,旧正文只留作内部撤回,不展示也不参与检索。条数到硬上限时把同层最久没被读的一条移进归档,结果里写明动了哪条,不拒绝新记忆;单条正文超限只报错、不截断。前置条件不同的同一标题是两条。${NOTES}`,
162
1310
  parameters: SAVE_PARAMS,
163
1311
  output: {
1312
+ presentationMeta: (_args, value) => ({
1313
+ kind: 'memory-save',
1314
+ id: value.id || '',
1315
+ title: value.title || '',
1316
+ scope: value.scope || '',
1317
+ revision: value.revision || 0,
1318
+ changed: value.changed === true,
1319
+ mode: value.needsScope ? 'needs_scope'
1320
+ : value.existingId ? 'duplicate'
1321
+ : value.needsResolution ? 'needs_resolution'
1322
+ : value.changed === false ? 'unchanged' : value.merged ? 'updated' : 'created',
1323
+ }),
164
1324
  schema: {
165
1325
  type: 'object',
166
1326
  properties: {
167
1327
  id: { type: 'string' },
168
1328
  title: { type: 'string' },
169
1329
  summary: { type: 'string' },
1330
+ when: { type: 'string' },
1331
+ revision: { type: 'integer' },
170
1332
  merged: { type: 'boolean' },
1333
+ changed: { type: 'boolean' },
1334
+ scope: { type: 'string' },
1335
+ scopeName: { type: 'string' },
1336
+ needsResolution: { type: 'boolean' },
1337
+ needsScope: { type: 'boolean' },
1338
+ reason: { type: 'string' },
1339
+ existingId: { type: 'string' },
1340
+ existingScope: { type: 'string' },
1341
+ crossLayer: { type: 'boolean' },
1342
+ sameTopic: { type: 'boolean' },
1343
+ similarIds: { type: 'string' },
1344
+ similar: { type: 'string' },
1345
+ archived: { type: 'string' },
171
1346
  },
172
- required: ['id', 'title', 'summary', 'merged'],
1347
+ required: ['id', 'title', 'summary', 'when', 'revision', 'merged', 'changed', 'scope', 'needsResolution', 'archived'],
173
1348
  additionalProperties: false,
174
1349
  },
175
- render: (value) => [{
1350
+ render: (_args, value) => [{
176
1351
  type: 'text',
177
- text: `${value.merged ? 'memory · merged' : 'memory · saved'} "${value.title}"${value.summary ? ` — ${value.summary}` : ''}`,
1352
+ text: value.needsScope
1353
+ ? value.reason === 'memory_disabled'
1354
+ ? 'memory · 本项目记忆已关闭,先在面板打开'
1355
+ : value.reason === 'unconfirmed_project'
1356
+ ? `memory · 当前目录还不能确认属于哪个项目:「${value.title}」\n先确认项目根目录,或显式 scope="global"`
1357
+ : `memory · 归属还没定:「${value.title}」\n补 memoryClass(personal_preference / project_fact / environment_fact / portable_procedure)或显式 scope;设备事实还要给设备条件`
1358
+ : value.existingId
1359
+ ? `memory · 已有相同记忆:「${value.title}」 · ${value.existingId}(${value.existingScope})\n要改成另一条,加 resolution="distinct"`
1360
+ : value.needsResolution
1361
+ ? `memory · 需要确认:「${value.title}」与已有条目相近${value.crossLayer ? '(在另一层)' : ''}${value.sameTopic ? '(同一主题槽位)' : ''}\n${value.similar}\n要更新它,用 id 重存;确实是另一条,加 resolution="distinct"`
1362
+ : value.changed === false
1363
+ ? `memory · unchanged ${withTag(value, `"${value.title}"`)} · ${value.id}`
1364
+ : `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''} · ${value.id}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}`,
178
1365
  }],
179
1366
  },
180
1367
  execute: async (args, execution) => {
181
- const scope = args.scope === 'global' ? globalScope() : scopeOf(cwdOf(execution));
182
- const saved = upsert(scope, args);
183
- return { id: saved.record.id, title: saved.record.title, summary: saved.record.summary, merged: saved.merged };
1368
+ const projectScope = scopeOf(cwdOf(execution));
1369
+ const projectScopeName = projectNameOf(projectScope);
1370
+ if (!memoryEnabled(projectScope)) {
1371
+ return {
1372
+ id: '', title: String(args.title || ''), summary: '', when: '', revision: 0,
1373
+ merged: false, changed: false, scope: '', needsScope: true, reason: 'memory_disabled',
1374
+ needsResolution: false, similarIds: '', similar: '', archived: '',
1375
+ };
1376
+ }
1377
+ const saved = saveEntry(projectScope, { ...args, writeMode: args.mode || 'patch' });
1378
+ if (saved.needsScope) {
1379
+ return {
1380
+ id: '',
1381
+ title: saved.draft?.title || '',
1382
+ summary: saved.draft?.summary || '',
1383
+ when: saved.draft?.when || '',
1384
+ revision: 0,
1385
+ merged: false,
1386
+ changed: false,
1387
+ scope: '',
1388
+ needsScope: true,
1389
+ reason: saved.reasonCode,
1390
+ needsResolution: false,
1391
+ similarIds: '',
1392
+ similar: '',
1393
+ archived: '',
1394
+ };
1395
+ }
1396
+ if (saved.existing) {
1397
+ return {
1398
+ id: saved.existing.id,
1399
+ title: saved.existing.title || saved.draft?.title || '',
1400
+ summary: '',
1401
+ when: '',
1402
+ revision: saved.existing.revision,
1403
+ merged: false,
1404
+ changed: false,
1405
+ scope: saved.existing.scope,
1406
+ scopeName: saved.existing.scope === 'global' ? '' : projectScopeName,
1407
+ needsScope: false,
1408
+ existingId: saved.existing.id,
1409
+ existingScope: saved.existing.scope,
1410
+ needsResolution: false,
1411
+ similarIds: '',
1412
+ similar: '',
1413
+ archived: '',
1414
+ };
1415
+ }
1416
+ const scope = saved.chosenScope === 'global' || (args.scope === 'global' && !saved.chosenScope) ? globalScope() : projectScope;
1417
+ if (saved.needsResolution) {
1418
+ return {
1419
+ id: '',
1420
+ title: saved.draft.title,
1421
+ summary: saved.draft.summary,
1422
+ when: saved.draft.when,
1423
+ revision: 0,
1424
+ merged: false,
1425
+ changed: false,
1426
+ scope: saved.chosenScope || (scope.key === 'global' ? 'global' : 'project'),
1427
+ needsScope: false,
1428
+ needsResolution: true,
1429
+ crossLayer: Boolean(saved.crossLayer),
1430
+ sameTopic: Boolean(saved.sameTopic),
1431
+ similarIds: saved.similarList.map((item) => item.id).join('、'),
1432
+ similar: saved.similarList.map((item) => `${item.id}「${item.title}」${item.summary ? `|${item.summary}` : ''}`).join(';'),
1433
+ archived: '',
1434
+ };
1435
+ }
1436
+ recordCitations(scope, sessionIdOf(execution), [saved.record]);
1437
+ return {
1438
+ id: saved.record.id,
1439
+ title: saved.record.title,
1440
+ summary: saved.record.summary,
1441
+ when: saved.record.when || '',
1442
+ revision: saved.record.revision || 1,
1443
+ merged: saved.merged,
1444
+ changed: !saved.noop,
1445
+ scope: scope.key === 'global' ? 'global' : 'project',
1446
+ scopeName: scope.key === 'global' ? '' : projectScopeName,
1447
+ needsScope: false,
1448
+ needsResolution: false,
1449
+ similarIds: saved.similarList.map((item) => item.id).join('、'),
1450
+ archived: (saved.archived || []).map((item) => item.title).join('、'),
1451
+ ...(saved.similar ? { similar: `${saved.similar.id}「${saved.similar.title}」` } : {}),
1452
+ };
184
1453
  },
185
1454
  }), 'lite-memory: memory_save');
186
1455
 
187
1456
  ctx.effect(() => tools.register({
188
1457
  name: 'memory_suggest',
189
- description: `对一条用户没说、但跨会话还有用的经验提出保存建议。调用后对话里出现一个「保留」按钮,用户点了才保存。不要用问句征询「要不要记」,那要用户打字;按钮不打扰对话。一次对话最多建议一条,问自己「下次会话还得靠它吗」,不确定就别建议。${NOTES}`,
1458
+ description: `对一条用户没说、但跨会话还有用的经验提出保存建议。调用后对话里出现一个「保留」按钮,用户点了才进正式记忆。不要用问句征询「要不要记」,那要用户打字。一次对话最多建议一条,48 小时内每个项目一条。建议草稿与正式保存走同一套校验,凭据不会落盘;待确认草稿 48 小时过期,确认前就落盘所以撤回/过期都查得到。已有同一条时不再建议;相近的另一条会记成「更新候选」,保留后改的是那条,不会另建。${NOTES}`,
190
1459
  parameters: SUGGEST_PARAMS,
191
1460
  output: {
1461
+ presentationMeta: (_args, value) => ({ kind: 'memory-suggest', id: value.id || '', asked: value.asked }),
192
1462
  schema: {
193
1463
  type: 'object',
194
1464
  properties: {
195
1465
  asked: { type: 'boolean' },
196
1466
  title: { type: 'string' },
197
- summary: { type: 'string' },
1467
+ id: { type: 'string' },
198
1468
  reason: { type: 'string' },
1469
+ targetId: { type: 'string' },
1470
+ targetTitle: { type: 'string' },
199
1471
  },
200
- required: ['asked', 'title', 'summary'],
1472
+ required: ['asked'],
201
1473
  additionalProperties: false,
202
1474
  },
203
- render: (value) => [{
1475
+ render: (_args, value) => [{
204
1476
  type: 'text',
205
1477
  text: value.asked
206
- ? `memory · keep? "${value.title}"${value.summary ? ` — ${value.summary}` : ''}`
207
- : 'memory · skipped',
1478
+ ? `memory · suggested "${value.title}" · ${value.id}${value.targetId ? ` · 确认后更新 ${value.targetId}「${value.targetTitle}」` : ''}`
1479
+ : `memory · skipped${value.reason ? `\n${value.reason}` : ''}`,
208
1480
  }],
209
1481
  },
210
1482
  execute: async (args, execution) => {
211
1483
  const scope = scopeOf(cwdOf(execution));
212
- const gate = suggestionGate(scope, args.title);
213
- const summary = String(args.summary ?? '');
214
- if (!gate.ok) return { asked: false, title: String(args.title ?? ''), summary, reason: gate.reason };
215
- noteSuggestion(scope, args, 'asked');
216
- return { asked: true, title: String(args.title ?? ''), summary };
1484
+ if (!memoryEnabled(scope)) return { asked: false, reason: '本项目记忆已关闭' };
1485
+ const gate = suggestionGate(scope, args);
1486
+ if (!gate.ok) return { asked: false, reason: gate.reason };
1487
+ const noted = noteSuggestion(scope, gate.target ? { ...args, targetId: gate.target.id } : args);
1488
+ return {
1489
+ asked: true,
1490
+ title: noted.title,
1491
+ id: noted.id,
1492
+ ...(noted.targetId ? { targetId: noted.targetId, targetTitle: noted.targetTitle } : {}),
1493
+ };
217
1494
  },
218
1495
  }), 'lite-memory: memory_suggest');
219
1496
 
220
1497
  ctx.effect(() => tools.register({
221
1498
  name: 'memory_search',
222
- description: `按关键词检索已有记忆,返回标题与一句话摘要(最多 4 条),项目库与全局库一起查,「[全局]」前缀表示跨项目通用。带(场景)的条目只在该条件下成立。用户提到「记住过 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」时先查这里。命中后再用 memory_read 取正文。${NOTES}`,
1499
+ description: `按需检索已有记忆:先搜,命中再 memory_read 读正文。用户提到「记住了什么 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」,或者眼前反复出现的报错、弹窗、异常时,先查这里有没有记过解法。跨项目的通用条目以 [global] 开头,项目条目以 [项目名] 开头。中文长句可以直接当查询。每行带 id,更新时把 id 传回 memory_save。默认只列当前有效的;已经归档或被更新替换的旧说法不混进来,要查历史就在参数里显式要 history。`,
223
1500
  parameters: SEARCH_PARAMS,
224
1501
  output: {
1502
+ presentationMeta: (_args, value) => ({ kind: 'memory-search', count: value.count, status: value.status || 'ok', refs: value.refs || [] }),
225
1503
  schema: {
226
1504
  type: 'object',
227
1505
  properties: {
228
1506
  count: { type: 'integer' },
229
1507
  chars: { type: 'integer' },
230
- index: { type: 'string' },
1508
+ text: { type: 'string' },
1509
+ status: { type: 'string', enum: ['ok', 'budget_exhausted', 'memory_disabled', 'needs_context'] },
1510
+ refs: {
1511
+ type: 'array',
1512
+ items: {
1513
+ type: 'object',
1514
+ properties: {
1515
+ id: { type: 'string' },
1516
+ title: { type: 'string' },
1517
+ scope: { type: 'string' },
1518
+ revision: { type: 'integer' },
1519
+ status: { type: 'string' },
1520
+ origin: { type: 'string' },
1521
+ },
1522
+ required: ['id', 'title', 'scope', 'revision', 'status', 'origin'],
1523
+ additionalProperties: false,
1524
+ },
1525
+ },
231
1526
  },
232
- required: ['count', 'chars', 'index'],
1527
+ required: ['count', 'chars', 'text', 'refs'],
233
1528
  additionalProperties: false,
234
1529
  },
235
- render: (value) => [{
1530
+ render: (_args, value) => [{
236
1531
  type: 'text',
237
- text: value.count === 0 ? 'memory · no hits' : `memory · ${value.count} hits\n${value.index}`,
1532
+ text: value.status === 'budget_exhausted'
1533
+ ? 'memory · 本请求的记忆额度已用完'
1534
+ : value.status === 'memory_disabled'
1535
+ ? 'memory · 记忆检索已关闭'
1536
+ : value.status === 'needs_context'
1537
+ ? `memory · 先确认当前任务\n${value.text}`
1538
+ : value.count > 0
1539
+ ? `memory · 命中 ${value.count} 条 · 引用 ${value.chars} 字\n${value.text}`
1540
+ : 'memory · 没有命中',
238
1541
  }],
239
1542
  },
240
1543
  execute: async (args, execution) => {
241
- const scope = scopeOf(cwdOf(execution));
242
- const hits = search(scope, args.query, args.limit ?? 4);
243
- const rendered = renderIndex(hits);
244
- return { count: hits.length, chars: rendered.chars, index: rendered.text };
1544
+ if (recallMode() === 'off' || !memoryEnabledFor(execution)) return { count: 0, chars: 0, text: '', status: 'memory_disabled', refs: [] };
1545
+ const agent = execution?.agent;
1546
+ const win = windowOf(agent);
1547
+ if (win && !affordable(win, 0)) return { count: 0, chars: 0, text: '', status: 'budget_exhausted', refs: [] };
1548
+ const wantsBind = args.bindForRequest === true;
1549
+ const contextId = wantsBind && !args.contextId ? toolContextFor(execution) : args.contextId;
1550
+ if (wantsBind && !contextId) {
1551
+ return { count: 0, chars: 0, text: '先带上当前任务的 contextId,再绑定这一期。', status: 'needs_context', refs: [] };
1552
+ }
1553
+ const resolved = resolveRequestContext(execution, contextId);
1554
+ if (!resolved.ok) return { count: 0, chars: 0, text: resolved.reason, status: resolved.status || 'needs_context', refs: [] };
1555
+ if (wantsBind) {
1556
+ const bound = bindSelection(execution, resolved, String(args.query || ''));
1557
+ if (!bound.ok) return { count: 0, chars: 0, text: bound.reason, status: bound.status || 'needs_context', refs: [] };
1558
+ }
1559
+ const scope = resolved.scope;
1560
+ const context = resolved.context;
1561
+ /** 检索只列当前任务真能用的条目;条件未知或不适用就只留状态,不给索引行。 */
1562
+ const hits = search(scope, args.query, (args.limit ?? 3) + 3, {
1563
+ context,
1564
+ history: args.history === true,
1565
+ allowExperimental: resolved.experimental === true,
1566
+ }).filter((item) => (item.archived || item.status === 'ok')
1567
+ && (!item.ownerProjectId || item.ownerProjectId === scope.key))
1568
+ .slice(0, args.limit ?? 3);
1569
+ const scopeName = scope.key === 'global' ? '' : projectNameOf(scope);
1570
+ const shown = hits.map((item) => ({
1571
+ ...view.toModelView(item, 'search', { status: item.status || (item.archived ? 'archived' : 'ok') }),
1572
+ scopeName: item.scope === 'global' ? '' : scopeName,
1573
+ }));
1574
+ const valueOf = (kept) => {
1575
+ const rendered = renderIndex(shown.slice(0, kept));
1576
+ return { count: kept, chars: rendered.chars, text: rendered.text, status: 'ok', refs: refsOf(hits.slice(0, kept)) };
1577
+ };
1578
+ /** 没命中是正常结果,不是额度问题;空响应同样记一次本请求开销。 */
1579
+ if (!hits.length) {
1580
+ const empty = { count: 0, chars: 0, text: '', status: 'ok', refs: [] };
1581
+ if (win) {
1582
+ const bytes = jsonBytes(empty);
1583
+ if (!affordable(win, bytes)) return { ...empty, status: 'budget_exhausted' };
1584
+ win.bytes += bytes;
1585
+ win.reads += 1;
1586
+ }
1587
+ return empty;
1588
+ }
1589
+ if (win) {
1590
+ let kept = shown.length;
1591
+ while (kept > 0 && !affordable(win, jsonBytes(valueOf(kept)))) kept -= 1;
1592
+ if (kept === 0) return { count: 0, chars: 0, text: '', status: 'budget_exhausted', refs: [] };
1593
+ const value = valueOf(kept);
1594
+ win.bytes += jsonBytes(value);
1595
+ win.reads += 1;
1596
+ recordCitations(scope, sessionIdOf(execution), hits.slice(0, kept));
1597
+ return value;
1598
+ }
1599
+ const value = valueOf(shown.length);
1600
+ recordCitations(scope, sessionIdOf(execution), hits);
1601
+ return value;
245
1602
  },
246
1603
  }), 'lite-memory: memory_search');
247
1604
 
248
1605
  ctx.effect(() => tools.register({
249
1606
  name: 'memory_read',
250
- description: '读取一条记忆的正文。只在摘要不足以回答时才调用。',
1607
+ description: '读一条记忆的正文。搜索结果里只有一行摘要,需要细节时按 id 读。索引里还有、正文文件丢了会直接报错,不会返回空正文。',
251
1608
  parameters: READ_PARAMS,
252
1609
  output: {
253
1610
  schema: {
@@ -255,52 +1612,699 @@ export function apply(ctx) {
255
1612
  properties: {
256
1613
  id: { type: 'string' },
257
1614
  title: { type: 'string' },
1615
+ when: { type: 'string' },
1616
+ revision: { type: 'integer' },
1617
+ scope: { type: 'string' },
1618
+ scopeName: { type: 'string' },
1619
+ origin: { type: 'string', enum: ['user_explicit', 'project_evolution'] },
1620
+ status: { type: 'string', enum: ['ok', 'archived', 'needs_context', 'not_applicable', 'gone', 'budget_exhausted', 'memory_disabled'] },
258
1621
  body: { type: 'string' },
1622
+ links: { type: 'string' },
1623
+ repeat: { type: 'boolean' },
259
1624
  },
260
- required: ['id', 'title', 'body'],
1625
+ required: ['id', 'scope', 'status'],
261
1626
  additionalProperties: false,
262
1627
  },
263
- render: (value) => text([{ type: 'text', text: value.body }]),
1628
+ render: (_args, value) => [{
1629
+ type: 'text',
1630
+ text: value.status === 'budget_exhausted'
1631
+ ? 'memory · 本请求的记忆额度已用完'
1632
+ : value.status === 'memory_disabled'
1633
+ ? 'memory · 记忆检索已关闭'
1634
+ : value.repeat
1635
+ ? `memory · 本请求已读过「${value.title}」 · ${value.id}`
1636
+ : value.body
1637
+ ? `memory · ${withTag(value, `「${value.title}」`)}${value.when ? `(${value.when})` : ''} · ${value.id}${statusLabel(value.status)}\n${value.body}`
1638
+ : `memory · 「${value.title}」的相关记忆\n${value.links || '(无)'}`,
1639
+ }],
1640
+ presentationMeta: (_args, value) => ({
1641
+ kind: 'memory-read',
1642
+ id: value.id || '',
1643
+ title: value.title || '',
1644
+ scope: value.scope || '',
1645
+ revision: value.revision || 0,
1646
+ origin: value.origin === 'project_evolution' ? 'project_evolution' : 'user_explicit',
1647
+ status: value.status || 'ok',
1648
+ }),
264
1649
  },
265
1650
  execute: async (args, execution) => {
266
- const scope = scopeOf(cwdOf(execution));
267
- const { record, body } = readEntry(scope, args.id);
268
- return { id: record.id, title: record.title, body };
1651
+ if (recallMode() === 'off' || !memoryEnabledFor(execution)) return { id: '', scope: '', status: 'memory_disabled' };
1652
+ const resolved = resolveRequestContext(execution, args.contextId);
1653
+ if (!resolved.ok) return { id: '', scope: '', status: resolved.status || 'needs_context' };
1654
+ const scope = resolved.scope;
1655
+ const scopeName = scope.key === 'global' ? '' : projectNameOf(scope);
1656
+ const context = resolved.context;
1657
+ const allowExperimental = resolved.experimental === true;
1658
+ const agent = execution?.agent;
1659
+ const win = windowOf(agent);
1660
+ if (args.view === 'links') {
1661
+ const found = readLinks(scope, args.id, { context, allowExperimental });
1662
+ if (!found) throw new Error(`没有 ${args.id} 这条记忆`);
1663
+ if (found.status !== 'ok') {
1664
+ const withheld = { id: found.id, scope: found.scope, status: found.status, links: '' };
1665
+ if (win) {
1666
+ const bytes = jsonBytes(withheld);
1667
+ if (!affordable(win, bytes)) return { id: '', scope: '', status: 'budget_exhausted' };
1668
+ win.bytes += bytes;
1669
+ win.reads += 1;
1670
+ }
1671
+ return withheld;
1672
+ }
1673
+ const rows = found.links.filter((link) => link.status === 'invalid' || link.status === 'ok')
1674
+ .map((link) => (link.status === 'invalid'
1675
+ ? `- ${link.id} · 已失效`
1676
+ : `- ${link.scope === 'global' ? '[global] ' : (scopeName ? `[${scopeName}] ` : '')}「${link.title}」 · ${link.id}`));
1677
+ const text = rows.join('\n') || '(无)';
1678
+ const value = { id: found.id, scope: found.scope, scopeName, status: found.status, links: text };
1679
+ if (win) {
1680
+ const bytes = jsonBytes(value);
1681
+ if (!affordable(win, bytes)) return { id: '', scope: '', status: 'budget_exhausted' };
1682
+ win.bytes += bytes;
1683
+ win.reads += 1;
1684
+ }
1685
+ return value;
1686
+ }
1687
+ const record = readEntry(scope, args.id, { context, allowExperimental });
1688
+ if (!record) throw new Error(`没有 ${args.id} 这条记忆`);
1689
+ if (record.origin === 'project_evolution' && record.ownerProjectId && record.ownerProjectId !== scope.key) {
1690
+ return { id: args.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: 'not_applicable', body: '' };
1691
+ }
1692
+ if (record.origin === 'project_evolution' && !allowExperimental) {
1693
+ return { id: record.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: 'needs_context', body: '' };
1694
+ }
1695
+ /** 暂停、过期或条件不匹配的实验条目只给状态,不给正文。 */
1696
+ if (record.origin === 'project_evolution' && !view.experimentalUsable(record)) {
1697
+ return { id: record.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: 'needs_context', body: '' };
1698
+ }
1699
+ const learningState = record.learning && typeof record.learning === 'object' ? record.learning : {};
1700
+ if (record.origin === 'project_evolution' && (learningState.state === 'paused' || learningState.blockedReason)) {
1701
+ return { id: record.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: 'needs_context', body: '' };
1702
+ }
1703
+ /** 条件未知或不适用的条目只报状态:正文绝不能绕过自动召回的门槛。 */
1704
+ if (record.status !== 'ok') {
1705
+ return { id: record.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: record.status === 'needs_context' ? 'needs_context' : 'not_applicable', body: '' };
1706
+ }
1707
+ const revision = record.revision || 1;
1708
+ const body = String(record.body ?? '');
1709
+ const value = {
1710
+ id: record.id,
1711
+ title: record.title,
1712
+ when: record.when || '',
1713
+ revision,
1714
+ scope: record.scope,
1715
+ scopeName,
1716
+ status: record.status,
1717
+ body,
1718
+ };
1719
+ if (win) {
1720
+ if (!affordable(win, 0)) return { id: '', scope: '', status: 'budget_exhausted' };
1721
+ const seenRevision = win.readBodies.get(record.id);
1722
+ if (seenRevision === revision) {
1723
+ const receipt = { ...value, body: '', repeat: true };
1724
+ const bytes = jsonBytes(receipt);
1725
+ if (!affordable(win, bytes)) return { id: '', scope: '', status: 'budget_exhausted' };
1726
+ win.bytes += bytes;
1727
+ win.reads += 1;
1728
+ return receipt;
1729
+ }
1730
+ const bytes = jsonBytes(value);
1731
+ if (!affordable(win, bytes, 1)) return { id: '', scope: '', status: 'budget_exhausted' };
1732
+ win.bytes += bytes;
1733
+ win.bodies += 1;
1734
+ win.reads += 1;
1735
+ win.readBodies.set(record.id, revision);
1736
+ }
1737
+ recordCitations(scope, sessionIdOf(execution), [record]);
1738
+ return value;
269
1739
  },
270
1740
  }), 'lite-memory: memory_read');
271
1741
 
272
1742
  ctx.effect(() => tools.register({
273
1743
  name: 'memory_forget',
274
- description: '把一条记忆移入归档。用户说「别记这个 / 删掉那条」时调用;归档不删除文件。',
1744
+ description: '把一条记忆移入归档(不是彻底删除,正文与记录都留在 archive/,可人工取回)。只在用户明确说「别记这个 / 删掉那条」时用。',
275
1745
  parameters: FORGET_PARAMS,
276
1746
  output: {
277
1747
  schema: {
278
1748
  type: 'object',
279
- properties: { id: { type: 'string' }, title: { type: 'string' } },
280
- required: ['id', 'title'],
1749
+ properties: {
1750
+ id: { type: 'string' },
1751
+ title: { type: 'string' },
1752
+ scope: { type: 'string' },
1753
+ scopeName: { type: 'string' },
1754
+ mode: { type: 'string', enum: ['archive', 'purged', 'needs_resolution', 'purge_pending', 'memory_disabled'] },
1755
+ },
1756
+ required: ['id', 'mode'],
281
1757
  additionalProperties: false,
282
1758
  },
283
- render: (value) => [{
1759
+ render: (_args, value) => [{
284
1760
  type: 'text',
285
- text: `memory · dropped "${value.title}"`,
1761
+ text: value.mode === 'memory_disabled'
1762
+ ? 'memory · 本项目记忆已关闭,先在面板打开'
1763
+ : value.mode === 'purged'
1764
+ ? `memory · forgotten "${value.title}" · ${value.id}`
1765
+ : value.mode === 'purge_pending'
1766
+ ? `memory · 清除未完成 · ${value.id},重启后会继续`
1767
+ : value.mode === 'needs_resolution'
1768
+ ? `memory · 先确认是归档还是彻底忘掉 · ${value.id}`
1769
+ : `memory · archived ${withTag(value, `"${value.title}"`)} · ${value.id},可找回`,
286
1770
  }],
1771
+ presentationMeta: (_args, value) => ({
1772
+ kind: 'memory-forget',
1773
+ id: value.id || '',
1774
+ title: value.title || '',
1775
+ scope: value.scope || '',
1776
+ mode: value.mode,
1777
+ }),
287
1778
  },
288
1779
  execute: async (args, execution) => {
289
1780
  const scope = scopeOf(cwdOf(execution));
290
- const record = forget(scope, args.id);
291
- return { id: record.id, title: record.title };
1781
+ const projectScopeName = projectNameOf(scope);
1782
+ if (!memoryEnabled(scope)) return { id: String(args.id || ''), title: '', scope: '', mode: 'memory_disabled' };
1783
+ const located = locate(scope, args.id);
1784
+ if (!located && !args.mode) throw new Error(`没有 ${args.id} 这条记忆`);
1785
+ if (!args.mode) {
1786
+ return { id: args.id, mode: 'needs_resolution', scope: located.scope.key === 'global' ? 'global' : 'project' };
1787
+ }
1788
+ let dropped;
1789
+ try {
1790
+ dropped = forget(scope, args.id, { mode: args.mode });
1791
+ } catch (error) {
1792
+ if (error && error.code === 'purge_pending') {
1793
+ const layer = located ? located.scope : scope;
1794
+ try {
1795
+ state.resumePurge(layer);
1796
+ dropped = forget(scope, args.id, { mode: args.mode });
1797
+ } catch (again) {
1798
+ if (again && again.code === 'purge_pending') {
1799
+ return {
1800
+ id: args.id,
1801
+ title: located ? located.record.title : '',
1802
+ mode: 'purge_pending',
1803
+ scope: layer.key === 'global' ? 'global' : 'project',
1804
+ scopeName: layer.key === 'global' ? '' : projectScopeName,
1805
+ };
1806
+ }
1807
+ throw again;
1808
+ }
1809
+ } else {
1810
+ throw error;
1811
+ }
1812
+ }
1813
+ if (!dropped) throw new Error(`没有 ${args.id} 这条记忆`);
1814
+ const layerKey = dropped.scopeKey || (located ? located.scope.key : scope.key);
1815
+ if (dropped.mode === 'purged') revokeLearningUse(layerKey);
1816
+ return {
1817
+ id: dropped.id,
1818
+ title: dropped.title || (located ? located.record.title : ''),
1819
+ mode: dropped.mode,
1820
+ scope: layerKey === 'global' ? 'global' : 'project',
1821
+ scopeName: layerKey === 'global' ? '' : projectScopeName,
1822
+ };
292
1823
  },
293
1824
  }), 'lite-memory: memory_forget');
294
1825
 
295
- /** 「保留」走同源路由:点击只写盘,不产生消息,也不打断对话。 */
1826
+ /** 「保留」「✕」按钮与统计都挂在 WebServer 上;没有该服务时工具照常可用。 */
296
1827
  const mountRoute = (holder) => {
297
- const webServer = typeof holder.get === 'function' ? holder.get('webServer', false) : holder.webServer;
1828
+ const webServer = holder.webServer ?? holder;
298
1829
  if (!webServer || typeof webServer.register !== 'function' || typeof webServer.getOrigin !== 'function') return;
299
- holder.effect(() => webServer.register({ kind: 'prefix', path: REMOTE_PATH, handler: keepHandler(webServer) }), 'lite-memory: keep route');
1830
+ const cwdForSession = (sessionId) => {
1831
+ const sessions = getService('sessions');
1832
+ if (!sessions || typeof sessions.get !== 'function') return '';
1833
+ try {
1834
+ const cwd = sessions.get(sessionId)?.header?.cwd;
1835
+ return typeof cwd === 'string' ? cwd : '';
1836
+ } catch {
1837
+ return '';
1838
+ }
1839
+ };
1840
+ holder.effect(() => webServer.register({ kind: 'prefix', path: REMOTE_PATH, handler: keepHandler(webServer, cwdForSession) }), 'lite-memory: keep route');
300
1841
  };
301
1842
  if (typeof ctx.inject === 'function') ctx.inject(['webServer'], (scope) => mountRoute(scope));
302
1843
  else mountRoute(ctx);
303
1844
 
1845
+ /**
1846
+ * 自动召回:provider 在加载时注册一次,pre-step 拿到这次请求的准备结果后填充。
1847
+ * provider 只读内存快照;没准备好就返回空字符串,不注入任何东西。
1848
+ */
1849
+ const prompt = getService('systemPrompt');
1850
+ if (prompt && typeof prompt.context === 'function') {
1851
+ ctx.effect(() => prompt.context({
1852
+ name: 'lite-memory:recall',
1853
+ order: 60,
1854
+ interpolate: false,
1855
+ required: false,
1856
+ text: (holder) => recallText(holder),
1857
+ }), 'lite-memory: recall context');
1858
+ }
1859
+
1860
+ /** 执行入口再拦一次:工具若已出现在旧快照里,调用时也不放行。 */
1861
+ if (typeof ctx.on === 'function') ctx.on('agent/created', (payload) => {
1862
+ const agent = payload?.agent;
1863
+ if (!agent?.ctx?.tools) return;
1864
+ try {
1865
+ agent.ctx.effect(() => agent.ctx.tools.guard((execution) => {
1866
+ if (!MEMORY_TOOLS.includes(execution?.name)) return undefined;
1867
+ return memoryOnForAgent(agent) ? undefined : '本项目记忆已关闭,先在面板打开';
1868
+ }), 'lite-memory: tool guard');
1869
+ } catch { /* older host without guards */ }
1870
+ syncMemoryTools(agent);
1871
+ });
1872
+
1873
+ if (typeof ctx.on === 'function') ctx.on('agent/pre-step', async (payload, next) => {
1874
+ const startedAt = Date.now();
1875
+ const agent = payload?.agent;
1876
+ syncMemoryTools(agent);
1877
+ let decision;
1878
+ try {
1879
+ decision = await next();
1880
+ } catch (error) {
1881
+ clearPrepared(agent);
1882
+ throw error;
1883
+ }
1884
+ try {
1885
+ if (!agent || decision?.kind !== 'enter') {
1886
+ clearPrepared(agent);
1887
+ return decision;
1888
+ }
1889
+ const signal = payload?.signal;
1890
+ if (signal?.aborted) {
1891
+ clearPrepared(agent);
1892
+ return decision;
1893
+ }
1894
+ const sessionId = String(agent?.session?.id ?? '');
1895
+ if (!sessionId) {
1896
+ clearPrepared(agent);
1897
+ return decision;
1898
+ }
1899
+ const generation = (generations.get(agent) || 0) + 1;
1900
+ generations.set(agent, generation);
1901
+ const turn = String(payload?.turn ?? payload?.step?.turn ?? payload?.turnIndex ?? '');
1902
+ const humans = (Array.isArray(decision.messages) ? decision.messages : [])
1903
+ .filter((message) => message?.source?.kind === 'user');
1904
+ const fresh = freshHumans(sessionId, humans);
1905
+ const previous = prepared.get(agent);
1906
+ const sameSession = previous && previous.sessionId === sessionId;
1907
+
1908
+ if (fresh.length === 0) {
1909
+ /** 工具续步:内容冻结,只把快照绑到本步的 signal;盘上版本变了才重投影。 */
1910
+ const turnChanged = !!(turn && previous?.turn && turn !== previous.turn);
1911
+ if (!sameSession || turnChanged) {
1912
+ clearPrepared(agent);
1913
+ return decision;
1914
+ }
1915
+ previous.signal = signal;
1916
+ const scope = scopeOf(cwdOf({ agent }));
1917
+ if (!memoryEnabled(scope)) {
1918
+ clearPrepared(agent);
1919
+ return decision;
1920
+ }
1921
+ const fingerprint = fingerprintOf(scope);
1922
+ /** 指纹拿不到就当作已失效:不拿旧任务内容硬撑这一步。 */
1923
+ if (!fingerprint) {
1924
+ clearPrepared(agent);
1925
+ return decision;
1926
+ }
1927
+ if (fingerprint !== previous.fingerprint) {
1928
+ const win = windowOf(agent);
1929
+ const snapshot = projectRecall(scope, previous.query, view.trustedContext(scope, {}), win);
1930
+ let experimentText = '';
1931
+ if (win && win.experiment) {
1932
+ experimentText = renderExperiment(scope, win.experiment.decision, win.experiment.context, win.experiment, win);
1933
+ }
1934
+ if (generation === generations.get(agent) && !signal?.aborted) {
1935
+ if (snapshot || experimentText) {
1936
+ previous.text = snapshot ? snapshot.text : '';
1937
+ previous.cards = snapshot ? snapshot.cards : [];
1938
+ previous.experimentText = experimentText;
1939
+ previous.fingerprint = fingerprint;
1940
+ } else {
1941
+ clearPrepared(agent);
1942
+ }
1943
+ }
1944
+ }
1945
+ return decision;
1946
+ }
1947
+
1948
+ /** 工具模式也要开额度窗口:只是不自动注入,工具调用同样受每请求预算约束。 */
1949
+ const ids = fresh.map((message) => String(message?.id ?? '')).filter(Boolean).sort();
1950
+ const win = openWindow(agent, { sessionId, turn, messageIds: ids });
1951
+
1952
+ if (recallMode() !== 'adaptive') {
1953
+ clearPrepared(agent);
1954
+ return decision;
1955
+ }
1956
+
1957
+ const query = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
1958
+ if (!query) {
1959
+ clearPrepared(agent);
1960
+ return decision;
1961
+ }
1962
+ const cwd = cwdOf({ agent });
1963
+ if (typeof cwd !== 'string' || !cwd) {
1964
+ clearPrepared(agent);
1965
+ return decision;
1966
+ }
1967
+ const scope = scopeOf(cwd);
1968
+ if (!memoryEnabled(scope)) {
1969
+ clearPrepared(agent);
1970
+ return decision;
1971
+ }
1972
+ const context = view.trustedContext(scope, {});
1973
+ const fingerprint = fingerprintOf(scope);
1974
+ const controlEpoch = controlStatus(scope).epoch || '';
1975
+ const batchKey = anchor.requestBatchKey(ids);
1976
+ dropStaleCandidate(sessionId, batchKey);
1977
+ const experiment = experimentFor(scope, { sessionId, query, batchKey, messageIds: ids, win });
1978
+ const remembered = experiment && experiment.held
1979
+ ? rememberFeedback(scope, sessionId, fresh, experiment.held, turn)
1980
+ : null;
1981
+ const snapshot = projectRecall(scope, query, context, win);
1982
+ if (generation !== generations.get(agent)) return decision;
1983
+ /** 投影期间开关被改过:丢弃这批内容,不把关闭前的记忆交给当前请求。 */
1984
+ if (!memoryEnabled(scope) || (controlStatus(scope).epoch || '') !== controlEpoch) {
1985
+ clearPrepared(agent);
1986
+ return decision;
1987
+ }
1988
+ /** 超时或被取消:这一步不留半旧卡片,下一真人请求重新投影。 */
1989
+ if (signal?.aborted || Date.now() - startedAt > RECALL_DEADLINE_MS) {
1990
+ clearPrepared(agent);
1991
+ return decision;
1992
+ }
1993
+ /** 实验上下文即使本轮零注入也要留着:工具绑定后同一请求还能补上规则。 */
1994
+ if (!snapshot && !experiment) {
1995
+ clearPrepared(agent);
1996
+ return decision;
1997
+ }
1998
+ const state = {
1999
+ text: snapshot ? snapshot.text : '',
2000
+ experimentText: experiment ? experiment.text : '',
2001
+ cards: snapshot ? snapshot.cards : [],
2002
+ query,
2003
+ sessionId,
2004
+ turn,
2005
+ signal,
2006
+ fingerprint,
2007
+ controlEpoch,
2008
+ at: Date.now(),
2009
+ };
2010
+ prepared.set(agent, state);
2011
+ payloadBytes.set(sessionId, win.bytes);
2012
+ while (payloadBytes.size > 64) payloadBytes.delete(payloadBytes.keys().next().value);
2013
+ if (signal) signalAgents.set(signal, agent);
2014
+ const key = agentKey(agent);
2015
+ if (key && signal && typeof signal.addEventListener === 'function') {
2016
+ const onAbort = () => clearPrepared(agent);
2017
+ signal.addEventListener('abort', onAbort, { once: true });
2018
+ abortCleanups.set(key, () => signal.removeEventListener('abort', onAbort));
2019
+ }
2020
+ } catch {
2021
+ clearPrepared(agent);
2022
+ }
2023
+ return decision;
2024
+ });
2025
+
2026
+
2027
+ /* ---------------- 项目自进化:一次短整理,先扣频次,再走提交门禁 ---------------- */
2028
+
2029
+ /** 只清不属于当前这批人类消息的旧候选:新一轮开始后,上一轮没整理完的反馈不能再落盘。 */
2030
+ function dropStaleCandidate(sessionId, batchKey) {
2031
+ const prior = feedbackCandidates.get(sessionId);
2032
+ if (prior && prior.candidate && prior.candidate.batchKey !== batchKey) {
2033
+ feedbackCandidates.delete(sessionId);
2034
+ }
2035
+ }
2036
+
2037
+ /**
2038
+ * 反馈只来自本轮真人消息;初始任务、工具成功、沉默都不算。
2039
+ * 解析目标由整理器完成:不在这里暂停所有已注入规则,避免一次 BGM 纠正打掉开头和字幕。
2040
+ */
2041
+ function rememberFeedback(scope, sessionId, messages, held, turn) {
2042
+ let candidate;
2043
+ try {
2044
+ candidate = learning.candidateFrom({
2045
+ messages, anchor: held.anchor, sessionId, projectId: scope.key, now: Date.now(),
2046
+ });
2047
+ } catch { return null; }
2048
+ if (!candidate) return null;
2049
+ candidate.cwd = scope.cwd;
2050
+ candidate.episodeUpdatedAt = held.episodeUpdatedAt || '';
2051
+ candidate.batchKey = held.batchKey || '';
2052
+ candidate.turn = String(turn || '');
2053
+ const revision = (feedbackRevisions.get(scope.key) || 0) + 1;
2054
+ feedbackRevisions.set(scope.key, revision);
2055
+ candidate.feedbackRevision = revision;
2056
+ const prior = feedbackCandidates.get(sessionId);
2057
+ feedbackCandidates.set(sessionId, {
2058
+ scope,
2059
+ candidate,
2060
+ held,
2061
+ session: prior && prior.session ? prior.session : null,
2062
+ queued: Boolean(prior && prior.queued),
2063
+ at: Date.now(),
2064
+ });
2065
+ while (feedbackCandidates.size > 8) feedbackCandidates.delete(feedbackCandidates.keys().next().value);
2066
+ return { candidate };
2067
+ }
2068
+
2069
+ /** 只认前台最近一次真实请求的路线;拿不到就宁可这次不整理。 */
2070
+ function routeForSession(session) {
2071
+ let config;
2072
+ try { config = session && typeof session.requestHeader === 'function' ? session.requestHeader()?.config : null; } catch { config = null; }
2073
+ const provider = config && typeof config.provider === 'string' ? config.provider : '';
2074
+ const model = config && typeof config.model === 'string' ? config.model : '';
2075
+ return provider && model ? { provider, model } : null;
2076
+ }
2077
+
2078
+ function sessionIdOfSession(session) {
2079
+ try { return String(session?.header?.id ?? session?.id ?? ''); } catch { return ''; }
2080
+ }
2081
+
2082
+ function scopeOfSession(session) {
2083
+ let cwd = '';
2084
+ try { cwd = String(session?.header?.cwd ?? ''); } catch { cwd = ''; }
2085
+ if (!cwd) return null;
2086
+ const scope = scopeOf(cwd);
2087
+ return scope && scope.confirmed ? scope : null;
2088
+ }
2089
+
2090
+ /** 有配额才调用;输出整批校验,提交门禁再复核开关、版本、来源与目标。 */
2091
+ async function organize(scope, session, pending) {
2092
+ const status = evolutionStatus(scope);
2093
+ if (!status || status.enabled !== true || status.compatible === false) return;
2094
+ if (status.policyVersion !== learning.POLICY_VERSION) return;
2095
+ const route = routeForSession(session);
2096
+ if (!route) return;
2097
+ const candidate = pending.candidate;
2098
+ const operationId = `learn_${crypto.createHash('sha256')
2099
+ .update(`${scope.key}|${candidate.episodeId}|${candidate.messageIds.join(',')}`)
2100
+ .digest('hex').slice(0, 16)}`;
2101
+ const begin = beginLearningAttempt(scope, { episodeId: candidate.episodeId, operationId: `${operationId}_start`, now: Date.now() });
2102
+ if (!begin || begin.ok !== true || begin.replayed) return;
2103
+ const ranges = rangesOf(scope);
2104
+ const input = learning.buildCallInput({
2105
+ projectId: scope.key,
2106
+ episodeId: candidate.episodeId,
2107
+ taskKind: candidate.taskKind,
2108
+ seriesId: candidate.seriesId,
2109
+ phase: candidate.phase,
2110
+ feedback: candidate,
2111
+ rules: begin.rules,
2112
+ ranges,
2113
+ });
2114
+ const preparedTargets = (begin.rules || []).map((record) => ({
2115
+ id: record.id,
2116
+ revision: record.revision || 1,
2117
+ specDigest: learning.specDigestOf(record),
2118
+ }));
2119
+ const llm = getService('llm');
2120
+ if (!llm || typeof llm.prepareCall !== 'function') {
2121
+ recordLearningFailure(scope, { operationId: `${operationId}_fail`, episodeId: candidate.episodeId, reason: '宿主没有可用模型', epoch: begin.epoch });
2122
+ return;
2123
+ }
2124
+ const controller = new AbortController();
2125
+ learningAborts.set(scope.key, controller);
2126
+ const deadline = setTimeout(() => { try { controller.abort(); } catch { /* already aborted */ } }, learning.LIMITS.timeoutMs);
2127
+ const chunks = [];
2128
+ let text = '';
2129
+ let sawTool = false;
2130
+ let finish = { kind: 'stop' };
2131
+ try {
2132
+ const call = await llm.prepareCall({ ...route, maxTokens: learning.LIMITS.maxTokens }, controller.signal);
2133
+ const options = {
2134
+ ...call.config,
2135
+ messages: [{ role: 'user', content: [{ type: 'text', text: input.prompt }] }],
2136
+ system: input.system,
2137
+ sessionId: sessionIdOfSession(session),
2138
+ purpose: 'project-evolution',
2139
+ signal: controller.signal,
2140
+ };
2141
+ for await (const chunk of call.stream(options)) {
2142
+ if (controller.signal.aborted) throw new Error('整理已取消');
2143
+ chunks.push(chunk);
2144
+ if (chunk && chunk.type === 'text-delta') text += String(chunk.text || '');
2145
+ else if (chunk && chunk.type === 'tool-call-delta') sawTool = true;
2146
+ else if (chunk && chunk.type === 'block-start' && chunk.blockType === 'tool-call') sawTool = true;
2147
+ else if (chunk && chunk.type === 'finish' && chunk.reason) finish = chunk.reason;
2148
+ }
2149
+ if (sawTool) throw new Error('整理返回了工具调用');
2150
+ /** 运行期间用户又给了新反馈:旧结果不能覆盖新纠正,直接作废,等排队的下一次整理。 */
2151
+ if ((feedbackRevisions.get(scope.key) || 0) !== candidate.feedbackRevision) {
2152
+ throw new Error('反馈已更新,这次整理作废');
2153
+ }
2154
+ const parsed = learning.parseOutput(text, { finish, refs: input.refs, rules: input.rules, ranges });
2155
+ if (!parsed.ok) throw new Error(parsed.reason);
2156
+ const commit = commitLearning(scope, {
2157
+ operationId,
2158
+ epoch: begin.epoch,
2159
+ episodeId: candidate.episodeId,
2160
+ episodeUpdatedAt: candidate.episodeUpdatedAt,
2161
+ projectId: scope.key,
2162
+ sessionId: candidate.sessionId,
2163
+ messageIds: candidate.messageIds,
2164
+ feedbackText: candidate.text,
2165
+ dimension: candidate.cue?.dimension || '',
2166
+ changes: parsed.changes,
2167
+ preparedTargets,
2168
+ usage: learning.usageOf(chunks),
2169
+ explicitLongTerm: Boolean(candidate.cue.explicitLongTerm),
2170
+ now: Date.now(),
2171
+ });
2172
+ if (commit && commit.ok === false) {
2173
+ recordLearningFailure(scope, {
2174
+ operationId: `${operationId}_fail`,
2175
+ episodeId: candidate.episodeId,
2176
+ reason: commit.reason,
2177
+ usage: learning.usageOf(chunks),
2178
+ epoch: begin.epoch,
2179
+ });
2180
+ }
2181
+ } catch (error) {
2182
+ const reason = controller.signal.aborted ? '整理已取消' : (error instanceof Error ? error.message : '整理没有完成');
2183
+ recordLearningFailure(scope, {
2184
+ operationId: `${operationId}_fail`,
2185
+ episodeId: candidate.episodeId,
2186
+ reason,
2187
+ usage: learning.usageOf(chunks),
2188
+ epoch: begin.epoch,
2189
+ });
2190
+ } finally {
2191
+ clearTimeout(deadline);
2192
+ if (learningAborts.get(scope.key) === controller) learningAborts.delete(scope.key);
2193
+ }
2194
+ }
2195
+
2196
+ /** 启动一次整理;结束后再看同项目是否还排着一条候选。 */
2197
+ function startOrganizer(scope, session, pending) {
2198
+ const job = { controller: learningAborts.get(scope.key) || null };
2199
+ learningJobs.set(scope.key, job);
2200
+ void organize(scope, session, pending)
2201
+ .catch(() => { /* 整理失败不影响会话 */ })
2202
+ .finally(() => {
2203
+ if (learningJobs.get(scope.key) === job) learningJobs.delete(scope.key);
2204
+ drainOrganizer(scope.key);
2205
+ });
2206
+ }
2207
+
2208
+ /** 彻底删除后中止该层在途整理并丢掉候选:不让旧结果复活已删规则。 */
2209
+ function revokeLearningUse(layerKey) {
2210
+ for (const [scopeKey, controller] of learningAborts) {
2211
+ if (layerKey !== 'global' && scopeKey !== layerKey) continue;
2212
+ try { controller.abort(); } catch { /* already settled */ }
2213
+ }
2214
+ for (const [sessionId, pending] of feedbackCandidates) {
2215
+ if (layerKey !== 'global' && pending?.scope?.key !== layerKey) continue;
2216
+ feedbackCandidates.delete(sessionId);
2217
+ }
2218
+ }
2219
+
2220
+ /** 忙碌时保留一条最新候选,不丢用户刚说的纠正;同一项目仍只跑一个整理。 */
2221
+ function drainOrganizer(scopeKey) {
2222
+ if (learningJobs.has(scopeKey)) return;
2223
+ for (const [sessionId, pending] of feedbackCandidates) {
2224
+ if (!pending || !pending.scope || pending.scope.key !== scopeKey) continue;
2225
+ feedbackCandidates.delete(sessionId);
2226
+ if (!pending.session) return;
2227
+ startOrganizer(pending.scope, pending.session, pending);
2228
+ return;
2229
+ }
2230
+ }
2231
+
2232
+ /** 只在正常完成的人类回合后调度,一次一个项目;永不阻塞事件派发。 */
2233
+ function scheduleOrganizer(session, event) {
2234
+ const reason = event && event.data ? event.data.reason : null;
2235
+ if (!reason || reason.kind !== 'completed') return;
2236
+ const sessionId = sessionIdOfSession(session);
2237
+ if (!sessionId) return;
2238
+ const pending = feedbackCandidates.get(sessionId);
2239
+ if (!pending) return;
2240
+ const eventTurn = event.data && event.data.turn !== undefined ? String(event.data.turn) : '';
2241
+ if (eventTurn && pending.candidate && pending.candidate.turn && eventTurn !== pending.candidate.turn) {
2242
+ feedbackCandidates.delete(sessionId);
2243
+ return;
2244
+ }
2245
+ pending.session = session;
2246
+ if (learningJobs.has(pending.scope.key)) {
2247
+ pending.queued = true;
2248
+ return;
2249
+ }
2250
+ feedbackCandidates.delete(sessionId);
2251
+ startOrganizer(pending.scope, session, pending);
2252
+ }
2253
+
2254
+ if (typeof ctx.on === 'function') ctx.on('agent/disposed', (payload) => {
2255
+ const agent = payload?.agent;
2256
+ if (!agent) return;
2257
+ clearPrepared(agent);
2258
+ windows.delete(agentKey(agent));
2259
+ const sessionId = String(agent?.session?.id ?? '');
2260
+ if (sessionId) seenHumans.delete(sessionId);
2261
+ });
2262
+
2263
+ /**
2264
+ * 宿主没有 agent/turn-end 这个派发;真正的轮次收尾是 agent/turn-stopping,
2265
+ * 载荷只有 {turn, signal}。按 signal 找回对应 agent,只清这一轮的准备结果。
2266
+ */
2267
+ if (typeof ctx.on === 'function') ctx.on('agent/turn-stopping', (payload) => {
2268
+ const signal = payload?.signal;
2269
+ if (!signal) return;
2270
+ const agent = signalAgents.get(signal);
2271
+ if (agent) clearPrepared(agent);
2272
+ /** 被取消或被中断的回合不保留反馈候选,下一句「谢谢」不能把没说完的纠正写进去。 */
2273
+ if (signal.aborted === true && agent) {
2274
+ const sessionId = String(agent?.session?.id ?? '');
2275
+ if (sessionId) feedbackCandidates.delete(sessionId);
2276
+ }
2277
+ signalAgents.delete(signal);
2278
+ });
2279
+
2280
+ /** 正常完成的人类回合才整理;失败、取消、被打断都不改写规则。 */
2281
+ if (typeof ctx.on === 'function') ctx.on('session/event', (session, event) => {
2282
+ if (!event || event.type !== 'turn/end') return;
2283
+ scheduleOrganizer(session, event);
2284
+ });
2285
+
2286
+ /** 插件卸载时中止在途整理;提交门禁仍会拒绝过期结果。 */
2287
+ ctx.effect(() => () => {
2288
+ for (const controller of learningAborts.values()) {
2289
+ try { controller.abort(); } catch { /* already gone */ }
2290
+ }
2291
+ learningAborts.clear();
2292
+ feedbackCandidates.clear();
2293
+ learningJobs.clear();
2294
+ }, 'lite-memory: learning jobs');
2295
+
2296
+ /** 测试与排查用:只暴露只读查询,不提供改状态入口。 */
2297
+ ctx.effect(() => {
2298
+ globalThis.__liteMemoryRecall = {
2299
+ mode: recallMode,
2300
+ preparedOf: (agent) => prepared.get(agent) || null,
2301
+ windowOf: (agent) => windowOf(agent),
2302
+ };
2303
+ return () => {
2304
+ delete globalThis.__liteMemoryRecall;
2305
+ };
2306
+ }, 'lite-memory: recall debug');
2307
+
304
2308
  /** Read-only summary for the Client row: how big the store already is. */
305
2309
  ctx.effect(() => {
306
2310
  globalThis.__liteMemoryStats = (cwd) => {