dsh-lite-memory 0.17.0 → 0.18.19

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 (42) hide show
  1. package/README.md +15 -15
  2. package/TRIGGERS.md +1 -1
  3. package/client.js +343 -44
  4. package/docs/BENCH.md +10 -8
  5. package/docs/RELEASE-0.18.0.md +35 -0
  6. package/docs/RELEASE-0.18.1.md +18 -0
  7. package/docs/RELEASE-0.18.10.md +25 -0
  8. package/docs/RELEASE-0.18.11.md +48 -0
  9. package/docs/RELEASE-0.18.12.md +27 -0
  10. package/docs/RELEASE-0.18.13.md +13 -0
  11. package/docs/RELEASE-0.18.14.md +13 -0
  12. package/docs/RELEASE-0.18.15.md +5 -0
  13. package/docs/RELEASE-0.18.2.md +21 -0
  14. package/docs/RELEASE-0.18.3.md +16 -0
  15. package/docs/RELEASE-0.18.4.md +15 -0
  16. package/docs/RELEASE-0.18.5.md +21 -0
  17. package/docs/RELEASE-0.18.6.md +18 -0
  18. package/docs/RELEASE-0.18.7.md +23 -0
  19. package/docs/RELEASE-0.18.8.md +18 -0
  20. package/docs/RELEASE-0.18.9.md +24 -0
  21. package/index.js +403 -131
  22. package/learning.js +65 -6
  23. package/package.json +7 -4
  24. package/state.js +56 -8
  25. package/store.js +361 -33
  26. package/test/client.test.mjs +79 -17
  27. package/test/contract.test.mjs +231 -93
  28. package/test/control.test.mjs +2 -2
  29. package/test/crossing.test.mjs +22 -1
  30. package/test/eval-seed.test.mjs +113 -0
  31. package/test/evolution-read.test.mjs +33 -10
  32. package/test/ha.test.mjs +186 -0
  33. package/test/layer-read.test.mjs +293 -0
  34. package/test/learning.test.mjs +80 -0
  35. package/test/palace.test.mjs +2 -0
  36. package/test/recall.test.mjs +80 -9
  37. package/test/record.test.mjs +130 -0
  38. package/test/safety.test.mjs +119 -0
  39. package/test/state.test.mjs +57 -1
  40. package/test/store.test.mjs +49 -5
  41. package/test/view.test.mjs +3 -0
  42. package/view.js +97 -13
package/index.js CHANGED
@@ -8,6 +8,8 @@ import {
8
8
  forget,
9
9
  undoChange,
10
10
  locate,
11
+ layersFor,
12
+ runtimeLayers,
11
13
  findAnywhere,
12
14
  renderIndex,
13
15
  loadIndex,
@@ -22,6 +24,8 @@ import {
22
24
  recordCitations,
23
25
  listCitations,
24
26
  readCitationBody,
27
+ readCitationBodyByTitle,
28
+ locateByTitle,
25
29
  evolutionStatus,
26
30
  controlStatus,
27
31
  setMemoryEnabled,
@@ -88,6 +92,17 @@ function memoryEnabled(scope) {
88
92
  }
89
93
  }
90
94
 
95
+ /** 关着的原因决定下一步:恢复出来的状态要用户重新开启,而不是让人以为是自己关的。 */
96
+ function memoryOffCopy(scope) {
97
+ try {
98
+ return controlStatus(scope).recoveryPending
99
+ ? '本项目记忆刚从损坏里恢复,需要你重新开启'
100
+ : '本项目记忆已关闭,先在面板打开';
101
+ } catch {
102
+ return '本项目记忆已关闭,先在面板打开';
103
+ }
104
+ }
105
+
91
106
  function memoryEnabledFor(execution) {
92
107
  return memoryEnabled(scopeOf(cwdOf(execution)));
93
108
  }
@@ -136,6 +151,28 @@ function readJsonBody(req, limit = 8192) {
136
151
  });
137
152
  }
138
153
 
154
+ /** 每个会话最近一次自动召回的判断依据;只留判断,不留正文。 */
155
+ const recallReceipts = new Map();
156
+
157
+ function noteRecall(scope, sessionId, receipt) {
158
+ if (!scope || typeof sessionId !== 'string' || !sessionId) return;
159
+ recallReceipts.set(`${scope.key}\u0000${sessionId}`, {
160
+ at: new Date().toISOString(),
161
+ reason: String(receipt.reason || ''),
162
+ matched: Number(receipt.matched) || 0,
163
+ cards: Number(receipt.cards) || 0,
164
+ chars: Number(receipt.chars) || 0,
165
+ limit: view.CARD.max,
166
+ bytes: view.CARD.bytes,
167
+ });
168
+ while (recallReceipts.size > 64) recallReceipts.delete(recallReceipts.keys().next().value);
169
+ }
170
+
171
+ function recallReceiptOf(scope, sessionId) {
172
+ if (!scope || typeof sessionId !== 'string' || !sessionId) return null;
173
+ return recallReceipts.get(`${scope.key}\u0000${sessionId}`) || null;
174
+ }
175
+
139
176
  /**
140
177
  * 桌面壳与本地 Web 是同一个界面:认 host 自己的 origin,也认 app 内建协议。
141
178
  */
@@ -162,6 +199,7 @@ function keepHandler(webServer, cwdForSession) {
162
199
  : pathname === REMOTE_PATH + '/keep' ? 'keep'
163
200
  : pathname === REMOTE_PATH + '/citations' ? 'citations'
164
201
  : pathname === REMOTE_PATH + '/body' ? 'body'
202
+ : pathname === REMOTE_PATH + '/entry' ? 'entry'
165
203
  : pathname === REMOTE_PATH + '/move' ? 'move'
166
204
  : pathname === REMOTE_PATH + '/overview' ? 'overview'
167
205
  : pathname === REMOTE_PATH + '/control' ? 'control'
@@ -181,10 +219,18 @@ function keepHandler(webServer, cwdForSession) {
181
219
  */
182
220
  if (action === 'body') {
183
221
  const id = typeof body.id === 'string' ? body.id : '';
184
- if (!id) return sendJson(res, 400, { ok: false, reason: '缺少 id' });
222
+ /** 界面只有标题:按标题照样能读,id 留着兼容旧引用文件。 */
223
+ const titleRef = typeof body.title === 'string' ? body.title.trim() : '';
224
+ const revisionRef = Number.isInteger(body.revision) ? body.revision : undefined;
225
+ if (!id && !titleRef) return sendJson(res, 400, { ok: false, reason: '缺少标题' });
226
+ /** 找不到时只回调用方给的那个句柄:按 id 问就回 id,按标题问就回标题。 */
227
+ const goneReply = () => sendJson(res, 200, titleRef
228
+ ? { ok: true, status: 'gone', title: titleRef }
229
+ : { ok: true, status: 'gone', id });
185
230
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
186
231
  const cwd = typeof body.cwd === 'string' ? body.cwd : '';
187
232
  const scopeKey = typeof body.scopeKey === 'string' ? body.scopeKey : '';
233
+ const scopeRef = body.scope === 'global' || body.scope === 'project' ? body.scope : '';
188
234
  let found;
189
235
  if (scopeKey) {
190
236
  /**
@@ -197,8 +243,9 @@ function keepHandler(webServer, cwdForSession) {
197
243
  : listed ? { key: listed.key, dir: listed.dir, cwd: listed.cwd || '', requestedCwd: listed.cwd || '', rootSource: 'configured', confirmed: true }
198
244
  : null;
199
245
  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 });
246
+ const located = id ? locate(target, id, { readOnly: true })
247
+ : locateByTitle(target, titleRef, { readOnly: true, revision: revisionRef });
248
+ if (!located) return goneReply();
202
249
  found = {
203
250
  ...located.record,
204
251
  scope: located.scope.key === 'global' ? 'global' : 'project',
@@ -207,15 +254,17 @@ function keepHandler(webServer, cwdForSession) {
207
254
  };
208
255
  } else if (sessionId) {
209
256
  try {
210
- found = readCitationBody(sessionId, id);
257
+ found = id ? readCitationBody(sessionId, id)
258
+ : readCitationBodyByTitle(sessionId, titleRef, { revision: revisionRef, scope: scopeRef });
211
259
  } catch {
212
260
  found = null;
213
261
  }
214
262
  /** 不在本会话引用里就当已消失:不给跨会话探正文,界面也只显示「已删除」。 */
215
- if (!found) return sendJson(res, 200, { ok: true, status: 'gone', id });
263
+ if (!found) return goneReply();
216
264
  } else if (cwd) {
217
- const located = locate(scopeOf(cwd), id, { readOnly: true });
218
- if (!located) return sendJson(res, 200, { ok: true, status: 'gone', id });
265
+ const located = id ? locate(scopeOf(cwd), id, { readOnly: true })
266
+ : locateByTitle(scopeOf(cwd), titleRef, { readOnly: true, revision: revisionRef });
267
+ if (!located) return goneReply();
219
268
  found = {
220
269
  ...located.record,
221
270
  scope: located.scope.key === 'global' ? 'global' : 'project',
@@ -225,7 +274,7 @@ function keepHandler(webServer, cwdForSession) {
225
274
  } else {
226
275
  return sendJson(res, 400, { ok: false, reason: '缺少来源' });
227
276
  }
228
- if (!found) return sendJson(res, 200, { ok: true, status: 'gone', id });
277
+ if (!found) return goneReply();
229
278
  /** 面板要分开「当前规范」和旧版本;历史只作可折叠对照,不拼进正文。 */
230
279
  const versions = Array.isArray(found.versions) ? found.versions
231
280
  : Array.isArray(found.history) ? found.history : [];
@@ -235,7 +284,6 @@ function keepHandler(webServer, cwdForSession) {
235
284
  : enums.has(learning.lastStatus) ? learning.lastStatus : '';
236
285
  return sendJson(res, 200, {
237
286
  ok: true,
238
- id: found.id,
239
287
  status: found.status,
240
288
  title: found.title,
241
289
  when: found.when || '',
@@ -253,6 +301,52 @@ function keepHandler(webServer, cwdForSession) {
253
301
  });
254
302
  }
255
303
 
304
+ /**
305
+ * 面板改一条普通记忆:只改当前项目里的标题与正文,别层只读。
306
+ * 必须带读到的版本,写前再对一次;中间被别人改过就拒绝,不覆盖。
307
+ */
308
+ if (action === 'entry') {
309
+ const edit = typeof body.edit === 'string' ? body.edit : '';
310
+ if (edit && edit !== 'save') return sendJson(res, 400, { ok: false, reason: '不认识这个操作' });
311
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
312
+ if (!sessionId) return sendJson(res, 400, { ok: false, reason: '缺少 sessionId' });
313
+ const cwd = typeof cwdForSession === 'function' ? cwdForSession(sessionId) : '';
314
+ if (!cwd) return sendJson(res, 409, { ok: false, reason: '这个会话还没有项目目录' });
315
+ const scope = scopeOf(cwd);
316
+ if (!scope.confirmed) return sendJson(res, 409, { ok: false, reason: '项目根还没确认,先重新打开这个项目' });
317
+ const scopeKey = typeof body.scopeKey === 'string' ? body.scopeKey : '';
318
+ if (scopeKey && scopeKey !== scope.key) {
319
+ return sendJson(res, 409, { ok: false, reason: '别的项目只读,回到那个项目里再改' });
320
+ }
321
+ if (!memoryEnabled(scope)) return sendJson(res, 409, { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' });
322
+ const id = typeof body.id === 'string' ? body.id : '';
323
+ const title = typeof body.title === 'string' ? body.title : '';
324
+ const text = typeof body.body === 'string' ? body.body : '';
325
+ const expectedRevision = Number(body.expectedRevision);
326
+ if (!id) return sendJson(res, 400, { ok: false, reason: '缺少这条记忆' });
327
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
328
+ return sendJson(res, 409, { ok: false, reason: '这条记忆的版本不清楚,先刷新再改' });
329
+ }
330
+ if (!title.trim()) return sendJson(res, 400, { ok: false, reason: '标题不能为空' });
331
+ try {
332
+ const result = saveEntry(scope, { id, title, body: text, expectedRevision, scope: 'project', source: 'user_explicit' });
333
+ if (!result || !result.record) {
334
+ const reason = result && result.reasonCode === 'memory_disabled' ? '本项目记忆已关闭'
335
+ : result && result.needsResolution ? '改动和另一条太像,先去那条上改'
336
+ : '这次没有改动';
337
+ return sendJson(res, 409, { ok: false, reason });
338
+ }
339
+ return sendJson(res, 200, { ok: true, title: result.record.title, revision: result.record.revision, changed: result.noop !== true });
340
+ } catch (error) {
341
+ const conflict = error && error.code === 'conflict';
342
+ return sendJson(res, conflict ? 409 : 400, {
343
+ ok: false,
344
+ retry: conflict,
345
+ reason: error instanceof Error ? error.message : '这次没有改动',
346
+ });
347
+ }
348
+ }
349
+
256
350
  /**
257
351
  * 面板总览:当前会话项目、可切换的项目层与全局层,都从服务端枚举。
258
352
  * 只读;正文不进列表,点开单条再按 scopeKey 取。
@@ -400,7 +494,7 @@ function keepHandler(webServer, cwdForSession) {
400
494
  })();
401
495
  return sendJson(res, 200, {
402
496
  ok: true,
403
- current: { key: scope.key, label: labelOf(scope), control: currentControl, evolution: currentEvolution },
497
+ current: { key: scope.key, label: labelOf(scope), control: currentControl, evolution: currentEvolution, recall: recallReceiptOf(scope, sessionId) },
404
498
  scopes,
405
499
  selected: {
406
500
  key: selectedScope.key,
@@ -575,7 +669,7 @@ function keepHandler(webServer, cwdForSession) {
575
669
 
576
670
  /** 总开关关闭时面板只能看:保留、拒绝、搬移、撤销都不落盘。 */
577
671
  if (!controlStatus(scope).enabled) {
578
- return sendJson(res, 409, { ok: false, reason: '本项目记忆已关闭,先在面板打开', code: 'memory_disabled' });
672
+ return sendJson(res, 409, { ok: false, reason: memoryOffCopy(scope), code: 'memory_disabled' });
579
673
  }
580
674
 
581
675
  if (action === 'move' || action === 'resume-move') {
@@ -596,16 +690,19 @@ function keepHandler(webServer, cwdForSession) {
596
690
  }
597
691
 
598
692
  if (action === 'undo') {
599
- if (!id) return sendJson(res, 400, { ok: false, reason: '缺少 id' });
600
- if (!locate(scope, id)) return sendJson(res, 404, { ok: false, reason: '没有这条记忆' });
693
+ const undoTitle = typeof body.title === 'string' ? body.title.trim() : '';
694
+ if (!id && !undoTitle) return sendJson(res, 400, { ok: false, reason: '缺少标题' });
695
+ const located = id ? locate(scope, id)
696
+ : locateByTitle(scope, undoTitle, { revision: Number.isInteger(body.revision) ? body.revision : undefined });
697
+ if (!located) return sendJson(res, 404, { ok: false, reason: '没有这条记忆' });
601
698
  /** 撤销是精确动作:必须带当时看到的版本;缺版本就不猜,免得撤掉别人后来的修改。 */
602
699
  if (!Number.isInteger(body.revision) || body.revision < 1) {
603
700
  return sendJson(res, 400, { ok: false, reason: '缺少版本' });
604
701
  }
605
702
  try {
606
- const undone = undoChange(scope, id, { expectedRevision: body.revision });
703
+ const undone = undoChange(scope, located.record.id, { expectedRevision: body.revision });
607
704
  if (!undone) return sendJson(res, 404, { ok: false, reason: '没有这条记忆' });
608
- return sendJson(res, 200, { ok: true, id: undone.id, mode: undone.mode });
705
+ return sendJson(res, 200, { ok: true, title: undone.title, revision: undone.revision, mode: undone.mode });
609
706
  } catch (error) {
610
707
  return sendJson(res, 409, { ok: false, reason: error instanceof Error ? error.message : '撤销失败' });
611
708
  }
@@ -635,7 +732,6 @@ function keepHandler(webServer, cwdForSession) {
635
732
  /** 给界面的事件引用:只有定位与版次,不带正文,避免把维护字段当内容画出来。 */
636
733
  function refsOf(hits) {
637
734
  return hits.map((item) => ({
638
- id: item.id,
639
735
  title: item.title || '',
640
736
  scope: item.scope || 'project',
641
737
  revision: item.revision || 1,
@@ -644,19 +740,25 @@ function refsOf(hits) {
644
740
  /** 界面要按类型分开展示(偏好/约定/流程/资料),维护字段不进正文。 */
645
741
  kind: typeof item.kind === 'string' && item.kind ? item.kind : 'note',
646
742
  when: typeof item.when === 'string' ? item.when : '',
743
+ /** 界面要能说「N 天前更新」,只给时间不给正文。 */
744
+ updated: typeof item.updated === 'string' ? item.updated : '',
647
745
  }));
648
746
  }
649
747
 
650
748
  const SAVE_PARAMS = {
651
749
  type: 'object',
652
750
  properties: {
653
- id: { type: 'string', description: '要更新的记忆 id(来自搜索结果或相近提示)。同一件事更新时用它,别另建一条' },
751
+ updateTitle: { type: 'string', description: '要更新的那条记忆的标题(原样抄搜索结果里的标题)。同一件事更新时用它,别另建一条' },
654
752
  title: { type: 'string', description: `标题:名词短语,用户一眼能认出是哪件事,不超过 ${LIMITS.titleChars} 字。它就是这条记忆的身份` },
655
753
  summary: { type: 'string', description: `一句话说清要记的内容本身——用户只看这一行决定留不留,名词短语最好,超长会自动截到这个长度` },
656
754
  body: { type: 'string', description: `正文 markdown,整段替换旧正文,不超过 ${LIMITS.entryBytes} 字节。只写原则、判断规则、触发条件和边界;不写一次性数字或代码片段,这些查当前代码或文档。` },
657
755
  when: { type: 'string', description: `前置条件或适用场景(「在哪台机器 / 哪个环境 / 什么情况下」),不超过 ${LIMITS.whenChars} 字。同一标题但前置条件不同,会存成两条,不合并;更新时不给就保留原值` },
658
756
  kind: { type: 'string', enum: ['preference', 'convention', 'procedure', 'pointer', 'pitfall', 'credential_hint', 'note'], description: '条目类型;更新时不给就保留原值' },
659
757
  tags: { type: 'array', items: { type: 'string' }, description: '最多 5 个标签;更新时不给就保留原值' },
758
+ supersedes: {
759
+ type: 'string',
760
+ description: '要取代的那条记忆的标题(原样抄搜索结果里的标题):新内容写成新条目,旧条目标为已被取代并保留在历史里;同层才有效,已被取代过的不能再取代',
761
+ },
660
762
  resolution: {
661
763
  type: 'string',
662
764
  enum: ['distinct'],
@@ -668,13 +770,12 @@ const SAVE_PARAMS = {
668
770
  evidenceRef: { type: 'string', description: '归属判定的依据(哪条消息、哪个文件、哪次观察);用户没说范围时用来判断能不能存' },
669
771
  sameTopicAs: {
670
772
  type: 'object',
671
- description: '显式绑定到已有主题槽位:{scope, id, revision}。版本不符或目标不存在就报错,不写',
773
+ description: '显式绑定到已有主题槽位:{scope, title}。标题对不上就报错,不写',
672
774
  properties: {
673
775
  scope: { type: 'string', enum: ['project', 'global'] },
674
- id: { type: 'string' },
675
- revision: { type: 'integer' },
776
+ title: { type: 'string' },
676
777
  },
677
- required: ['scope', 'id', 'revision'],
778
+ required: ['scope', 'title'],
678
779
  additionalProperties: false,
679
780
  },
680
781
  mode: {
@@ -718,9 +819,9 @@ const SAVE_PARAMS = {
718
819
  type: 'object',
719
820
  properties: {
720
821
  type: { type: 'string', enum: view.LINK_TYPES, description: '默认 related' },
721
- id: { type: 'string', pattern: '^mem_[a-z0-9]{6,}$' },
822
+ title: { type: 'string', description: '被关联那条记忆的标题(原样抄搜索结果里的标题)' },
722
823
  },
723
- required: ['id'],
824
+ required: ['title'],
724
825
  additionalProperties: false,
725
826
  },
726
827
  },
@@ -737,7 +838,7 @@ const SAVE_PARAMS = {
737
838
 
738
839
  const SUGGEST_PARAMS = {
739
840
  ...SAVE_PARAMS,
740
- properties: Object.fromEntries(Object.entries(SAVE_PARAMS.properties).filter(([key]) => !['scope', 'id', 'resolution', 'memoryClass', 'durability', 'evidenceRef', 'sameTopicAs'].includes(key))),
841
+ properties: Object.fromEntries(Object.entries(SAVE_PARAMS.properties).filter(([key]) => !['scope', 'id', 'updateTitle', 'resolution', 'memoryClass', 'durability', 'evidenceRef', 'sameTopicAs'].includes(key))),
741
842
  };
742
843
 
743
844
  const SEARCH_PARAMS = {
@@ -756,28 +857,69 @@ const SEARCH_PARAMS = {
756
857
  const READ_PARAMS = {
757
858
  type: 'object',
758
859
  properties: {
759
- id: { type: 'string', description: '记忆 id,来自搜索结果' },
860
+ title: { type: 'string', description: '记忆标题,来自搜索结果或召回卡' },
760
861
  view: { type: 'string', enum: ['full', 'links'], description: '默认 full 读正文;links 只看直接相关的记忆清单,不取正文' },
862
+ scope: { type: 'string', enum: ['project', 'global'], description: '只在指定层解析标题;默认先当前项目再全局' },
761
863
  contextId: { type: 'string', description: '上一轮自动索引或工具结果给出的任务上下文 id;读实验规则时带上' },
762
864
  },
763
- required: ['id'],
764
865
  additionalProperties: false,
765
866
  };
766
867
 
767
868
  const FORGET_PARAMS = {
768
869
  type: 'object',
769
870
  properties: {
770
- id: { type: 'string', description: '要忘掉的记忆 id' },
871
+ title: { type: 'string', description: '要忘掉的那条记忆的标题' },
872
+ scope: { type: 'string', enum: ['project', 'global'], description: '只在指定层解析标题;默认先当前项目再全局' },
771
873
  mode: {
772
874
  type: 'string',
773
875
  enum: ['archive', 'purge'],
774
876
  description: 'archive 移入归档可找回(只是不想再提);purge 彻底忘掉,正文与受管副本清除且不可复活。没点明是哪种就两种都别做,先问用户',
775
877
  },
776
878
  },
777
- required: ['id'],
778
879
  additionalProperties: false,
779
880
  };
780
881
 
882
+ /** 对外只认标题:id 留作内部地址,模型和界面都不用碰。 */
883
+ function refIdOf(scope, args, label = '记忆') {
884
+ const id = typeof args.id === 'string' ? args.id.trim() : '';
885
+ if (id) return id;
886
+ const title = typeof args.title === 'string' ? args.title.trim() : '';
887
+ if (!title) throw new Error(`要读或要忘一条${label},先给标题`);
888
+ /** 显式指定层时只解析那一层:同名条目不会被顺手改掉,项目层也不会命中全局。 */
889
+ const explicit = args && args.scope === 'global' ? globalScope() : args && args.scope === 'project' ? scope : null;
890
+ const sources = explicit ? [explicit] : runtimeLayers(scope);
891
+ /** 运行时只解析当前项目与全局:别的项目正文不进本会话。 */
892
+ for (const target of sources) {
893
+ let index;
894
+ try { index = loadIndex(target); } catch { continue; }
895
+ const hit = (index.entries || []).find((entry) => entry && entry.status !== 'archived'
896
+ && String(entry.title || '').trim() === title);
897
+ if (hit) return hit.id;
898
+ }
899
+ /** 忘掉已归档的条目也要能按标题解析到;归档不会因此重新参与召回。 */
900
+ for (const target of sources) {
901
+ let index;
902
+ try { index = loadIndex(target); } catch { continue; }
903
+ const hit = (index.archived || []).find((entry) => entry
904
+ && String(entry.title || '').trim() === title);
905
+ if (hit) return hit.id;
906
+ }
907
+ throw new Error(`没有「${title}」这条记忆;先用 memory_search 对一下标题`);
908
+ }
909
+
910
+ /** 同名条目可能分布在项目层和全局层:更新前先报候选,不能先匹配。 */
911
+ function titleCandidates(scope, title) {
912
+ const out = [];
913
+ for (const target of runtimeLayers(scope)) {
914
+ let index;
915
+ try { index = loadIndex(target); } catch { continue; }
916
+ const hit = (index.entries || []).find((entry) => entry && entry.status !== 'archived'
917
+ && String(entry.title || '').trim() === title);
918
+ if (hit) out.push({ id: hit.id, scope: target.key === 'global' ? 'global' : 'project' });
919
+ }
920
+ return out;
921
+ }
922
+
781
923
  /** 记忆归属标记:全局 `[global]`,项目用项目目录名(如 `[DSH]`)。 */
782
924
  function projectNameOf(scope) {
783
925
  const base = scope && scope.cwd ? path.basename(scope.cwd) : '';
@@ -795,6 +937,29 @@ function withTag(value, text) {
795
937
  return tag ? `${tag} ${text}` : text;
796
938
  }
797
939
 
940
+ /** 候选要能自己解释「什么时候的、为什么像」,模型才好判断是更新还是另一条。 */
941
+ function humanAge(iso) {
942
+ const at = Date.parse(String(iso || ''));
943
+ if (!Number.isFinite(at)) return '';
944
+ const days = Math.floor((Date.now() - at) / 86400000);
945
+ if (days <= 0) return '今天';
946
+ if (days === 1) return '昨天';
947
+ if (days < 30) return `${days} 天前`;
948
+ if (days < 365) return `${Math.floor(days / 30)} 个月前`;
949
+ return `${Math.floor(days / 365)} 年前`;
950
+ }
951
+
952
+ function describeCandidate(item, options = {}) {
953
+ const age = humanAge(item.updated);
954
+ const why = options.sameTopic ? '' : item.reason === 'similar_summary' ? '摘要相近' : '标题相近';
955
+ const parts = [`「${item.title}」`];
956
+ if (age) parts.push(`${age}更新`);
957
+ if (item.when) parts.push(item.when);
958
+ if (why) parts.push(why);
959
+ if (item.summary) parts.push(item.summary);
960
+ return parts.join(' · ');
961
+ }
962
+
798
963
  function statusLabel(status) {
799
964
  if (status === 'archived') return ' · 已归档';
800
965
  if (status === 'needs_context') return ' · 适用范围待确认';
@@ -811,12 +976,12 @@ function statusLabel(status) {
811
976
  export const RECALL_MODES = ['adaptive', 'tool_only', 'off'];
812
977
  const RECALL_QUERY_CHARS = 400;
813
978
  const RECALL_DEADLINE_MS = 150;
814
- const RECALL_HEADER = '已有记忆可能相关(需要细节用 memory_read 取正文,别凭标题猜):';
979
+ const RECALL_HEADER = '已有记忆可能相关(需要细节用 memory_read 带标题取正文,别凭标题猜):';
815
980
 
816
981
  /** 卡片只放够判断要不要读的几行;必要前提整条在场,装不下就整卡省略。 */
817
982
  function renderRecallCard(card) {
818
983
  const tag = card.scope === 'global' ? '[global]' : card.scopeName ? `[${card.scopeName}]` : '';
819
- const parts = [`- ${tag ? `${tag} ` : ''}「${card.title}」 · ${card.id}`];
984
+ const parts = [`- ${tag ? `${tag} ` : ''}「${card.title}」`];
820
985
  if (card.summary) parts.push(`|${card.summary}`);
821
986
  if (card.conditions && card.conditions.length) parts.push(`|前提:${card.conditions.join('、')}`);
822
987
  if (card.selection) parts.push(`|只适用于 ${card.selection}`);
@@ -907,6 +1072,27 @@ export function apply(ctx, config) {
907
1072
  return win.bytes + bytes <= view.REQUEST_BYTES;
908
1073
  }
909
1074
 
1075
+ /**
1076
+ * 工具自己取的记忆另算一笔:自动注入已经用满时,模型按标题来读正文不该被连带挡掉,
1077
+ * 否则用户看到的就是「额度已用完」这种没法处理的死路。
1078
+ */
1079
+ function affordableTool(win, bytes, bodies = 0) {
1080
+ if (!win) return true;
1081
+ if (win.reads >= view.REQUEST_LIMITS.reads) return false;
1082
+ if (bodies && win.bodies + bodies > view.REQUEST_LIMITS.bodies) return false;
1083
+ return (win.toolBytes || 0) + bytes <= view.TOOL_BYTES;
1084
+ }
1085
+
1086
+ /** 续写轮没有新消息:沿用上一次能核对的请求上下文。 */
1087
+ const lastRuntime = new Map();
1088
+ const rememberRuntime = (scope, sessionId, held) => {
1089
+ if (!held || !sessionId) return;
1090
+ const key = `${scope.key}\u0000${sessionId}`;
1091
+ lastRuntime.delete(key);
1092
+ lastRuntime.set(key, held);
1093
+ if (lastRuntime.size > 20) lastRuntime.delete(lastRuntime.keys().next().value);
1094
+ };
1095
+
910
1096
  function openWindow(agent, { sessionId, turn, messageIds }) {
911
1097
  const key = agentKey(agent);
912
1098
  if (!key) return null;
@@ -915,7 +1101,7 @@ export function apply(ctx, config) {
915
1101
  if (existing && existing.signature === signature) return existing;
916
1102
  const next = {
917
1103
  signature, sessionId, turn, messageIds,
918
- bytes: 0, bodies: 0, reads: 0,
1104
+ bytes: 0, bodies: 0, reads: 0, toolBytes: 0,
919
1105
  provided: new Map(), readBodies: new Map(), at: Date.now(),
920
1106
  experiment: null, contexts: new Map(), runtime: null, autoBudget: null,
921
1107
  };
@@ -986,17 +1172,18 @@ export function apply(ctx, config) {
986
1172
  }
987
1173
  }
988
1174
  /** 同一槽位只让胜出的规范进去;冲突或条件未知的整组不进自动召回。 */
1175
+ const groups = [...view.resolveTopics(layers.project, layers.global, context).values()];
989
1176
  const records = [];
990
1177
  const layerOf = new Map();
991
- for (const group of view.resolveTopics(layers.project, layers.global, context).values()) {
1178
+ for (const group of groups) {
992
1179
  if (group.status !== 'ok' || !group.record) continue;
993
1180
  records.push(group.record);
994
1181
  layerOf.set(group.record.id, group.scope === 'global' ? 'global' : 'project');
995
1182
  }
1183
+ const gateRuntime = runtime || (win && win.runtime) || null;
1184
+ const task = runtime && runtime.task ? runtime.task : null;
996
1185
  const ranked = view.rank(records, query, {
997
- limit: 8, requireApplicable: true, context,
998
- runtime: runtime || (win && win.runtime) || null,
999
- task: runtime && runtime.task ? runtime.task : null,
1186
+ limit: 8, requireApplicable: true, context, runtime: gateRuntime, task,
1000
1187
  });
1001
1188
  /** 普通记忆与实验规则共用一份自动注入预算:总条数和最终完整文本字节一起算。 */
1002
1189
  const auto = win && win.autoBudget ? win.autoBudget : null;
@@ -1034,7 +1221,7 @@ export function apply(ctx, config) {
1034
1221
  win.recallHeader = true;
1035
1222
  }
1036
1223
  lines.push(line);
1037
- cards.push({ id: card.id, revision, scope: card.scope, title: card.title });
1224
+ cards.push({ revision, scope: card.scope, title: card.title });
1038
1225
  }
1039
1226
  const finalText = `${RECALL_HEADER}\n${lines.join('\n')}`;
1040
1227
  if (auto) {
@@ -1046,8 +1233,35 @@ export function apply(ctx, config) {
1046
1233
  experimentCards: auto.experimentCards || 0,
1047
1234
  });
1048
1235
  }
1049
- if (!cards.length) return null;
1050
- return { text: finalText, cards, bytes: Buffer.byteLength(finalText), query };
1236
+ /** 没卡片也要给出判断依据:面板要能回答「为什么这次没提醒我」。 */
1237
+ let near = [];
1238
+ if (!cards.length && !ranked.length) {
1239
+ /** 自动召回只认完全适用;再放宽一层看有没有字面匹配,好把「条件未确认」和「没有匹配」分开。 */
1240
+ near = view.rank(records, query, {
1241
+ limit: 8, requireApplicable: false, context, runtime: gateRuntime, task,
1242
+ });
1243
+ }
1244
+ let waiting = near.some((row) => row.status !== 'ok' || row.record.reviewState === 'legacy_unreviewed');
1245
+ let nearMatched = near.length;
1246
+ if (!cards.length && !ranked.length) {
1247
+ /** 整组被条件或冲突挡下的候选没进 records,单独按同一查询算;只计数,不投放。 */
1248
+ for (const group of groups) {
1249
+ if (group.status !== 'conflict' && group.status !== 'needs_context') continue;
1250
+ const hits = view.rank(group.records || [], query, {
1251
+ limit: 1, requireApplicable: false, context, runtime: gateRuntime, task,
1252
+ });
1253
+ if (!hits.length) continue;
1254
+ waiting = true;
1255
+ nearMatched += hits.length;
1256
+ }
1257
+ }
1258
+ const reason = cards.length ? 'hit'
1259
+ : ranked.length ? 'budget'
1260
+ : waiting ? 'needs_context'
1261
+ : (gateRuntime && gateRuntime.enabled ? 'no_overlap' : 'needs_context');
1262
+ const matched = ranked.length || nearMatched;
1263
+ if (!cards.length) return { text: '', cards: [], bytes: 0, query, reason, matched };
1264
+ return { text: finalText, cards, bytes: Buffer.byteLength(finalText), query, reason, matched };
1051
1265
  }
1052
1266
 
1053
1267
  /* ---------------- 项目自进化:任务锚点、读取决策、上下文解析 ---------------- */
@@ -1257,8 +1471,8 @@ export function apply(ctx, config) {
1257
1471
  ? `;核对:${String(checkpoint.text).slice(0, 40)}`
1258
1472
  : '';
1259
1473
  const line = short
1260
- ? `- 「${record.title}」${conditions ? `(${conditions})` : ''} · ${record.id}:${body}${check}`
1261
- : `- 「${record.title}」 · ${record.id}(用途:${record.summary || record.title};要用时 memory_read 取正文)`;
1474
+ ? `- 「${record.title}」${conditions ? `(${conditions})` : ''}:${body}${check}`
1475
+ : `- 「${record.title}」(用途:${record.summary || record.title};要用时 memory_read 带标题取正文)`;
1262
1476
  const size = Buffer.byteLength(`\n${line}`);
1263
1477
  if (used + size > autoCap) break;
1264
1478
  used += size;
@@ -1394,11 +1608,13 @@ export function apply(ctx, config) {
1394
1608
  const contextId = typeof optionalContextId === 'string' ? optionalContextId.trim() : '';
1395
1609
  if (!contextId) {
1396
1610
  /** 没有显式上下文时,用本请求已经路由出来的任务;这层不依赖自进化开关。 */
1397
- const runtime = win && win.runtime;
1611
+ const live = win && win.runtime ? win.runtime : null;
1612
+ const runtime = live
1613
+ || (win && win.sessionId ? lastRuntime.get(`${scope.key}\u0000${win.sessionId}`) : null);
1398
1614
  const sameRequest = runtime
1399
1615
  && runtime.projectKey === scope.key
1400
1616
  && (!runtime.sessionId || runtime.sessionId === String(win.sessionId || ''))
1401
- && runtime.batchKey === anchor.requestBatchKey(win.messageIds || []);
1617
+ && (runtime === live ? runtime.batchKey === anchor.requestBatchKey(win.messageIds || []) : true);
1402
1618
  if (sameRequest) {
1403
1619
  /** 普通记忆可以直接用本请求上下文;实验正文仍要求显式 contextId,不能顺手放行。 */
1404
1620
  return { ok: true, scope, win, context: runtime.context, held: runtime, experimental: false };
@@ -1545,26 +1761,24 @@ export function apply(ctx, config) {
1545
1761
 
1546
1762
  ctx.effect(() => tools.register({
1547
1763
  name: 'memory_save',
1548
- description: `保存一条记忆。只在会改变以后判断时才存;先搜已有条目,能更新就不要新建。保存前先蒸馏:标题是结论,正文只写原则与触发条件,通常 2–4 句;路径、字段表、完整命令、版本号、尺寸数值不要抄进正文,改成文档或 skill 的指针。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。更新同一件事时带 id:身份只看标题、旧标题和前置条件,像不像不授权覆盖。文本相近但标题不同时先不写,返回 needsResolution 与 1–2 条相近条目:确实是同一条就用那个 id 更新,确实是另一条才带 resolution="distinct" 再存。带 id 更新时没给的字段保持原值,正文整段替换,旧正文只留作内部撤回,不展示也不参与检索。条数到硬上限时把同层最久没被读的一条移进归档,结果里写明动了哪条,不拒绝新记忆;单条正文超限只报错、不截断。前置条件不同的同一标题是两条。${NOTES}`,
1764
+ description: `保存一条记忆。只在会改变以后判断时才存;先搜已有条目,能更新就不要新建。保存前先蒸馏:标题是结论,正文只写原则与触发条件,通常 2–4 句;路径、字段表、完整命令、版本号、尺寸数值不要抄进正文,改成文档或 skill 的指针。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。更新同一件事时用同一个标题:身份只看标题、旧标题和前置条件,像不像不授权覆盖。文本相近但标题不同时先不写,返回 needsResolution 与 1–2 条相近条目:确实是同一条就改成那个标题重存,确实是另一条才带 resolution="distinct" 再存。按标题更新时没给的字段保持原值,正文整段替换,旧正文只留作内部撤回,不展示也不参与检索。条数到硬上限时把同层最久没被读的一条移进归档,结果里写明动了哪条,不拒绝新记忆;单条正文超限只报错、不截断。前置条件不同的同一标题是两条。${NOTES}`,
1549
1765
  parameters: SAVE_PARAMS,
1550
1766
  output: {
1551
1767
  presentationMeta: (_args, value) => ({
1552
1768
  kind: 'memory-save',
1553
- id: value.id || '',
1554
1769
  title: value.title || '',
1555
1770
  scope: value.scope || '',
1556
1771
  revision: value.revision || 0,
1557
1772
  changed: value.changed === true,
1558
1773
  recordKind: typeof value.kind === 'string' && value.kind ? value.kind : '',
1559
1774
  mode: value.needsScope ? 'needs_scope'
1560
- : value.existingId ? 'duplicate'
1775
+ : value.existingTitle ? 'duplicate'
1561
1776
  : value.needsResolution ? 'needs_resolution'
1562
1777
  : value.changed === false ? 'unchanged' : value.merged ? 'updated' : 'created',
1563
1778
  }),
1564
1779
  schema: {
1565
1780
  type: 'object',
1566
1781
  properties: {
1567
- id: { type: 'string' },
1568
1782
  title: { type: 'string' },
1569
1783
  summary: { type: 'string' },
1570
1784
  when: { type: 'string' },
@@ -1579,32 +1793,35 @@ export function apply(ctx, config) {
1579
1793
  needsResolution: { type: 'boolean' },
1580
1794
  needsScope: { type: 'boolean' },
1581
1795
  reason: { type: 'string' },
1582
- existingId: { type: 'string' },
1796
+ existingTitle: { type: 'string' },
1583
1797
  existingScope: { type: 'string' },
1584
1798
  crossLayer: { type: 'boolean' },
1799
+ crossScope: { type: 'string', enum: ['global', 'project'] },
1585
1800
  sameTopic: { type: 'boolean' },
1586
- similarIds: { type: 'string' },
1801
+ similarTitles: { type: 'string' },
1587
1802
  similar: { type: 'string' },
1803
+ supersededTitle: { type: 'string' },
1588
1804
  archived: { type: 'string' },
1805
+ pendingConflicts: { type: 'integer' },
1589
1806
  },
1590
- required: ['id', 'title', 'summary', 'when', 'revision', 'merged', 'changed', 'scope', 'needsResolution', 'archived'],
1807
+ required: ['title', 'summary', 'when', 'revision', 'merged', 'changed', 'scope', 'needsResolution', 'archived'],
1591
1808
  additionalProperties: false,
1592
1809
  },
1593
1810
  render: (_args, value) => [{
1594
1811
  type: 'text',
1595
1812
  text: value.needsScope
1596
1813
  ? value.reason === 'memory_disabled'
1597
- ? 'memory · 本项目记忆已关闭,先在面板打开'
1814
+ ? `memory · ${memoryOffCopy(projectScope)}`
1598
1815
  : value.reason === 'unconfirmed_project'
1599
1816
  ? `memory · 当前目录还不能确认属于哪个项目:「${value.title}」\n先确认项目根目录,或显式 scope="global"`
1600
1817
  : `memory · 归属还没定:「${value.title}」\n补 memoryClass(personal_preference / project_fact / environment_fact / portable_procedure)或显式 scope;设备事实还要给设备条件`
1601
- : value.existingId
1602
- ? `memory · 已有相同记忆:「${value.title}」 · ${value.existingId}(${value.existingScope})\n要改成另一条,加 resolution="distinct"`
1818
+ : value.existingTitle
1819
+ ? `memory · 已有相同记忆:「${value.title}」(${value.existingScope})\n要改成另一条,加 resolution="distinct"`
1603
1820
  : value.needsResolution
1604
- ? `memory · 需要确认:「${value.title}」与已有条目相近${value.crossLayer ? '(在另一层)' : ''}${value.sameTopic ? '(同一主题槽位)' : ''}\n${value.similar}\n要更新它,用 id 重存;确实是另一条,加 resolution="distinct"`
1821
+ ? `memory · 需要确认:「${value.title}」与已有条目相近${value.crossLayer ? '(在另一层)' : ''}${value.sameTopic ? '(同一主题槽位)' : ''}\n${value.similar}\n要更新它:用同一个标题${value.crossScope === 'global' ? '、并带 scope="global"' : ''}再存一次;确实是另一条,加 resolution="distinct"`
1605
1822
  : value.changed === false
1606
- ? `memory · unchanged ${withTag(value, `"${value.title}"`)} · ${value.id}`
1607
- : `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''} · ${value.id}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}${value.fullTextSaved ? ' · 完整原句已另存' : ''}`,
1823
+ ? `memory · unchanged ${withTag(value, `"${value.title}"`)}`
1824
+ : `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}${value.fullTextSaved ? ' · 完整原句已另存' : ''}${value.supersededTitle ? ` · 已取代「${value.supersededTitle}」` : ''}`,
1608
1825
  }],
1609
1826
  },
1610
1827
  execute: async (args, execution) => {
@@ -1612,15 +1829,44 @@ export function apply(ctx, config) {
1612
1829
  const projectScopeName = projectNameOf(projectScope);
1613
1830
  if (!memoryEnabled(projectScope)) {
1614
1831
  return {
1615
- id: '', title: String(args.title || ''), summary: '', when: '', revision: 0,
1832
+ title: String(args.title || ''), summary: '', when: '', revision: 0,
1616
1833
  merged: false, changed: false, scope: '', needsScope: true, reason: 'memory_disabled',
1617
- needsResolution: false, similarIds: '', similar: '', archived: '',
1834
+ recoveryPending: controlStatus(projectScope).recoveryPending,
1835
+ needsResolution: false, similarTitles: '', similar: '', archived: '',
1618
1836
  };
1619
1837
  }
1620
- const saved = saveEntry(projectScope, { ...args, writeMode: args.mode || 'patch' });
1838
+ const supersedes = String(args.supersedes || '').trim();
1839
+ const supersedesId = supersedes && !/^mem_[a-z0-9]+$/.test(supersedes)
1840
+ ? (() => { try { return refIdOf(projectScope, { title: supersedes }, '记忆'); } catch { return ''; } })()
1841
+ : supersedes;
1842
+ const draft = { ...args, writeMode: args.mode || 'patch' };
1843
+ if (supersedes) { if (supersedesId) draft.supersedes = supersedesId; else delete draft.supersedes; }
1844
+ /** 更新句柄也用标题:id 只是底层实现,模型抄标题就行。 */
1845
+ const updateTitle = String(args.updateTitle || '').trim();
1846
+ if (updateTitle) {
1847
+ const candidates = args.scope ? [] : titleCandidates(projectScope, updateTitle);
1848
+ if (candidates.length > 1) {
1849
+ return { error: `memory · 「${updateTitle}」在项目层和全局层各有一条:改全局加 scope="global",改项目加 scope="project"` };
1850
+ }
1851
+ const updateId = (() => { try { return refIdOf(projectScope, { title: updateTitle, scope: args.scope }, '记忆'); } catch { return ''; } })();
1852
+ if (!updateId) return { error: `memory · 没有「${updateTitle}」这条记忆;要新增就不用 updateTitle` };
1853
+ draft.id = updateId;
1854
+ }
1855
+ /** 关联目标同样按标题解析成内部 id 再落盘。 */
1856
+ if (Array.isArray(args.links) && args.links.length) {
1857
+ const resolved = [];
1858
+ for (const link of args.links) {
1859
+ const title = typeof link?.title === 'string' ? link.title.trim() : '';
1860
+ if (!title) return { error: 'memory · 关联要写成 {type, title}:给被关联那条记忆的标题' };
1861
+ const targetId = (() => { try { return refIdOf(projectScope, { title }, '记忆'); } catch { return ''; } })();
1862
+ if (!targetId) return { error: `memory · 没有「${title}」这条记忆,关联没写成` };
1863
+ resolved.push({ type: link.type || 'related', id: targetId });
1864
+ }
1865
+ draft.links = resolved;
1866
+ }
1867
+ const saved = saveEntry(projectScope, draft);
1621
1868
  if (saved.needsScope) {
1622
1869
  return {
1623
- id: '',
1624
1870
  title: saved.draft?.title || '',
1625
1871
  summary: saved.draft?.summary || '',
1626
1872
  when: saved.draft?.when || '',
@@ -1631,14 +1877,13 @@ export function apply(ctx, config) {
1631
1877
  needsScope: true,
1632
1878
  reason: saved.reasonCode,
1633
1879
  needsResolution: false,
1634
- similarIds: '',
1880
+ similarTitles: '',
1635
1881
  similar: '',
1636
1882
  archived: '',
1637
1883
  };
1638
1884
  }
1639
1885
  if (saved.existing) {
1640
1886
  return {
1641
- id: saved.existing.id,
1642
1887
  title: saved.existing.title || saved.draft?.title || '',
1643
1888
  summary: '',
1644
1889
  when: '',
@@ -1648,10 +1893,10 @@ export function apply(ctx, config) {
1648
1893
  scope: saved.existing.scope,
1649
1894
  scopeName: saved.existing.scope === 'global' ? '' : projectScopeName,
1650
1895
  needsScope: false,
1651
- existingId: saved.existing.id,
1896
+ existingTitle: saved.existing.title || saved.draft?.title || '',
1652
1897
  existingScope: saved.existing.scope,
1653
1898
  needsResolution: false,
1654
- similarIds: '',
1899
+ similarTitles: '',
1655
1900
  similar: '',
1656
1901
  archived: '',
1657
1902
  };
@@ -1659,7 +1904,6 @@ export function apply(ctx, config) {
1659
1904
  const scope = saved.chosenScope === 'global' || (args.scope === 'global' && !saved.chosenScope) ? globalScope() : projectScope;
1660
1905
  if (saved.needsResolution) {
1661
1906
  return {
1662
- id: '',
1663
1907
  title: saved.draft.title,
1664
1908
  summary: saved.draft.summary,
1665
1909
  when: saved.draft.when,
@@ -1670,15 +1914,16 @@ export function apply(ctx, config) {
1670
1914
  needsScope: false,
1671
1915
  needsResolution: true,
1672
1916
  crossLayer: Boolean(saved.crossLayer),
1917
+ crossScope: saved.crossScope === 'global' ? 'global' : 'project',
1673
1918
  sameTopic: Boolean(saved.sameTopic),
1674
- similarIds: saved.similarList.map((item) => item.id).join('、'),
1675
- similar: saved.similarList.map((item) => `${item.id}「${item.title}」${item.summary ? `|${item.summary}` : ''}`).join(';'),
1919
+ similarTitles: saved.similarList.map((item) => item.title).join('、'),
1920
+ similar: saved.similarList.map((item) => describeCandidate(item, { sameTopic: saved.sameTopic })).join(';'),
1676
1921
  archived: '',
1922
+ pendingConflicts: Number(saved.pendingConflicts) || 0,
1677
1923
  };
1678
1924
  }
1679
1925
  recordCitations(scope, sessionIdOf(execution), [saved.record]);
1680
1926
  return {
1681
- id: saved.record.id,
1682
1927
  title: saved.record.title,
1683
1928
  summary: saved.record.summary,
1684
1929
  when: saved.record.when || '',
@@ -1692,9 +1937,11 @@ export function apply(ctx, config) {
1692
1937
  scopeName: scope.key === 'global' ? '' : projectScopeName,
1693
1938
  needsScope: false,
1694
1939
  needsResolution: false,
1695
- similarIds: saved.similarList.map((item) => item.id).join('、'),
1940
+ similarTitles: saved.similarList.map((item) => item.title).join('、'),
1941
+ supersededTitle: supersedes && supersedesId ? supersedes : '',
1696
1942
  archived: (saved.archived || []).map((item) => item.title).join('、'),
1697
- ...(saved.similar ? { similar: `${saved.similar.id}「${saved.similar.title}」` } : {}),
1943
+ pendingConflicts: Number(saved.pendingConflicts) || 0,
1944
+ ...(saved.similar ? { similar: `「${saved.similar.title}」` } : {}),
1698
1945
  };
1699
1946
  },
1700
1947
  }), 'lite-memory: memory_save');
@@ -1712,7 +1959,6 @@ export function apply(ctx, config) {
1712
1959
  title: { type: 'string' },
1713
1960
  id: { type: 'string' },
1714
1961
  reason: { type: 'string' },
1715
- targetId: { type: 'string' },
1716
1962
  targetTitle: { type: 'string' },
1717
1963
  },
1718
1964
  required: ['asked'],
@@ -1721,7 +1967,7 @@ export function apply(ctx, config) {
1721
1967
  render: (_args, value) => [{
1722
1968
  type: 'text',
1723
1969
  text: value.asked
1724
- ? `memory · suggested "${value.title}" · ${value.id}${value.targetId ? ` · 确认后更新 ${value.targetId}「${value.targetTitle}」` : ''}`
1970
+ ? `memory · suggested "${value.title}"${value.targetTitle ? ` · 确认后更新「${value.targetTitle}」` : ''}`
1725
1971
  : `memory · skipped${value.reason ? `\n${value.reason}` : ''}`,
1726
1972
  }],
1727
1973
  },
@@ -1735,14 +1981,14 @@ export function apply(ctx, config) {
1735
1981
  asked: true,
1736
1982
  title: noted.title,
1737
1983
  id: noted.id,
1738
- ...(noted.targetId ? { targetId: noted.targetId, targetTitle: noted.targetTitle } : {}),
1984
+ ...(noted.targetTitle ? { targetTitle: noted.targetTitle } : {}),
1739
1985
  };
1740
1986
  },
1741
1987
  }), 'lite-memory: memory_suggest');
1742
1988
 
1743
1989
  ctx.effect(() => tools.register({
1744
1990
  name: 'memory_search',
1745
- description: `按需检索已有记忆:先搜,命中再 memory_read 读正文。用户提到「记住了什么 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」,或者眼前反复出现的报错、弹窗、异常时,先查这里有没有记过解法。跨项目的通用条目以 [global] 开头,项目条目以 [项目名] 开头。中文长句可以直接当查询。每行带 id,更新时把 id 传回 memory_save。默认只列当前有效的;已经归档或被更新替换的旧说法不混进来,要查历史就在参数里显式要 history。`,
1991
+ description: `按需检索已有记忆:先搜,命中再 memory_read 读正文。用户提到「记住了什么 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」,或者眼前反复出现的报错、弹窗、异常时,先查这里有没有记过解法。跨项目的通用条目以 [global] 开头,项目条目以 [项目名] 开头。中文长句可以直接当查询。每行给标题与适用范围;要更新就用那个标题重存。默认只列当前有效的;已经归档或被更新替换的旧说法不混进来,要查历史就在参数里显式要 history。`,
1746
1992
  parameters: SEARCH_PARAMS,
1747
1993
  output: {
1748
1994
  presentationMeta: (_args, value) => ({
@@ -1763,7 +2009,6 @@ export function apply(ctx, config) {
1763
2009
  items: {
1764
2010
  type: 'object',
1765
2011
  properties: {
1766
- id: { type: 'string' },
1767
2012
  title: { type: 'string' },
1768
2013
  scope: { type: 'string' },
1769
2014
  revision: { type: 'integer' },
@@ -1771,8 +2016,9 @@ export function apply(ctx, config) {
1771
2016
  origin: { type: 'string' },
1772
2017
  kind: { type: 'string' },
1773
2018
  when: { type: 'string' },
2019
+ updated: { type: 'string' }, // ui-copy-ignore
1774
2020
  },
1775
- required: ['id', 'title', 'scope', 'revision', 'status', 'origin', 'kind', 'when'],
2021
+ required: ['title', 'scope', 'revision', 'status', 'origin', 'kind', 'when'],
1776
2022
  additionalProperties: false,
1777
2023
  },
1778
2024
  },
@@ -1783,7 +2029,7 @@ export function apply(ctx, config) {
1783
2029
  render: (_args, value) => [{
1784
2030
  type: 'text',
1785
2031
  text: value.status === 'budget_exhausted'
1786
- ? 'memory · 本请求的记忆额度已用完'
2032
+ ? 'memory · 这条消息已停止检索,下一条消息再查'
1787
2033
  : value.status === 'memory_disabled'
1788
2034
  ? 'memory · 记忆检索已关闭'
1789
2035
  : value.status === 'needs_context'
@@ -1797,7 +2043,7 @@ export function apply(ctx, config) {
1797
2043
  if (recallMode() === 'off' || !memoryEnabledFor(execution)) return { count: 0, chars: 0, text: '', status: 'memory_disabled', refs: [] };
1798
2044
  const agent = execution?.agent;
1799
2045
  const win = windowOf(agent);
1800
- if (win && !affordable(win, 0)) return { count: 0, chars: 0, text: '', status: 'budget_exhausted', refs: [] };
2046
+ if (win && !affordableTool(win, 0)) return { count: 0, chars: 0, text: '', status: 'budget_exhausted', refs: [] };
1801
2047
  const wantsBind = args.bindForRequest === true;
1802
2048
  const contextId = wantsBind && !args.contextId ? toolContextFor(execution) : args.contextId;
1803
2049
  if (wantsBind && !contextId) {
@@ -1848,8 +2094,9 @@ export function apply(ctx, config) {
1848
2094
  };
1849
2095
  if (win) {
1850
2096
  const bytes = jsonBytes(value);
1851
- if (!affordable(win, bytes)) return { ...value, status: 'budget_exhausted', text: '' };
2097
+ if (!affordableTool(win, bytes)) return { ...value, status: 'budget_exhausted', text: '' };
1852
2098
  win.bytes += bytes;
2099
+ win.toolBytes += bytes;
1853
2100
  win.reads += 1;
1854
2101
  }
1855
2102
  return value;
@@ -1859,18 +2106,20 @@ export function apply(ctx, config) {
1859
2106
  const empty = { count: 0, chars: 0, text: '', status: 'ok', reasonCode: 'no_match', noTriggerReason: '没有匹配的记忆', refs: [] };
1860
2107
  if (win) {
1861
2108
  const bytes = jsonBytes(empty);
1862
- if (!affordable(win, bytes)) return { ...empty, status: 'budget_exhausted' };
2109
+ if (!affordableTool(win, bytes)) return { ...empty, status: 'budget_exhausted' };
1863
2110
  win.bytes += bytes;
2111
+ win.toolBytes += bytes;
1864
2112
  win.reads += 1;
1865
2113
  }
1866
2114
  return empty;
1867
2115
  }
1868
2116
  if (win) {
1869
2117
  let kept = shown.length;
1870
- while (kept > 0 && !affordable(win, jsonBytes(valueOf(kept)))) kept -= 1;
2118
+ while (kept > 0 && !affordableTool(win, jsonBytes(valueOf(kept)))) kept -= 1;
1871
2119
  if (kept === 0) return { count: 0, chars: 0, text: '', status: 'budget_exhausted', refs: [] };
1872
2120
  const value = valueOf(kept);
1873
2121
  win.bytes += jsonBytes(value);
2122
+ win.toolBytes += jsonBytes(value);
1874
2123
  win.reads += 1;
1875
2124
  recordCitations(scope, sessionIdOf(execution), hits.slice(0, kept));
1876
2125
  return value;
@@ -1883,13 +2132,12 @@ export function apply(ctx, config) {
1883
2132
 
1884
2133
  ctx.effect(() => tools.register({
1885
2134
  name: 'memory_read',
1886
- description: '读一条记忆的正文。搜索结果里只有一行摘要,需要细节时按 id 读。索引里还有、正文文件丢了会直接报错,不会返回空正文。',
2135
+ description: '读一条记忆的正文。搜索结果里只有一行摘要,需要细节时按标题读。索引里还有、正文文件丢了会直接报错,不会返回空正文。',
1887
2136
  parameters: READ_PARAMS,
1888
2137
  output: {
1889
2138
  schema: {
1890
2139
  type: 'object',
1891
2140
  properties: {
1892
- id: { type: 'string' },
1893
2141
  title: { type: 'string' },
1894
2142
  when: { type: 'string' },
1895
2143
  revision: { type: 'integer' },
@@ -1902,23 +2150,23 @@ export function apply(ctx, config) {
1902
2150
  links: { type: 'string' },
1903
2151
  repeat: { type: 'boolean' },
1904
2152
  },
1905
- required: ['id', 'scope', 'status'],
2153
+ required: ['scope', 'status'],
1906
2154
  additionalProperties: false,
1907
2155
  },
1908
2156
  render: (_args, value) => [{
1909
2157
  type: 'text',
1910
2158
  text: value.status === 'budget_exhausted'
1911
- ? 'memory · 本请求的记忆额度已用完'
2159
+ ? 'memory · 这条消息已停止读记忆,下一条消息再读'
1912
2160
  : value.status === 'memory_disabled'
1913
2161
  ? 'memory · 记忆检索已关闭'
1914
2162
  : value.repeat
1915
2163
  ? 'memory · 本请求已读过这条记忆'
1916
2164
  : value.body
1917
- ? `memory · ${withTag(value, `「${value.title}」`)}${value.when ? `(${value.when})` : ''} · ${value.id}${statusLabel(value.status)}\n${value.body}`
2165
+ ? `memory · ${withTag(value, `「${value.title}」`)}${value.when ? `(${value.when})` : ''}${statusLabel(value.status)}\n${value.body}`
1918
2166
  : value.links !== undefined && value.status === 'ok'
1919
2167
  ? `memory · 「${value.title}」的相关记忆\n${value.links || '(无)'}`
1920
2168
  : value.status === 'needs_context'
1921
- ? 'memory · 这条记忆的适用条件还没确认'
2169
+ ? 'memory · 这条记忆的适用条件还没确认,先说明当前任务再读'
1922
2170
  : value.status === 'not_applicable'
1923
2171
  ? 'memory · 这条记忆不适用于当前任务'
1924
2172
  : value.status === 'archived'
@@ -1929,7 +2177,6 @@ export function apply(ctx, config) {
1929
2177
  }],
1930
2178
  presentationMeta: (_args, value) => ({
1931
2179
  kind: 'memory-read',
1932
- id: value.id || '',
1933
2180
  title: value.title || '',
1934
2181
  scope: value.scope || '',
1935
2182
  revision: value.revision || 0,
@@ -1939,11 +2186,13 @@ export function apply(ctx, config) {
1939
2186
  }),
1940
2187
  },
1941
2188
  execute: async (args, execution) => {
1942
- if (recallMode() === 'off' || !memoryEnabledFor(execution)) return { id: '', scope: '', status: 'memory_disabled' };
2189
+ if (recallMode() === 'off' || !memoryEnabledFor(execution)) return { scope: '', status: 'memory_disabled' };
1943
2190
  const resolved = resolveRequestContext(execution, args.contextId);
1944
- if (!resolved.ok) return { id: '', scope: '', status: resolved.status || 'needs_context' };
2191
+ if (!resolved.ok) return { scope: '', status: resolved.status || 'needs_context' };
1945
2192
  const scope = resolved.scope;
1946
2193
  const scopeName = scope.key === 'global' ? '' : projectNameOf(scope);
2194
+ /** 明确指定层时只在那一层解析标题,同名条目不会串到别的层。 */
2195
+ const readScope = args.scope === 'global' ? globalScope() : scope;
1947
2196
  const context = resolved.context;
1948
2197
  const allowExperimental = resolved.experimental === true;
1949
2198
  const agent = execution?.agent;
@@ -1951,48 +2200,57 @@ export function apply(ctx, config) {
1951
2200
  const gateRuntime = resolved.held || win?.runtime
1952
2201
  || { projectId: scope.key, context, experimental: resolved.experimental === true };
1953
2202
  if (args.view === 'links') {
1954
- const source = locate(scope, args.id);
1955
- if (source && !view.eligibility(source.record, gateRuntime, 'read').ok) {
1956
- return { id: '', scope: '', status: 'needs_context', links: '' };
1957
- }
1958
- const found = readLinks(scope, args.id, { context, allowExperimental, runtime: gateRuntime, now: Date.now() });
1959
- if (!found) throw new Error(`没有 ${args.id} 这条记忆`);
2203
+ const refId = refIdOf(readScope, args);
2204
+ const source = locate(readScope, refId);
2205
+ const gateOk = !source || view.eligibility(source.record, gateRuntime, 'read').ok;
2206
+ if (!gateOk) return { scope: '', status: 'needs_context', links: '' };
2207
+ const found = readLinks(readScope, refId, { context, allowExperimental, runtime: gateRuntime, now: Date.now() });
2208
+ if (!found) throw new Error(`没有「${String(args.title || args.id || '').trim()}」这条记忆`);
1960
2209
  if (found.status !== 'ok') {
1961
- const withheld = { id: '', scope: '', status: found.status, links: '' };
2210
+ const withheld = { scope: '', status: found.status, links: '' };
1962
2211
  if (win) {
1963
2212
  const bytes = jsonBytes(withheld);
1964
- if (!affordable(win, bytes)) return { id: '', scope: '', status: 'budget_exhausted' };
2213
+ if (!affordableTool(win, bytes)) return { scope: '', status: 'budget_exhausted' };
1965
2214
  win.bytes += bytes;
2215
+ win.toolBytes += bytes;
1966
2216
  win.reads += 1;
1967
2217
  }
1968
2218
  return withheld;
1969
2219
  }
1970
2220
  const rows = found.links.filter((link) => link.status === 'invalid' || link.status === 'ok')
1971
2221
  .map((link) => (link.status === 'invalid'
1972
- ? `- ${link.id} · 已失效`
1973
- : `- ${link.scope === 'global' ? '[global] ' : (scopeName ? `[${scopeName}] ` : '')}「${link.title}」 · ${link.id}`));
2222
+ ? '- 有一条已失效'
2223
+ : `- ${link.scope === 'global' ? '[global] ' : (scopeName ? `[${scopeName}] ` : '')}「${link.title}」`));
1974
2224
  const text = rows.join('\n') || '(无)';
1975
- const value = { id: found.id, scope: found.scope, scopeName, status: found.status, links: text };
2225
+ const value = { scope: found.scope, scopeName, status: found.status, links: text };
1976
2226
  if (win) {
1977
2227
  const bytes = jsonBytes(value);
1978
- if (!affordable(win, bytes)) return { id: '', scope: '', status: 'budget_exhausted' };
2228
+ if (!affordableTool(win, bytes)) return { scope: '', status: 'budget_exhausted' };
1979
2229
  win.bytes += bytes;
2230
+ win.toolBytes += bytes;
1980
2231
  win.reads += 1;
1981
2232
  }
1982
2233
  return value;
1983
2234
  }
1984
- const record = readEntry(scope, args.id, { context, allowExperimental });
1985
- if (!record) throw new Error(`没有 ${args.id} 这条记忆`);
2235
+ const record = readEntry(readScope, refIdOf(readScope, args), {
2236
+ layers: args.scope ? [readScope] : undefined,
2237
+ context,
2238
+ allowExperimental,
2239
+ /** 只有真正把正文交出去的读取才算用过:条件未知/被取代/过期不更新使用记录。 */
2240
+ usage: (item) => view.eligibility(item, gateRuntime, 'read').ok,
2241
+ });
2242
+ if (!record) throw new Error(`没有「${String(args.title || args.id || '').trim()}」这条记忆`);
2243
+ /** 归档条目还能按标题找到:只回状态,不交正文,也不把它当成「不存在」。 */
2244
+ if (record.status === 'archived') return { title: record.title, scope: record.scope, status: 'archived', body: '' };
1986
2245
  const gate = view.eligibility(record, gateRuntime, 'read');
1987
- if (!gate.ok) return { id: '', scope: record.scope, status: gate.status, body: '' };
1988
- /** 条件未知或不适用的条目只报状态:正文和标题都不能绕过自动召回的门槛。 */
1989
- if (record.status !== 'ok') {
1990
- return { id: '', scope: record.scope, status: record.status === 'needs_context' ? 'needs_context' : 'not_applicable', body: '' };
2246
+ if (!gate.ok) return { scope: record.scope, status: gate.status, body: '' };
2247
+ /** 条件未知或不适用的条目只报状态:标题只能定位,不能证明适用,正文一律不先返回。 */
2248
+ if (record.status !== 'ok' && record.status !== 'archived') {
2249
+ return { scope: record.scope, status: record.status === 'needs_context' ? 'needs_context' : 'not_applicable', body: '' };
1991
2250
  }
1992
2251
  const revision = record.revision || 1;
1993
2252
  const body = String(record.body ?? '');
1994
2253
  const value = {
1995
- id: record.id,
1996
2254
  title: record.title,
1997
2255
  when: record.when || '',
1998
2256
  revision,
@@ -2003,19 +2261,21 @@ export function apply(ctx, config) {
2003
2261
  body,
2004
2262
  };
2005
2263
  if (win) {
2006
- if (!affordable(win, 0)) return { id: '', scope: '', status: 'budget_exhausted' };
2264
+ if (!affordableTool(win, 0)) return { scope: '', status: 'budget_exhausted' };
2007
2265
  const seenRevision = win.readBodies.get(record.id);
2008
2266
  if (seenRevision === revision) {
2009
2267
  const receipt = { ...value, body: '', repeat: true };
2010
2268
  const bytes = jsonBytes(receipt);
2011
- if (!affordable(win, bytes)) return { id: '', scope: '', status: 'budget_exhausted' };
2269
+ if (!affordableTool(win, bytes)) return { scope: '', status: 'budget_exhausted' };
2012
2270
  win.bytes += bytes;
2271
+ win.toolBytes += bytes;
2013
2272
  win.reads += 1;
2014
2273
  return receipt;
2015
2274
  }
2016
2275
  const bytes = jsonBytes(value);
2017
- if (!affordable(win, bytes, 1)) return { id: '', scope: '', status: 'budget_exhausted' };
2276
+ if (!affordableTool(win, bytes, 1)) return { scope: '', status: 'budget_exhausted' };
2018
2277
  win.bytes += bytes;
2278
+ win.toolBytes += bytes;
2019
2279
  win.bodies += 1;
2020
2280
  win.reads += 1;
2021
2281
  win.readBodies.set(record.id, revision);
@@ -2033,13 +2293,12 @@ export function apply(ctx, config) {
2033
2293
  schema: {
2034
2294
  type: 'object',
2035
2295
  properties: {
2036
- id: { type: 'string' },
2037
2296
  title: { type: 'string' },
2038
2297
  scope: { type: 'string' },
2039
2298
  scopeName: { type: 'string' },
2040
2299
  mode: { type: 'string', enum: ['archive', 'purged', 'needs_resolution', 'purge_pending', 'memory_disabled'] },
2041
2300
  },
2042
- required: ['id', 'mode'],
2301
+ required: ['mode'],
2043
2302
  additionalProperties: false,
2044
2303
  },
2045
2304
  render: (_args, value) => [{
@@ -2047,16 +2306,15 @@ export function apply(ctx, config) {
2047
2306
  text: value.mode === 'memory_disabled'
2048
2307
  ? 'memory · 本项目记忆已关闭,先在面板打开'
2049
2308
  : value.mode === 'purged'
2050
- ? `memory · forgotten "${value.title}" · ${value.id}`
2309
+ ? `memory · forgotten "${value.title}"`
2051
2310
  : value.mode === 'purge_pending'
2052
- ? `memory · 清除未完成 · ${value.id},重启后会继续`
2311
+ ? `memory · 清除未完成 · 「${value.title || '这条记忆'}」,重启后会继续`
2053
2312
  : value.mode === 'needs_resolution'
2054
- ? `memory · 先确认是归档还是彻底忘掉 · ${value.id}`
2055
- : `memory · archived ${withTag(value, `"${value.title}"`)} · ${value.id},可找回`,
2313
+ ? `memory · 先确认是归档还是彻底忘掉 · 「${value.title || '这条记忆'}」`
2314
+ : `memory · archived ${withTag(value, `"${value.title}"`)},可找回`,
2056
2315
  }],
2057
2316
  presentationMeta: (_args, value) => ({
2058
2317
  kind: 'memory-forget',
2059
- id: value.id || '',
2060
2318
  title: value.title || '',
2061
2319
  scope: value.scope || '',
2062
2320
  mode: value.mode,
@@ -2065,25 +2323,33 @@ export function apply(ctx, config) {
2065
2323
  execute: async (args, execution) => {
2066
2324
  const scope = scopeOf(cwdOf(execution));
2067
2325
  const projectScopeName = projectNameOf(scope);
2068
- if (!memoryEnabled(scope)) return { id: String(args.id || ''), title: '', scope: '', mode: 'memory_disabled' };
2069
- const located = locate(scope, args.id);
2070
- if (!located && !args.mode) throw new Error(`没有 ${args.id} 这条记忆`);
2326
+ if (!memoryEnabled(scope)) return { title: '', scope: '', mode: 'memory_disabled' };
2327
+ const forgetTitle = String(args.title || args.id || '').trim();
2328
+ if (!args.scope && args.mode) {
2329
+ const candidates = titleCandidates(scope, forgetTitle);
2330
+ if (candidates.length > 1) {
2331
+ throw new Error(`「${forgetTitle}」在项目层和全局层各有一条:删全局加 scope="global",删项目加 scope="project"`);
2332
+ }
2333
+ }
2334
+ const onlyLayer = args.scope === 'global' ? globalScope() : args.scope === 'project' ? scope : null;
2335
+ const refId = refIdOf(scope, args);
2336
+ const located = locate(scope, refId, onlyLayer ? { layers: [onlyLayer] } : {});
2337
+ if (!located && !args.mode) throw new Error(`没有「${String(args.title || args.id || '').trim()}」这条记忆`);
2071
2338
  if (!args.mode) {
2072
- return { id: args.id, mode: 'needs_resolution', scope: located.scope.key === 'global' ? 'global' : 'project' };
2339
+ return { title: located.record.title || '', mode: 'needs_resolution', scope: located.scope.key === 'global' ? 'global' : 'project' };
2073
2340
  }
2074
2341
  let dropped;
2075
2342
  try {
2076
- dropped = forget(scope, args.id, { mode: args.mode });
2343
+ dropped = forget(scope, refId, onlyLayer ? { mode: args.mode, layers: [onlyLayer] } : { mode: args.mode });
2077
2344
  } catch (error) {
2078
2345
  if (error && error.code === 'purge_pending') {
2079
2346
  const layer = located ? located.scope : scope;
2080
2347
  try {
2081
2348
  state.resumePurge(layer);
2082
- dropped = forget(scope, args.id, { mode: args.mode });
2349
+ dropped = forget(scope, refId, onlyLayer ? { mode: args.mode, layers: [onlyLayer] } : { mode: args.mode });
2083
2350
  } catch (again) {
2084
2351
  if (again && again.code === 'purge_pending') {
2085
2352
  return {
2086
- id: args.id,
2087
2353
  title: located ? located.record.title : '',
2088
2354
  mode: 'purge_pending',
2089
2355
  scope: layer.key === 'global' ? 'global' : 'project',
@@ -2096,11 +2362,10 @@ export function apply(ctx, config) {
2096
2362
  throw error;
2097
2363
  }
2098
2364
  }
2099
- if (!dropped) throw new Error(`没有 ${args.id} 这条记忆`);
2365
+ if (!dropped) throw new Error(`没有「${String(args.title || args.id || '').trim()}」这条记忆`);
2100
2366
  const layerKey = dropped.scopeKey || (located ? located.scope.key : scope.key);
2101
2367
  if (dropped.mode === 'purged') revokeLearningUse(layerKey);
2102
2368
  return {
2103
- id: dropped.id,
2104
2369
  title: dropped.title || (located ? located.record.title : ''),
2105
2370
  mode: dropped.mode,
2106
2371
  scope: layerKey === 'global' ? 'global' : 'project',
@@ -2239,7 +2504,7 @@ export function apply(ctx, config) {
2239
2504
  const ids = fresh.map((message) => String(message?.id ?? '')).filter(Boolean).sort();
2240
2505
  const win = openWindow(agent, { sessionId, turn, messageIds: ids });
2241
2506
  const budgetBefore = win ? {
2242
- bytes: win.bytes, bodies: win.bodies, reads: win.reads,
2507
+ bytes: win.bytes, bodies: win.bodies, reads: win.reads, toolBytes: win.toolBytes || 0,
2243
2508
  provided: new Map(win.provided), readBodies: new Map(win.readBodies),
2244
2509
  recallHeader: win.recallHeader, experimentBytes: win.experimentBytes,
2245
2510
  autoBudget: win.autoBudget ? JSON.parse(JSON.stringify(win.autoBudget)) : null,
@@ -2259,6 +2524,7 @@ export function apply(ctx, config) {
2259
2524
  }
2260
2525
  const scope = scopeOf(cwd);
2261
2526
  if (!memoryEnabled(scope)) {
2527
+ noteRecall(scope, sessionId, { reason: 'memory_off' });
2262
2528
  clearPrepared(agent);
2263
2529
  return decision;
2264
2530
  }
@@ -2269,7 +2535,9 @@ export function apply(ctx, config) {
2269
2535
  */
2270
2536
  const runtime = runtimeFor(scope, { sessionId, query, batchKey, messageIds: ids, autoRoute: recallMode() === 'adaptive' });
2271
2537
  if (win && runtime) win.runtime = runtime.held;
2538
+ rememberRuntime(scope, sessionId, runtime && runtime.held);
2272
2539
  if (recallMode() !== 'adaptive') {
2540
+ noteRecall(scope, sessionId, { reason: 'tools_only' });
2273
2541
  clearPrepared(agent);
2274
2542
  return decision;
2275
2543
  }
@@ -2286,12 +2554,16 @@ export function apply(ctx, config) {
2286
2554
  ? rememberFeedback(scope, sessionId, fresh, runtime.held, turn)
2287
2555
  : null;
2288
2556
  const snapshot = projectRecall(scope, query, recalledContext, win, runtime ? runtime.held : null);
2557
+ noteRecall(scope, sessionId, {
2558
+ reason: snapshot.reason, matched: snapshot.matched, cards: snapshot.cards.length, chars: snapshot.bytes,
2559
+ });
2289
2560
  /** 没发布出去的投影要退还额度:不能因为一次超时把本请求的召回额度耗掉。 */
2290
2561
  const rollbackBudget = () => {
2291
2562
  if (!win || !budgetBefore) return;
2292
2563
  win.bytes = budgetBefore.bytes;
2293
2564
  win.bodies = budgetBefore.bodies;
2294
2565
  win.reads = budgetBefore.reads;
2566
+ win.toolBytes = budgetBefore.toolBytes;
2295
2567
  win.provided = budgetBefore.provided;
2296
2568
  win.readBodies = budgetBefore.readBodies;
2297
2569
  win.recallHeader = budgetBefore.recallHeader;
@@ -2314,7 +2586,7 @@ export function apply(ctx, config) {
2314
2586
  return decision;
2315
2587
  }
2316
2588
  /** 实验上下文即使本轮零注入也要留着:工具绑定后同一请求还能补上规则。 */
2317
- if (!snapshot && !experiment) {
2589
+ if ((!snapshot || !snapshot.cards.length) && !experiment) {
2318
2590
  rollbackBudget();
2319
2591
  clearPrepared(agent);
2320
2592
  return decision;
@@ -2322,7 +2594,7 @@ export function apply(ctx, config) {
2322
2594
  let evolutionEpoch = '';
2323
2595
  try { evolutionEpoch = String(evolutionStatus(scope).epoch || ''); } catch { evolutionEpoch = ''; }
2324
2596
  const state = {
2325
- text: snapshot ? snapshot.text : '',
2597
+ text: snapshot && snapshot.cards.length ? snapshot.text : '',
2326
2598
  experimentText: experiment ? experiment.text : '',
2327
2599
  experimentParts: experiment && experiment.held ? (experiment.held.experimentParts || null) : null,
2328
2600
  validUntil: experiment && experiment.held ? (experiment.held.validUntil || '') : '',
@@ -2487,7 +2759,7 @@ export function apply(ctx, config) {
2487
2759
  if ((feedbackRevisions.get(scope.key) || 0) !== candidate.feedbackRevision) {
2488
2760
  throw new Error('反馈已更新,这轮结果没采用');
2489
2761
  }
2490
- const parsed = learning.parseOutput(text, { finish, refs: input.refs, rules: input.rules, ranges });
2762
+ const parsed = learning.parseOutput(text, { finish, refs: input.refs, rules: input.rules, ranges, expectedTarget: candidate.expectedTarget });
2491
2763
  if (!parsed.ok) throw new Error(parsed.reason);
2492
2764
  /** 用户已经把要求说成长期规则,模型仍返回空时至少留一条规则,不让这条反馈无声消失。 */
2493
2765
  const changes = parsed.changes.length