dsh-lite-memory 0.18.1 → 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.
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,
@@ -652,7 +748,7 @@ function refsOf(hits) {
652
748
  const SAVE_PARAMS = {
653
749
  type: 'object',
654
750
  properties: {
655
- id: { type: 'string', description: '要更新的记忆 id(来自搜索结果或相近提示)。同一件事更新时用它,别另建一条' },
751
+ updateTitle: { type: 'string', description: '要更新的那条记忆的标题(原样抄搜索结果里的标题)。同一件事更新时用它,别另建一条' },
656
752
  title: { type: 'string', description: `标题:名词短语,用户一眼能认出是哪件事,不超过 ${LIMITS.titleChars} 字。它就是这条记忆的身份` },
657
753
  summary: { type: 'string', description: `一句话说清要记的内容本身——用户只看这一行决定留不留,名词短语最好,超长会自动截到这个长度` },
658
754
  body: { type: 'string', description: `正文 markdown,整段替换旧正文,不超过 ${LIMITS.entryBytes} 字节。只写原则、判断规则、触发条件和边界;不写一次性数字或代码片段,这些查当前代码或文档。` },
@@ -661,7 +757,7 @@ const SAVE_PARAMS = {
661
757
  tags: { type: 'array', items: { type: 'string' }, description: '最多 5 个标签;更新时不给就保留原值' },
662
758
  supersedes: {
663
759
  type: 'string',
664
- description: '要取代的记忆 id:新内容写成新条目,旧条目标为已被取代并保留在历史里;同层才有效,已被取代过的不能再取代',
760
+ description: '要取代的那条记忆的标题(原样抄搜索结果里的标题):新内容写成新条目,旧条目标为已被取代并保留在历史里;同层才有效,已被取代过的不能再取代',
665
761
  },
666
762
  resolution: {
667
763
  type: 'string',
@@ -674,13 +770,12 @@ const SAVE_PARAMS = {
674
770
  evidenceRef: { type: 'string', description: '归属判定的依据(哪条消息、哪个文件、哪次观察);用户没说范围时用来判断能不能存' },
675
771
  sameTopicAs: {
676
772
  type: 'object',
677
- description: '显式绑定到已有主题槽位:{scope, id, revision}。版本不符或目标不存在就报错,不写',
773
+ description: '显式绑定到已有主题槽位:{scope, title}。标题对不上就报错,不写',
678
774
  properties: {
679
775
  scope: { type: 'string', enum: ['project', 'global'] },
680
- id: { type: 'string' },
681
- revision: { type: 'integer' },
776
+ title: { type: 'string' },
682
777
  },
683
- required: ['scope', 'id', 'revision'],
778
+ required: ['scope', 'title'],
684
779
  additionalProperties: false,
685
780
  },
686
781
  mode: {
@@ -724,9 +819,9 @@ const SAVE_PARAMS = {
724
819
  type: 'object',
725
820
  properties: {
726
821
  type: { type: 'string', enum: view.LINK_TYPES, description: '默认 related' },
727
- id: { type: 'string', pattern: '^mem_[a-z0-9]{6,}$' },
822
+ title: { type: 'string', description: '被关联那条记忆的标题(原样抄搜索结果里的标题)' },
728
823
  },
729
- required: ['id'],
824
+ required: ['title'],
730
825
  additionalProperties: false,
731
826
  },
732
827
  },
@@ -743,7 +838,7 @@ const SAVE_PARAMS = {
743
838
 
744
839
  const SUGGEST_PARAMS = {
745
840
  ...SAVE_PARAMS,
746
- 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))),
747
842
  };
748
843
 
749
844
  const SEARCH_PARAMS = {
@@ -762,28 +857,69 @@ const SEARCH_PARAMS = {
762
857
  const READ_PARAMS = {
763
858
  type: 'object',
764
859
  properties: {
765
- id: { type: 'string', description: '记忆 id,来自搜索结果' },
860
+ title: { type: 'string', description: '记忆标题,来自搜索结果或召回卡' },
766
861
  view: { type: 'string', enum: ['full', 'links'], description: '默认 full 读正文;links 只看直接相关的记忆清单,不取正文' },
862
+ scope: { type: 'string', enum: ['project', 'global'], description: '只在指定层解析标题;默认先当前项目再全局' },
767
863
  contextId: { type: 'string', description: '上一轮自动索引或工具结果给出的任务上下文 id;读实验规则时带上' },
768
864
  },
769
- required: ['id'],
770
865
  additionalProperties: false,
771
866
  };
772
867
 
773
868
  const FORGET_PARAMS = {
774
869
  type: 'object',
775
870
  properties: {
776
- id: { type: 'string', description: '要忘掉的记忆 id' },
871
+ title: { type: 'string', description: '要忘掉的那条记忆的标题' },
872
+ scope: { type: 'string', enum: ['project', 'global'], description: '只在指定层解析标题;默认先当前项目再全局' },
777
873
  mode: {
778
874
  type: 'string',
779
875
  enum: ['archive', 'purge'],
780
876
  description: 'archive 移入归档可找回(只是不想再提);purge 彻底忘掉,正文与受管副本清除且不可复活。没点明是哪种就两种都别做,先问用户',
781
877
  },
782
878
  },
783
- required: ['id'],
784
879
  additionalProperties: false,
785
880
  };
786
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
+
787
923
  /** 记忆归属标记:全局 `[global]`,项目用项目目录名(如 `[DSH]`)。 */
788
924
  function projectNameOf(scope) {
789
925
  const base = scope && scope.cwd ? path.basename(scope.cwd) : '';
@@ -816,7 +952,7 @@ function humanAge(iso) {
816
952
  function describeCandidate(item, options = {}) {
817
953
  const age = humanAge(item.updated);
818
954
  const why = options.sameTopic ? '' : item.reason === 'similar_summary' ? '摘要相近' : '标题相近';
819
- const parts = [`${item.id}「${item.title}」`];
955
+ const parts = [`「${item.title}」`];
820
956
  if (age) parts.push(`${age}更新`);
821
957
  if (item.when) parts.push(item.when);
822
958
  if (why) parts.push(why);
@@ -840,12 +976,12 @@ function statusLabel(status) {
840
976
  export const RECALL_MODES = ['adaptive', 'tool_only', 'off'];
841
977
  const RECALL_QUERY_CHARS = 400;
842
978
  const RECALL_DEADLINE_MS = 150;
843
- const RECALL_HEADER = '已有记忆可能相关(需要细节用 memory_read 取正文,别凭标题猜):';
979
+ const RECALL_HEADER = '已有记忆可能相关(需要细节用 memory_read 带标题取正文,别凭标题猜):';
844
980
 
845
981
  /** 卡片只放够判断要不要读的几行;必要前提整条在场,装不下就整卡省略。 */
846
982
  function renderRecallCard(card) {
847
983
  const tag = card.scope === 'global' ? '[global]' : card.scopeName ? `[${card.scopeName}]` : '';
848
- const parts = [`- ${tag ? `${tag} ` : ''}「${card.title}」 · ${card.id}`];
984
+ const parts = [`- ${tag ? `${tag} ` : ''}「${card.title}」`];
849
985
  if (card.summary) parts.push(`|${card.summary}`);
850
986
  if (card.conditions && card.conditions.length) parts.push(`|前提:${card.conditions.join('、')}`);
851
987
  if (card.selection) parts.push(`|只适用于 ${card.selection}`);
@@ -936,6 +1072,17 @@ export function apply(ctx, config) {
936
1072
  return win.bytes + bytes <= view.REQUEST_BYTES;
937
1073
  }
938
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
+
939
1086
  /** 续写轮没有新消息:沿用上一次能核对的请求上下文。 */
940
1087
  const lastRuntime = new Map();
941
1088
  const rememberRuntime = (scope, sessionId, held) => {
@@ -954,7 +1101,7 @@ export function apply(ctx, config) {
954
1101
  if (existing && existing.signature === signature) return existing;
955
1102
  const next = {
956
1103
  signature, sessionId, turn, messageIds,
957
- bytes: 0, bodies: 0, reads: 0,
1104
+ bytes: 0, bodies: 0, reads: 0, toolBytes: 0,
958
1105
  provided: new Map(), readBodies: new Map(), at: Date.now(),
959
1106
  experiment: null, contexts: new Map(), runtime: null, autoBudget: null,
960
1107
  };
@@ -1025,17 +1172,18 @@ export function apply(ctx, config) {
1025
1172
  }
1026
1173
  }
1027
1174
  /** 同一槽位只让胜出的规范进去;冲突或条件未知的整组不进自动召回。 */
1175
+ const groups = [...view.resolveTopics(layers.project, layers.global, context).values()];
1028
1176
  const records = [];
1029
1177
  const layerOf = new Map();
1030
- for (const group of view.resolveTopics(layers.project, layers.global, context).values()) {
1178
+ for (const group of groups) {
1031
1179
  if (group.status !== 'ok' || !group.record) continue;
1032
1180
  records.push(group.record);
1033
1181
  layerOf.set(group.record.id, group.scope === 'global' ? 'global' : 'project');
1034
1182
  }
1183
+ const gateRuntime = runtime || (win && win.runtime) || null;
1184
+ const task = runtime && runtime.task ? runtime.task : null;
1035
1185
  const ranked = view.rank(records, query, {
1036
- limit: 8, requireApplicable: true, context,
1037
- runtime: runtime || (win && win.runtime) || null,
1038
- task: runtime && runtime.task ? runtime.task : null,
1186
+ limit: 8, requireApplicable: true, context, runtime: gateRuntime, task,
1039
1187
  });
1040
1188
  /** 普通记忆与实验规则共用一份自动注入预算:总条数和最终完整文本字节一起算。 */
1041
1189
  const auto = win && win.autoBudget ? win.autoBudget : null;
@@ -1073,7 +1221,7 @@ export function apply(ctx, config) {
1073
1221
  win.recallHeader = true;
1074
1222
  }
1075
1223
  lines.push(line);
1076
- cards.push({ id: card.id, revision, scope: card.scope, title: card.title });
1224
+ cards.push({ revision, scope: card.scope, title: card.title });
1077
1225
  }
1078
1226
  const finalText = `${RECALL_HEADER}\n${lines.join('\n')}`;
1079
1227
  if (auto) {
@@ -1085,8 +1233,35 @@ export function apply(ctx, config) {
1085
1233
  experimentCards: auto.experimentCards || 0,
1086
1234
  });
1087
1235
  }
1088
- if (!cards.length) return null;
1089
- 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 };
1090
1265
  }
1091
1266
 
1092
1267
  /* ---------------- 项目自进化:任务锚点、读取决策、上下文解析 ---------------- */
@@ -1296,8 +1471,8 @@ export function apply(ctx, config) {
1296
1471
  ? `;核对:${String(checkpoint.text).slice(0, 40)}`
1297
1472
  : '';
1298
1473
  const line = short
1299
- ? `- 「${record.title}」${conditions ? `(${conditions})` : ''} · ${record.id}:${body}${check}`
1300
- : `- 「${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 带标题取正文)`;
1301
1476
  const size = Buffer.byteLength(`\n${line}`);
1302
1477
  if (used + size > autoCap) break;
1303
1478
  used += size;
@@ -1586,26 +1761,24 @@ export function apply(ctx, config) {
1586
1761
 
1587
1762
  ctx.effect(() => tools.register({
1588
1763
  name: 'memory_save',
1589
- 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}`,
1590
1765
  parameters: SAVE_PARAMS,
1591
1766
  output: {
1592
1767
  presentationMeta: (_args, value) => ({
1593
1768
  kind: 'memory-save',
1594
- id: value.id || '',
1595
1769
  title: value.title || '',
1596
1770
  scope: value.scope || '',
1597
1771
  revision: value.revision || 0,
1598
1772
  changed: value.changed === true,
1599
1773
  recordKind: typeof value.kind === 'string' && value.kind ? value.kind : '',
1600
1774
  mode: value.needsScope ? 'needs_scope'
1601
- : value.existingId ? 'duplicate'
1775
+ : value.existingTitle ? 'duplicate'
1602
1776
  : value.needsResolution ? 'needs_resolution'
1603
1777
  : value.changed === false ? 'unchanged' : value.merged ? 'updated' : 'created',
1604
1778
  }),
1605
1779
  schema: {
1606
1780
  type: 'object',
1607
1781
  properties: {
1608
- id: { type: 'string' },
1609
1782
  title: { type: 'string' },
1610
1783
  summary: { type: 'string' },
1611
1784
  when: { type: 'string' },
@@ -1620,34 +1793,35 @@ export function apply(ctx, config) {
1620
1793
  needsResolution: { type: 'boolean' },
1621
1794
  needsScope: { type: 'boolean' },
1622
1795
  reason: { type: 'string' },
1623
- existingId: { type: 'string' },
1796
+ existingTitle: { type: 'string' },
1624
1797
  existingScope: { type: 'string' },
1625
1798
  crossLayer: { type: 'boolean' },
1799
+ crossScope: { type: 'string', enum: ['global', 'project'] },
1626
1800
  sameTopic: { type: 'boolean' },
1627
- similarIds: { type: 'string' },
1801
+ similarTitles: { type: 'string' },
1628
1802
  similar: { type: 'string' },
1629
- supersededId: { type: 'string' },
1803
+ supersededTitle: { type: 'string' },
1630
1804
  archived: { type: 'string' },
1631
1805
  pendingConflicts: { type: 'integer' },
1632
1806
  },
1633
- required: ['id', 'title', 'summary', 'when', 'revision', 'merged', 'changed', 'scope', 'needsResolution', 'archived'],
1807
+ required: ['title', 'summary', 'when', 'revision', 'merged', 'changed', 'scope', 'needsResolution', 'archived'],
1634
1808
  additionalProperties: false,
1635
1809
  },
1636
1810
  render: (_args, value) => [{
1637
1811
  type: 'text',
1638
1812
  text: value.needsScope
1639
1813
  ? value.reason === 'memory_disabled'
1640
- ? 'memory · 本项目记忆已关闭,先在面板打开'
1814
+ ? `memory · ${memoryOffCopy(projectScope)}`
1641
1815
  : value.reason === 'unconfirmed_project'
1642
1816
  ? `memory · 当前目录还不能确认属于哪个项目:「${value.title}」\n先确认项目根目录,或显式 scope="global"`
1643
1817
  : `memory · 归属还没定:「${value.title}」\n补 memoryClass(personal_preference / project_fact / environment_fact / portable_procedure)或显式 scope;设备事实还要给设备条件`
1644
- : value.existingId
1818
+ : value.existingTitle
1645
1819
  ? `memory · 已有相同记忆:「${value.title}」(${value.existingScope})\n要改成另一条,加 resolution="distinct"`
1646
1820
  : value.needsResolution
1647
- ? `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"`
1648
1822
  : value.changed === false
1649
1823
  ? `memory · unchanged ${withTag(value, `"${value.title}"`)}`
1650
- : `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}${value.fullTextSaved ? ' · 完整原句已另存' : ''}${value.supersededId ? ' · 已取代旧说法' : ''}`,
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}」` : ''}`,
1651
1825
  }],
1652
1826
  },
1653
1827
  execute: async (args, execution) => {
@@ -1655,15 +1829,44 @@ export function apply(ctx, config) {
1655
1829
  const projectScopeName = projectNameOf(projectScope);
1656
1830
  if (!memoryEnabled(projectScope)) {
1657
1831
  return {
1658
- id: '', title: String(args.title || ''), summary: '', when: '', revision: 0,
1832
+ title: String(args.title || ''), summary: '', when: '', revision: 0,
1659
1833
  merged: false, changed: false, scope: '', needsScope: true, reason: 'memory_disabled',
1660
- needsResolution: false, similarIds: '', similar: '', archived: '',
1834
+ recoveryPending: controlStatus(projectScope).recoveryPending,
1835
+ needsResolution: false, similarTitles: '', similar: '', archived: '',
1661
1836
  };
1662
1837
  }
1663
- 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);
1664
1868
  if (saved.needsScope) {
1665
1869
  return {
1666
- id: '',
1667
1870
  title: saved.draft?.title || '',
1668
1871
  summary: saved.draft?.summary || '',
1669
1872
  when: saved.draft?.when || '',
@@ -1674,14 +1877,13 @@ export function apply(ctx, config) {
1674
1877
  needsScope: true,
1675
1878
  reason: saved.reasonCode,
1676
1879
  needsResolution: false,
1677
- similarIds: '',
1880
+ similarTitles: '',
1678
1881
  similar: '',
1679
1882
  archived: '',
1680
1883
  };
1681
1884
  }
1682
1885
  if (saved.existing) {
1683
1886
  return {
1684
- id: saved.existing.id,
1685
1887
  title: saved.existing.title || saved.draft?.title || '',
1686
1888
  summary: '',
1687
1889
  when: '',
@@ -1691,10 +1893,10 @@ export function apply(ctx, config) {
1691
1893
  scope: saved.existing.scope,
1692
1894
  scopeName: saved.existing.scope === 'global' ? '' : projectScopeName,
1693
1895
  needsScope: false,
1694
- existingId: saved.existing.id,
1896
+ existingTitle: saved.existing.title || saved.draft?.title || '',
1695
1897
  existingScope: saved.existing.scope,
1696
1898
  needsResolution: false,
1697
- similarIds: '',
1899
+ similarTitles: '',
1698
1900
  similar: '',
1699
1901
  archived: '',
1700
1902
  };
@@ -1702,7 +1904,6 @@ export function apply(ctx, config) {
1702
1904
  const scope = saved.chosenScope === 'global' || (args.scope === 'global' && !saved.chosenScope) ? globalScope() : projectScope;
1703
1905
  if (saved.needsResolution) {
1704
1906
  return {
1705
- id: '',
1706
1907
  title: saved.draft.title,
1707
1908
  summary: saved.draft.summary,
1708
1909
  when: saved.draft.when,
@@ -1713,8 +1914,9 @@ export function apply(ctx, config) {
1713
1914
  needsScope: false,
1714
1915
  needsResolution: true,
1715
1916
  crossLayer: Boolean(saved.crossLayer),
1917
+ crossScope: saved.crossScope === 'global' ? 'global' : 'project',
1716
1918
  sameTopic: Boolean(saved.sameTopic),
1717
- similarIds: saved.similarList.map((item) => item.id).join('、'),
1919
+ similarTitles: saved.similarList.map((item) => item.title).join('、'),
1718
1920
  similar: saved.similarList.map((item) => describeCandidate(item, { sameTopic: saved.sameTopic })).join(';'),
1719
1921
  archived: '',
1720
1922
  pendingConflicts: Number(saved.pendingConflicts) || 0,
@@ -1722,7 +1924,6 @@ export function apply(ctx, config) {
1722
1924
  }
1723
1925
  recordCitations(scope, sessionIdOf(execution), [saved.record]);
1724
1926
  return {
1725
- id: saved.record.id,
1726
1927
  title: saved.record.title,
1727
1928
  summary: saved.record.summary,
1728
1929
  when: saved.record.when || '',
@@ -1736,8 +1937,8 @@ export function apply(ctx, config) {
1736
1937
  scopeName: scope.key === 'global' ? '' : projectScopeName,
1737
1938
  needsScope: false,
1738
1939
  needsResolution: false,
1739
- similarIds: saved.similarList.map((item) => item.id).join('、'),
1740
- supersededId: String(args.supersedes || ''),
1940
+ similarTitles: saved.similarList.map((item) => item.title).join('、'),
1941
+ supersededTitle: supersedes && supersedesId ? supersedes : '',
1741
1942
  archived: (saved.archived || []).map((item) => item.title).join('、'),
1742
1943
  pendingConflicts: Number(saved.pendingConflicts) || 0,
1743
1944
  ...(saved.similar ? { similar: `「${saved.similar.title}」` } : {}),
@@ -1758,7 +1959,6 @@ export function apply(ctx, config) {
1758
1959
  title: { type: 'string' },
1759
1960
  id: { type: 'string' },
1760
1961
  reason: { type: 'string' },
1761
- targetId: { type: 'string' },
1762
1962
  targetTitle: { type: 'string' },
1763
1963
  },
1764
1964
  required: ['asked'],
@@ -1767,7 +1967,7 @@ export function apply(ctx, config) {
1767
1967
  render: (_args, value) => [{
1768
1968
  type: 'text',
1769
1969
  text: value.asked
1770
- ? `memory · suggested "${value.title}"${value.targetId ? ` · 确认后更新「${value.targetTitle}」` : ''}`
1970
+ ? `memory · suggested "${value.title}"${value.targetTitle ? ` · 确认后更新「${value.targetTitle}」` : ''}`
1771
1971
  : `memory · skipped${value.reason ? `\n${value.reason}` : ''}`,
1772
1972
  }],
1773
1973
  },
@@ -1781,14 +1981,14 @@ export function apply(ctx, config) {
1781
1981
  asked: true,
1782
1982
  title: noted.title,
1783
1983
  id: noted.id,
1784
- ...(noted.targetId ? { targetId: noted.targetId, targetTitle: noted.targetTitle } : {}),
1984
+ ...(noted.targetTitle ? { targetTitle: noted.targetTitle } : {}),
1785
1985
  };
1786
1986
  },
1787
1987
  }), 'lite-memory: memory_suggest');
1788
1988
 
1789
1989
  ctx.effect(() => tools.register({
1790
1990
  name: 'memory_search',
1791
- description: `按需检索已有记忆:先搜,命中再 memory_read 读正文。用户提到「记住了什么 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」,或者眼前反复出现的报错、弹窗、异常时,先查这里有没有记过解法。跨项目的通用条目以 [global] 开头,项目条目以 [项目名] 开头。中文长句可以直接当查询。每行带 id,更新时把 id 传回 memory_save。默认只列当前有效的;已经归档或被更新替换的旧说法不混进来,要查历史就在参数里显式要 history。`,
1991
+ description: `按需检索已有记忆:先搜,命中再 memory_read 读正文。用户提到「记住了什么 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」,或者眼前反复出现的报错、弹窗、异常时,先查这里有没有记过解法。跨项目的通用条目以 [global] 开头,项目条目以 [项目名] 开头。中文长句可以直接当查询。每行给标题与适用范围;要更新就用那个标题重存。默认只列当前有效的;已经归档或被更新替换的旧说法不混进来,要查历史就在参数里显式要 history。`,
1792
1992
  parameters: SEARCH_PARAMS,
1793
1993
  output: {
1794
1994
  presentationMeta: (_args, value) => ({
@@ -1809,7 +2009,6 @@ export function apply(ctx, config) {
1809
2009
  items: {
1810
2010
  type: 'object',
1811
2011
  properties: {
1812
- id: { type: 'string' },
1813
2012
  title: { type: 'string' },
1814
2013
  scope: { type: 'string' },
1815
2014
  revision: { type: 'integer' },
@@ -1819,7 +2018,7 @@ export function apply(ctx, config) {
1819
2018
  when: { type: 'string' },
1820
2019
  updated: { type: 'string' }, // ui-copy-ignore
1821
2020
  },
1822
- required: ['id', 'title', 'scope', 'revision', 'status', 'origin', 'kind', 'when'],
2021
+ required: ['title', 'scope', 'revision', 'status', 'origin', 'kind', 'when'],
1823
2022
  additionalProperties: false,
1824
2023
  },
1825
2024
  },
@@ -1830,7 +2029,7 @@ export function apply(ctx, config) {
1830
2029
  render: (_args, value) => [{
1831
2030
  type: 'text',
1832
2031
  text: value.status === 'budget_exhausted'
1833
- ? 'memory · 本请求的记忆额度已用完'
2032
+ ? 'memory · 这条消息已停止检索,下一条消息再查'
1834
2033
  : value.status === 'memory_disabled'
1835
2034
  ? 'memory · 记忆检索已关闭'
1836
2035
  : value.status === 'needs_context'
@@ -1844,7 +2043,7 @@ export function apply(ctx, config) {
1844
2043
  if (recallMode() === 'off' || !memoryEnabledFor(execution)) return { count: 0, chars: 0, text: '', status: 'memory_disabled', refs: [] };
1845
2044
  const agent = execution?.agent;
1846
2045
  const win = windowOf(agent);
1847
- 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: [] };
1848
2047
  const wantsBind = args.bindForRequest === true;
1849
2048
  const contextId = wantsBind && !args.contextId ? toolContextFor(execution) : args.contextId;
1850
2049
  if (wantsBind && !contextId) {
@@ -1895,8 +2094,9 @@ export function apply(ctx, config) {
1895
2094
  };
1896
2095
  if (win) {
1897
2096
  const bytes = jsonBytes(value);
1898
- if (!affordable(win, bytes)) return { ...value, status: 'budget_exhausted', text: '' };
2097
+ if (!affordableTool(win, bytes)) return { ...value, status: 'budget_exhausted', text: '' };
1899
2098
  win.bytes += bytes;
2099
+ win.toolBytes += bytes;
1900
2100
  win.reads += 1;
1901
2101
  }
1902
2102
  return value;
@@ -1906,18 +2106,20 @@ export function apply(ctx, config) {
1906
2106
  const empty = { count: 0, chars: 0, text: '', status: 'ok', reasonCode: 'no_match', noTriggerReason: '没有匹配的记忆', refs: [] };
1907
2107
  if (win) {
1908
2108
  const bytes = jsonBytes(empty);
1909
- if (!affordable(win, bytes)) return { ...empty, status: 'budget_exhausted' };
2109
+ if (!affordableTool(win, bytes)) return { ...empty, status: 'budget_exhausted' };
1910
2110
  win.bytes += bytes;
2111
+ win.toolBytes += bytes;
1911
2112
  win.reads += 1;
1912
2113
  }
1913
2114
  return empty;
1914
2115
  }
1915
2116
  if (win) {
1916
2117
  let kept = shown.length;
1917
- while (kept > 0 && !affordable(win, jsonBytes(valueOf(kept)))) kept -= 1;
2118
+ while (kept > 0 && !affordableTool(win, jsonBytes(valueOf(kept)))) kept -= 1;
1918
2119
  if (kept === 0) return { count: 0, chars: 0, text: '', status: 'budget_exhausted', refs: [] };
1919
2120
  const value = valueOf(kept);
1920
2121
  win.bytes += jsonBytes(value);
2122
+ win.toolBytes += jsonBytes(value);
1921
2123
  win.reads += 1;
1922
2124
  recordCitations(scope, sessionIdOf(execution), hits.slice(0, kept));
1923
2125
  return value;
@@ -1930,13 +2132,12 @@ export function apply(ctx, config) {
1930
2132
 
1931
2133
  ctx.effect(() => tools.register({
1932
2134
  name: 'memory_read',
1933
- description: '读一条记忆的正文。搜索结果里只有一行摘要,需要细节时按 id 读。索引里还有、正文文件丢了会直接报错,不会返回空正文。',
2135
+ description: '读一条记忆的正文。搜索结果里只有一行摘要,需要细节时按标题读。索引里还有、正文文件丢了会直接报错,不会返回空正文。',
1934
2136
  parameters: READ_PARAMS,
1935
2137
  output: {
1936
2138
  schema: {
1937
2139
  type: 'object',
1938
2140
  properties: {
1939
- id: { type: 'string' },
1940
2141
  title: { type: 'string' },
1941
2142
  when: { type: 'string' },
1942
2143
  revision: { type: 'integer' },
@@ -1949,13 +2150,13 @@ export function apply(ctx, config) {
1949
2150
  links: { type: 'string' },
1950
2151
  repeat: { type: 'boolean' },
1951
2152
  },
1952
- required: ['id', 'scope', 'status'],
2153
+ required: ['scope', 'status'],
1953
2154
  additionalProperties: false,
1954
2155
  },
1955
2156
  render: (_args, value) => [{
1956
2157
  type: 'text',
1957
2158
  text: value.status === 'budget_exhausted'
1958
- ? 'memory · 本请求的记忆额度已用完'
2159
+ ? 'memory · 这条消息已停止读记忆,下一条消息再读'
1959
2160
  : value.status === 'memory_disabled'
1960
2161
  ? 'memory · 记忆检索已关闭'
1961
2162
  : value.repeat
@@ -1976,7 +2177,6 @@ export function apply(ctx, config) {
1976
2177
  }],
1977
2178
  presentationMeta: (_args, value) => ({
1978
2179
  kind: 'memory-read',
1979
- id: value.id || '',
1980
2180
  title: value.title || '',
1981
2181
  scope: value.scope || '',
1982
2182
  revision: value.revision || 0,
@@ -1986,11 +2186,13 @@ export function apply(ctx, config) {
1986
2186
  }),
1987
2187
  },
1988
2188
  execute: async (args, execution) => {
1989
- if (recallMode() === 'off' || !memoryEnabledFor(execution)) return { id: '', scope: '', status: 'memory_disabled' };
2189
+ if (recallMode() === 'off' || !memoryEnabledFor(execution)) return { scope: '', status: 'memory_disabled' };
1990
2190
  const resolved = resolveRequestContext(execution, args.contextId);
1991
- if (!resolved.ok) return { id: '', scope: '', status: resolved.status || 'needs_context' };
2191
+ if (!resolved.ok) return { scope: '', status: resolved.status || 'needs_context' };
1992
2192
  const scope = resolved.scope;
1993
2193
  const scopeName = scope.key === 'global' ? '' : projectNameOf(scope);
2194
+ /** 明确指定层时只在那一层解析标题,同名条目不会串到别的层。 */
2195
+ const readScope = args.scope === 'global' ? globalScope() : scope;
1994
2196
  const context = resolved.context;
1995
2197
  const allowExperimental = resolved.experimental === true;
1996
2198
  const agent = execution?.agent;
@@ -1998,48 +2200,57 @@ export function apply(ctx, config) {
1998
2200
  const gateRuntime = resolved.held || win?.runtime
1999
2201
  || { projectId: scope.key, context, experimental: resolved.experimental === true };
2000
2202
  if (args.view === 'links') {
2001
- const source = locate(scope, args.id);
2002
- if (source && !view.eligibility(source.record, gateRuntime, 'read').ok) {
2003
- return { id: '', scope: '', status: 'needs_context', links: '' };
2004
- }
2005
- const found = readLinks(scope, args.id, { context, allowExperimental, runtime: gateRuntime, now: Date.now() });
2006
- 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()}」这条记忆`);
2007
2209
  if (found.status !== 'ok') {
2008
- const withheld = { id: '', scope: '', status: found.status, links: '' };
2210
+ const withheld = { scope: '', status: found.status, links: '' };
2009
2211
  if (win) {
2010
2212
  const bytes = jsonBytes(withheld);
2011
- if (!affordable(win, bytes)) return { id: '', scope: '', status: 'budget_exhausted' };
2213
+ if (!affordableTool(win, bytes)) return { scope: '', status: 'budget_exhausted' };
2012
2214
  win.bytes += bytes;
2215
+ win.toolBytes += bytes;
2013
2216
  win.reads += 1;
2014
2217
  }
2015
2218
  return withheld;
2016
2219
  }
2017
2220
  const rows = found.links.filter((link) => link.status === 'invalid' || link.status === 'ok')
2018
2221
  .map((link) => (link.status === 'invalid'
2019
- ? `- ${link.id} · 已失效`
2020
- : `- ${link.scope === 'global' ? '[global] ' : (scopeName ? `[${scopeName}] ` : '')}「${link.title}」 · ${link.id}`));
2222
+ ? '- 有一条已失效'
2223
+ : `- ${link.scope === 'global' ? '[global] ' : (scopeName ? `[${scopeName}] ` : '')}「${link.title}」`));
2021
2224
  const text = rows.join('\n') || '(无)';
2022
- 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 };
2023
2226
  if (win) {
2024
2227
  const bytes = jsonBytes(value);
2025
- if (!affordable(win, bytes)) return { id: '', scope: '', status: 'budget_exhausted' };
2228
+ if (!affordableTool(win, bytes)) return { scope: '', status: 'budget_exhausted' };
2026
2229
  win.bytes += bytes;
2230
+ win.toolBytes += bytes;
2027
2231
  win.reads += 1;
2028
2232
  }
2029
2233
  return value;
2030
2234
  }
2031
- const record = readEntry(scope, args.id, { context, allowExperimental });
2032
- 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: '' };
2033
2245
  const gate = view.eligibility(record, gateRuntime, 'read');
2034
- if (!gate.ok) return { id: '', scope: record.scope, status: gate.status, body: '' };
2035
- /** 条件未知或不适用的条目只报状态:正文和标题都不能绕过自动召回的门槛。 */
2036
- if (record.status !== 'ok') {
2037
- 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: '' };
2038
2250
  }
2039
2251
  const revision = record.revision || 1;
2040
2252
  const body = String(record.body ?? '');
2041
2253
  const value = {
2042
- id: record.id,
2043
2254
  title: record.title,
2044
2255
  when: record.when || '',
2045
2256
  revision,
@@ -2050,19 +2261,21 @@ export function apply(ctx, config) {
2050
2261
  body,
2051
2262
  };
2052
2263
  if (win) {
2053
- if (!affordable(win, 0)) return { id: '', scope: '', status: 'budget_exhausted' };
2264
+ if (!affordableTool(win, 0)) return { scope: '', status: 'budget_exhausted' };
2054
2265
  const seenRevision = win.readBodies.get(record.id);
2055
2266
  if (seenRevision === revision) {
2056
2267
  const receipt = { ...value, body: '', repeat: true };
2057
2268
  const bytes = jsonBytes(receipt);
2058
- if (!affordable(win, bytes)) return { id: '', scope: '', status: 'budget_exhausted' };
2269
+ if (!affordableTool(win, bytes)) return { scope: '', status: 'budget_exhausted' };
2059
2270
  win.bytes += bytes;
2271
+ win.toolBytes += bytes;
2060
2272
  win.reads += 1;
2061
2273
  return receipt;
2062
2274
  }
2063
2275
  const bytes = jsonBytes(value);
2064
- if (!affordable(win, bytes, 1)) return { id: '', scope: '', status: 'budget_exhausted' };
2276
+ if (!affordableTool(win, bytes, 1)) return { scope: '', status: 'budget_exhausted' };
2065
2277
  win.bytes += bytes;
2278
+ win.toolBytes += bytes;
2066
2279
  win.bodies += 1;
2067
2280
  win.reads += 1;
2068
2281
  win.readBodies.set(record.id, revision);
@@ -2080,13 +2293,12 @@ export function apply(ctx, config) {
2080
2293
  schema: {
2081
2294
  type: 'object',
2082
2295
  properties: {
2083
- id: { type: 'string' },
2084
2296
  title: { type: 'string' },
2085
2297
  scope: { type: 'string' },
2086
2298
  scopeName: { type: 'string' },
2087
2299
  mode: { type: 'string', enum: ['archive', 'purged', 'needs_resolution', 'purge_pending', 'memory_disabled'] },
2088
2300
  },
2089
- required: ['id', 'mode'],
2301
+ required: ['mode'],
2090
2302
  additionalProperties: false,
2091
2303
  },
2092
2304
  render: (_args, value) => [{
@@ -2103,7 +2315,6 @@ export function apply(ctx, config) {
2103
2315
  }],
2104
2316
  presentationMeta: (_args, value) => ({
2105
2317
  kind: 'memory-forget',
2106
- id: value.id || '',
2107
2318
  title: value.title || '',
2108
2319
  scope: value.scope || '',
2109
2320
  mode: value.mode,
@@ -2112,25 +2323,33 @@ export function apply(ctx, config) {
2112
2323
  execute: async (args, execution) => {
2113
2324
  const scope = scopeOf(cwdOf(execution));
2114
2325
  const projectScopeName = projectNameOf(scope);
2115
- if (!memoryEnabled(scope)) return { id: String(args.id || ''), title: '', scope: '', mode: 'memory_disabled' };
2116
- const located = locate(scope, args.id);
2117
- 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()}」这条记忆`);
2118
2338
  if (!args.mode) {
2119
- return { id: args.id, title: located.record.title || '', 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' };
2120
2340
  }
2121
2341
  let dropped;
2122
2342
  try {
2123
- dropped = forget(scope, args.id, { mode: args.mode });
2343
+ dropped = forget(scope, refId, onlyLayer ? { mode: args.mode, layers: [onlyLayer] } : { mode: args.mode });
2124
2344
  } catch (error) {
2125
2345
  if (error && error.code === 'purge_pending') {
2126
2346
  const layer = located ? located.scope : scope;
2127
2347
  try {
2128
2348
  state.resumePurge(layer);
2129
- dropped = forget(scope, args.id, { mode: args.mode });
2349
+ dropped = forget(scope, refId, onlyLayer ? { mode: args.mode, layers: [onlyLayer] } : { mode: args.mode });
2130
2350
  } catch (again) {
2131
2351
  if (again && again.code === 'purge_pending') {
2132
2352
  return {
2133
- id: args.id,
2134
2353
  title: located ? located.record.title : '',
2135
2354
  mode: 'purge_pending',
2136
2355
  scope: layer.key === 'global' ? 'global' : 'project',
@@ -2143,11 +2362,10 @@ export function apply(ctx, config) {
2143
2362
  throw error;
2144
2363
  }
2145
2364
  }
2146
- if (!dropped) throw new Error(`没有 ${args.id} 这条记忆`);
2365
+ if (!dropped) throw new Error(`没有「${String(args.title || args.id || '').trim()}」这条记忆`);
2147
2366
  const layerKey = dropped.scopeKey || (located ? located.scope.key : scope.key);
2148
2367
  if (dropped.mode === 'purged') revokeLearningUse(layerKey);
2149
2368
  return {
2150
- id: dropped.id,
2151
2369
  title: dropped.title || (located ? located.record.title : ''),
2152
2370
  mode: dropped.mode,
2153
2371
  scope: layerKey === 'global' ? 'global' : 'project',
@@ -2286,7 +2504,7 @@ export function apply(ctx, config) {
2286
2504
  const ids = fresh.map((message) => String(message?.id ?? '')).filter(Boolean).sort();
2287
2505
  const win = openWindow(agent, { sessionId, turn, messageIds: ids });
2288
2506
  const budgetBefore = win ? {
2289
- bytes: win.bytes, bodies: win.bodies, reads: win.reads,
2507
+ bytes: win.bytes, bodies: win.bodies, reads: win.reads, toolBytes: win.toolBytes || 0,
2290
2508
  provided: new Map(win.provided), readBodies: new Map(win.readBodies),
2291
2509
  recallHeader: win.recallHeader, experimentBytes: win.experimentBytes,
2292
2510
  autoBudget: win.autoBudget ? JSON.parse(JSON.stringify(win.autoBudget)) : null,
@@ -2306,6 +2524,7 @@ export function apply(ctx, config) {
2306
2524
  }
2307
2525
  const scope = scopeOf(cwd);
2308
2526
  if (!memoryEnabled(scope)) {
2527
+ noteRecall(scope, sessionId, { reason: 'memory_off' });
2309
2528
  clearPrepared(agent);
2310
2529
  return decision;
2311
2530
  }
@@ -2318,6 +2537,7 @@ export function apply(ctx, config) {
2318
2537
  if (win && runtime) win.runtime = runtime.held;
2319
2538
  rememberRuntime(scope, sessionId, runtime && runtime.held);
2320
2539
  if (recallMode() !== 'adaptive') {
2540
+ noteRecall(scope, sessionId, { reason: 'tools_only' });
2321
2541
  clearPrepared(agent);
2322
2542
  return decision;
2323
2543
  }
@@ -2334,12 +2554,16 @@ export function apply(ctx, config) {
2334
2554
  ? rememberFeedback(scope, sessionId, fresh, runtime.held, turn)
2335
2555
  : null;
2336
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
+ });
2337
2560
  /** 没发布出去的投影要退还额度:不能因为一次超时把本请求的召回额度耗掉。 */
2338
2561
  const rollbackBudget = () => {
2339
2562
  if (!win || !budgetBefore) return;
2340
2563
  win.bytes = budgetBefore.bytes;
2341
2564
  win.bodies = budgetBefore.bodies;
2342
2565
  win.reads = budgetBefore.reads;
2566
+ win.toolBytes = budgetBefore.toolBytes;
2343
2567
  win.provided = budgetBefore.provided;
2344
2568
  win.readBodies = budgetBefore.readBodies;
2345
2569
  win.recallHeader = budgetBefore.recallHeader;
@@ -2362,7 +2586,7 @@ export function apply(ctx, config) {
2362
2586
  return decision;
2363
2587
  }
2364
2588
  /** 实验上下文即使本轮零注入也要留着:工具绑定后同一请求还能补上规则。 */
2365
- if (!snapshot && !experiment) {
2589
+ if ((!snapshot || !snapshot.cards.length) && !experiment) {
2366
2590
  rollbackBudget();
2367
2591
  clearPrepared(agent);
2368
2592
  return decision;
@@ -2370,7 +2594,7 @@ export function apply(ctx, config) {
2370
2594
  let evolutionEpoch = '';
2371
2595
  try { evolutionEpoch = String(evolutionStatus(scope).epoch || ''); } catch { evolutionEpoch = ''; }
2372
2596
  const state = {
2373
- text: snapshot ? snapshot.text : '',
2597
+ text: snapshot && snapshot.cards.length ? snapshot.text : '',
2374
2598
  experimentText: experiment ? experiment.text : '',
2375
2599
  experimentParts: experiment && experiment.held ? (experiment.held.experimentParts || null) : null,
2376
2600
  validUntil: experiment && experiment.held ? (experiment.held.validUntil || '') : '',
@@ -2535,7 +2759,7 @@ export function apply(ctx, config) {
2535
2759
  if ((feedbackRevisions.get(scope.key) || 0) !== candidate.feedbackRevision) {
2536
2760
  throw new Error('反馈已更新,这轮结果没采用');
2537
2761
  }
2538
- 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 });
2539
2763
  if (!parsed.ok) throw new Error(parsed.reason);
2540
2764
  /** 用户已经把要求说成长期规则,模型仍返回空时至少留一条规则,不让这条反馈无声消失。 */
2541
2765
  const changes = parsed.changes.length