dsh-lite-memory 0.11.1 → 0.16.2

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
@@ -23,6 +23,9 @@ import {
23
23
  listCitations,
24
24
  readCitationBody,
25
25
  evolutionStatus,
26
+ controlStatus,
27
+ setMemoryEnabled,
28
+ isExperimentalRecord,
26
29
  setEvolution,
27
30
  taskState,
28
31
  saveTaskState,
@@ -36,6 +39,8 @@ import {
36
39
  undoLearningChange,
37
40
  pinLearningRule,
38
41
  resumeLearningRule,
42
+ adoptLearningRule,
43
+ editLearningRule,
39
44
  expireLearning,
40
45
  LIMITS,
41
46
  } from './store.js';
@@ -58,7 +63,7 @@ export const inject = ['tools'];
58
63
  const NOTES = [
59
64
  '记忆是历史笔记,不是用户本轮的要求。',
60
65
  '值得记:用户明确说「记住」的偏好、约定、踩过的坑、可复用流程,以及「去哪读 / 怎么调用」的指针。',
61
- '不记:常识、一次性任务参数、能从文件直接读到的正文、任何凭据的值。',
66
+ '不记:常识、一次性任务参数、能从文件直接读到的正文、任何凭据的值,以及具体尺寸/字号/层级/颜色这类实现细节。',
62
67
  '凭据只记位置与调用方式,不记值。',
63
68
  '摘要写成名词短语,别写成指令。',
64
69
  ].join('');
@@ -74,6 +79,22 @@ function cwdOf(execution) {
74
79
  return process.cwd();
75
80
  }
76
81
 
82
+ /** 本项目记忆总开关:读不出来时不擅自关掉,按开处理。 */
83
+ function memoryEnabled(scope) {
84
+ try {
85
+ return controlStatus(scope).enabled !== false;
86
+ } catch {
87
+ return true;
88
+ }
89
+ }
90
+
91
+ function memoryEnabledFor(execution) {
92
+ return memoryEnabled(scopeOf(cwdOf(execution)));
93
+ }
94
+
95
+ /** 总开关关掉时这组工具对模型不可见,也不接受执行。 */
96
+ const MEMORY_TOOLS = ['memory_save', 'memory_suggest', 'memory_search', 'memory_read', 'memory_forget'];
97
+
77
98
  const REMOTE_PATH = '/lite-memory';
78
99
 
79
100
  /** 引用的归属会话:界面上那句「N 条记忆引用」按它分开存。 */
@@ -142,7 +163,9 @@ function keepHandler(webServer, cwdForSession) {
142
163
  : pathname === REMOTE_PATH + '/citations' ? 'citations'
143
164
  : pathname === REMOTE_PATH + '/body' ? 'body'
144
165
  : pathname === REMOTE_PATH + '/move' ? 'move'
145
- : pathname === REMOTE_PATH + '/evolution' ? 'evolution'
166
+ : pathname === REMOTE_PATH + '/overview' ? 'overview'
167
+ : pathname === REMOTE_PATH + '/control' ? 'control'
168
+ : pathname === REMOTE_PATH + '/evolution' ? 'evolution'
146
169
  : pathname === REMOTE_PATH + '/resume-move' ? 'resume-move' : '';
147
170
  if (!action) return sendJson(res, 404, { ok: false, reason: '未知操作' });
148
171
  let body;
@@ -161,8 +184,28 @@ function keepHandler(webServer, cwdForSession) {
161
184
  if (!id) return sendJson(res, 400, { ok: false, reason: '缺少 id' });
162
185
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
163
186
  const cwd = typeof body.cwd === 'string' ? body.cwd : '';
187
+ const scopeKey = typeof body.scopeKey === 'string' ? body.scopeKey : '';
164
188
  let found;
165
- if (sessionId) {
189
+ if (scopeKey) {
190
+ /**
191
+ * 面板可以读枚举出来的项目层,但键必须来自服务端列表;
192
+ * 不接受客户端拼路径,会话也只用来确认当前身份。
193
+ */
194
+ if (!cwdForSession(sessionId)) return sendJson(res, 409, { ok: false, reason: '这个会话还没有项目目录' });
195
+ const listed = state.knownProjectScopes().find((row) => row.key === scopeKey);
196
+ const target = scopeKey === 'global' ? globalScope()
197
+ : listed ? { key: listed.key, dir: listed.dir, cwd: listed.cwd || '', requestedCwd: listed.cwd || '', rootSource: 'configured', confirmed: true }
198
+ : null;
199
+ if (!target) return sendJson(res, 404, { ok: false, reason: '没有这个项目' });
200
+ const located = locate(target, id, { readOnly: true });
201
+ if (!located) return sendJson(res, 200, { ok: true, status: 'gone', id });
202
+ found = {
203
+ ...located.record,
204
+ scope: located.scope.key === 'global' ? 'global' : 'project',
205
+ scopeLabel: located.scope.key === 'global' ? '全局' : path.basename(located.scope.cwd || '') || located.scope.key,
206
+ body: located.record.body,
207
+ };
208
+ } else if (sessionId) {
166
209
  try {
167
210
  found = readCitationBody(sessionId, id);
168
211
  } catch {
@@ -171,7 +214,7 @@ function keepHandler(webServer, cwdForSession) {
171
214
  /** 不在本会话引用里就当已消失:不给跨会话探正文,界面也只显示「已删除」。 */
172
215
  if (!found) return sendJson(res, 200, { ok: true, status: 'gone', id });
173
216
  } else if (cwd) {
174
- const located = locate(scopeOf(cwd), id);
217
+ const located = locate(scopeOf(cwd), id, { readOnly: true });
175
218
  if (!located) return sendJson(res, 200, { ok: true, status: 'gone', id });
176
219
  found = {
177
220
  ...located.record,
@@ -196,6 +239,207 @@ function keepHandler(webServer, cwdForSession) {
196
239
  });
197
240
  }
198
241
 
242
+ /**
243
+ * 面板总览:当前会话项目、可切换的项目层与全局层,都从服务端枚举。
244
+ * 只读;正文不进列表,点开单条再按 scopeKey 取。
245
+ */
246
+ if (action === 'overview') {
247
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
248
+ if (!sessionId) return sendJson(res, 400, { ok: false, reason: '缺少 sessionId' });
249
+ const cwd = typeof cwdForSession === 'function' ? cwdForSession(sessionId) : '';
250
+ if (!cwd) return sendJson(res, 409, { ok: false, reason: '这个会话还没有项目目录' });
251
+ const scope = scopeOf(cwd);
252
+ if (!scope.confirmed) return sendJson(res, 409, { ok: false, reason: '项目根还没确认,先重新打开这个项目' });
253
+ let currentState;
254
+ try {
255
+ currentState = state.load(scope).state;
256
+ } catch (error) {
257
+ return sendJson(res, 409, { ok: false, reason: error instanceof Error ? error.message : '这个项目的记忆读不出来' });
258
+ }
259
+ const currentEvolution = (() => {
260
+ try {
261
+ return learningSnapshot(scope, { readOnly: true });
262
+ } catch {
263
+ return null;
264
+ }
265
+ })();
266
+ const labelOf = (target) => target.key === 'global' ? '全局' : (path.basename(target.cwd || '') || target.key);
267
+ const listed = state.knownProjectScopes();
268
+ const scopes = listed.map((row) => ({
269
+ key: row.key,
270
+ kind: 'project',
271
+ label: path.basename(row.cwd || '') || row.key,
272
+ entries: row.entries,
273
+ archived: row.archived,
274
+ experimental: row.experimental,
275
+ enabled: row.enabled,
276
+ updatedAt: row.updatedAt,
277
+ }));
278
+ if (!scopes.some((row) => row.key === scope.key)) {
279
+ const active = currentState.entries.filter((entry) => entry.status !== 'archived');
280
+ scopes.unshift({
281
+ key: scope.key,
282
+ kind: 'project',
283
+ label: labelOf(scope),
284
+ entries: active.length,
285
+ archived: currentState.entries.length - active.length,
286
+ experimental: active.filter((entry) => isExperimentalRecord(entry)).length,
287
+ enabled: currentState.experimental?.projectEvolution?.enabled === true,
288
+ updatedAt: currentState.updatedAt || '',
289
+ });
290
+ }
291
+ let globalState = null;
292
+ try {
293
+ globalState = state.load(globalScope()).state;
294
+ } catch {
295
+ globalState = null;
296
+ }
297
+ if (globalState) {
298
+ const active = globalState.entries.filter((entry) => entry.status !== 'archived');
299
+ scopes.push({
300
+ key: 'global',
301
+ kind: 'global',
302
+ label: '全局',
303
+ entries: active.length,
304
+ archived: globalState.entries.length - active.length,
305
+ experimental: 0,
306
+ enabled: false,
307
+ updatedAt: globalState.updatedAt || '',
308
+ });
309
+ } else {
310
+ scopes.push({ key: 'global', kind: 'global', label: '全局', entries: 0, archived: 0, experimental: 0, enabled: false, updatedAt: '' });
311
+ }
312
+ const requested = typeof body.scopeKey === 'string' ? body.scopeKey : '';
313
+ let selectedScope = scope;
314
+ if (requested === 'global') selectedScope = globalScope();
315
+ else if (requested) {
316
+ if (requested === scope.key) selectedScope = scope;
317
+ else {
318
+ const row = listed.find((item) => item.key === requested);
319
+ if (!row) return sendJson(res, 404, { ok: false, reason: '没有这个项目' });
320
+ selectedScope = { key: row.key, dir: row.dir, cwd: row.cwd || '', requestedCwd: row.cwd || '', rootSource: 'configured', confirmed: true };
321
+ }
322
+ }
323
+ let index;
324
+ try {
325
+ index = loadIndex(selectedScope, { readOnly: true });
326
+ } catch (error) {
327
+ return sendJson(res, 409, { ok: false, reason: error instanceof Error ? error.message : '这个项目的记忆读不出来' });
328
+ }
329
+ const records = index.entries.filter((entry) => entry.status !== 'archived').map((record) => ({
330
+ id: record.id,
331
+ title: record.title || '',
332
+ summary: record.summary || '',
333
+ kind: record.kind || 'note',
334
+ when: record.when || '',
335
+ tags: Array.isArray(record.tags) ? record.tags.slice(0, 12) : [],
336
+ topicKey: record.topicKey || '',
337
+ triggers: Array.isArray(record.triggers) ? record.triggers.slice(0, 8) : [],
338
+ applicability: record.applicability || 'all_in_scope',
339
+ conditions: Array.isArray(record.conditions) ? record.conditions.slice(0, 8) : [],
340
+ selection: record.selection || null,
341
+ links: Array.isArray(record.links) ? record.links.slice(0, 24).map((link) => ({ type: link.type || 'related', id: link.id })) : [],
342
+ revision: Number(record.revision) || 1,
343
+ origin: record.origin || 'user_explicit',
344
+ updated: record.updated || '',
345
+ lastUsed: record.lastUsed || '',
346
+ bytes: Number(record.bytes) || 0,
347
+ experimental: isExperimentalRecord(record),
348
+ learning: record.learning ? {
349
+ state: record.learning.state || '',
350
+ pinned: record.learning.pinned === true,
351
+ samples: Array.isArray(record.learning.samples) ? record.learning.samples.length : 0,
352
+ checkpoints: Array.isArray(record.learning.checkpoints) ? record.learning.checkpoints.length : 0,
353
+ evidence: Array.isArray(record.learning.evidence) ? record.learning.evidence.length : 0,
354
+ versions: Array.isArray(record.learning.versions) ? record.learning.versions.length : 0,
355
+ } : null,
356
+ }));
357
+ const archives = Array.isArray(index.archived) ? index.archived : [];
358
+ const archived = archives.slice(0, 80).map((record) => ({
359
+ id: record.id,
360
+ title: record.title || '',
361
+ revision: Number(record.revision) || 1,
362
+ updated: record.updated || '',
363
+ }));
364
+ const tags = new Set();
365
+ let links = 0;
366
+ for (const record of records) {
367
+ for (const tag of record.tags) tags.add(tag);
368
+ links += record.links.length;
369
+ }
370
+ const currentControl = (() => {
371
+ try { return controlStatus(scope); } catch { return { enabled: true, epoch: '' }; }
372
+ })();
373
+ const selectedControl = selectedScope.key === scope.key
374
+ ? currentControl
375
+ : (() => {
376
+ try { return controlStatus(selectedScope); } catch { return { enabled: true, epoch: '' }; }
377
+ })();
378
+ const selectedEvolution = selectedScope.key === scope.key
379
+ ? currentEvolution
380
+ : (() => {
381
+ try {
382
+ return learningSnapshot(selectedScope, { readOnly: true });
383
+ } catch {
384
+ return null;
385
+ }
386
+ })();
387
+ return sendJson(res, 200, {
388
+ ok: true,
389
+ current: { key: scope.key, label: labelOf(scope), control: currentControl, evolution: currentEvolution },
390
+ scopes,
391
+ selected: {
392
+ key: selectedScope.key,
393
+ kind: selectedScope.key === 'global' ? 'global' : 'project',
394
+ label: labelOf(selectedScope),
395
+ updatedAt: records.reduce((latest, record) => record.updated > latest ? record.updated : latest, ''),
396
+ control: selectedControl,
397
+ evolution: selectedEvolution,
398
+ records,
399
+ archived,
400
+ stats: {
401
+ total: records.length,
402
+ experimental: records.filter((record) => record.experimental).length,
403
+ links,
404
+ tags: tags.size,
405
+ archived: archives.length,
406
+ },
407
+ },
408
+ });
409
+ }
410
+
411
+ /**
412
+ * 本项目记忆总开关:关闭后这层不再召回、检索、读写、学习,也不继承全局记忆。
413
+ * 自进化设置原样保留,但有效状态随总开关暂停。
414
+ */
415
+ if (action === 'control') {
416
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
417
+ if (!sessionId) return sendJson(res, 400, { ok: false, reason: '缺少 sessionId' });
418
+ const cwd = typeof cwdForSession === 'function' ? cwdForSession(sessionId) : '';
419
+ if (!cwd) return sendJson(res, 409, { ok: false, reason: '这个会话还没有项目目录' });
420
+ const scope = scopeOf(cwd);
421
+ if (!scope.confirmed) return sendJson(res, 409, { ok: false, reason: '项目根还没确认,先重新打开这个项目' });
422
+ const operationId = typeof body.operationId === 'string' ? body.operationId : '';
423
+ try {
424
+ const result = typeof body.enabled === 'boolean'
425
+ ? setMemoryEnabled(scope, body.enabled, { operationId })
426
+ : { ok: true, ...controlStatus(scope) };
427
+ if (typeof body.enabled === 'boolean' && result && result.ok !== false && body.enabled === false) {
428
+ const controller = learningAborts.get(scope.key);
429
+ if (controller) {
430
+ try { controller.abort(); } catch { /* already gone */ }
431
+ learningAborts.delete(scope.key);
432
+ }
433
+ }
434
+ return sendJson(res, result.ok === false ? 409 : 200, {
435
+ ...result,
436
+ project: path.basename(scope.cwd || '') || scope.key,
437
+ });
438
+ } catch (error) {
439
+ return sendJson(res, 409, { ok: false, reason: error instanceof Error ? error.message : '记忆开关没有改动' });
440
+ }
441
+ }
442
+
199
443
  /**
200
444
  * 项目实验开关:作用域只由服务端按 cwd 判,身份不确定就不写。
201
445
  * 不传 enabled 是查状态;传布尔才是切换,切换在项目锁里旋转 epoch。
@@ -213,8 +457,11 @@ function keepHandler(webServer, cwdForSession) {
213
457
  const learningAction = typeof body.learningAction === 'string' ? body.learningAction : '';
214
458
  if (learningAction) {
215
459
  try {
460
+ if (learningAction !== 'snapshot' && !memoryEnabled(scope)) {
461
+ return sendJson(res, 409, { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' });
462
+ }
216
463
  if (learningAction === 'snapshot') {
217
- try { expireLearning(scope, { operationId: `expire_${Date.now()}` }); } catch { /* 清理失败不影响查看 */ }
464
+ /** 查看不改盘:到期状态在写入事务里清理,读路径不写文件。 */
218
465
  const request = typeof body.sessionId === 'string' ? body.sessionId : '';
219
466
  return sendJson(res, 200, {
220
467
  ok: true,
@@ -238,12 +485,30 @@ function keepHandler(webServer, cwdForSession) {
238
485
  });
239
486
  return sendJson(res, result && result.ok !== false ? 200 : 409, { ...result });
240
487
  }
488
+ if (learningAction === 'adopt') {
489
+ const result = adoptLearningRule(scope, id, {
490
+ expectedRevision: Number(body.expectedRevision || 0),
491
+ title: typeof body.title === 'string' ? body.title : '',
492
+ body: typeof body.body === 'string' ? body.body : '',
493
+ operationId: operationId || `adopt_learn_${id}_${Date.now()}`,
494
+ });
495
+ return sendJson(res, result && result.ok !== false ? 200 : 409, { ...result });
496
+ }
497
+ if (learningAction === 'edit') {
498
+ const result = editLearningRule(scope, id, {
499
+ expectedRevision: Number(body.expectedRevision || 0),
500
+ title: typeof body.title === 'string' ? body.title : '',
501
+ body: typeof body.body === 'string' ? body.body : '',
502
+ operationId: operationId || `edit_learn_${id}_${Date.now()}`,
503
+ });
504
+ return sendJson(res, result && result.ok !== false ? 200 : 409, { ...result });
505
+ }
241
506
  if (learningAction === 'pause') {
242
507
  const ids = Array.isArray(body.ids) ? body.ids.filter((value) => typeof value === 'string').slice(0, 12) : [];
243
508
  const result = pauseLearningRules(scope, ids, { operationId: operationId || `pause_learn_${Date.now()}`, by: 'management', reason: 'paused_by_user' });
244
509
  return sendJson(res, result && result.ok !== false ? 200 : 409, { ...result });
245
510
  }
246
- if (learningAction === 'delete') {
511
+ if (learningAction === 'delete' || learningAction === 'discard') {
247
512
  const result = removeLearningRule(scope, id, {
248
513
  expectedRevision: Number(body.expectedRevision || 0),
249
514
  operationId: operationId || `delete_learn_${id}_${Date.now()}`,
@@ -294,6 +559,11 @@ function keepHandler(webServer, cwdForSession) {
294
559
  : typeof body.suggestionId === 'string' ? body.suggestionId : '';
295
560
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
296
561
 
562
+ /** 总开关关闭时面板只能看:保留、拒绝、搬移、撤销都不落盘。 */
563
+ if (!controlStatus(scope).enabled) {
564
+ return sendJson(res, 409, { ok: false, reason: '本项目记忆已关闭,先在面板打开', code: 'memory_disabled' });
565
+ }
566
+
297
567
  if (action === 'move' || action === 'resume-move') {
298
568
  const operationId = typeof body.operationId === 'string' ? body.operationId : '';
299
569
  try {
@@ -365,8 +635,8 @@ const SAVE_PARAMS = {
365
635
  properties: {
366
636
  id: { type: 'string', description: '要更新的记忆 id(来自搜索结果或相近提示)。同一件事更新时用它,别另建一条' },
367
637
  title: { type: 'string', description: `标题:名词短语,用户一眼能认出是哪件事,不超过 ${LIMITS.titleChars} 字。它就是这条记忆的身份` },
368
- summary: { type: 'string', description: `一句话说清要记的内容本身——用户只看这一行决定留不留,名词短语,不超过 ${LIMITS.summaryChars} 字` },
369
- body: { type: 'string', description: `正文 markdown,整段替换旧正文,不超过 ${LIMITS.entryBytes} 字节。只写指针与要点,不抄录长文档` },
638
+ summary: { type: 'string', description: `一句话说清要记的内容本身——用户只看这一行决定留不留,名词短语最好,超长会自动截到这个长度` },
639
+ body: { type: 'string', description: `正文 markdown,整段替换旧正文,不超过 ${LIMITS.entryBytes} 字节。只写原则、判断规则、触发条件和边界;不写一次性数字或代码片段,这些查当前代码或文档。` },
370
640
  when: { type: 'string', description: `前置条件或适用场景(「在哪台机器 / 哪个环境 / 什么情况下」),不超过 ${LIMITS.whenChars} 字。同一标题但前置条件不同,会存成两条,不合并;更新时不给就保留原值` },
371
641
  kind: { type: 'string', enum: ['preference', 'convention', 'procedure', 'pointer', 'pitfall', 'credential_hint', 'note'], description: '条目类型;更新时不给就保留原值' },
372
642
  tags: { type: 'array', items: { type: 'string' }, description: '最多 5 个标签;更新时不给就保留原值' },
@@ -491,8 +761,21 @@ const FORGET_PARAMS = {
491
761
  additionalProperties: false,
492
762
  };
493
763
 
494
- function scopeLabel(value) {
495
- return value.scope === 'global' ? ' [全局]' : '';
764
+ /** 记忆归属标记:全局 `[global]`,项目用项目目录名(如 `[DSH]`)。 */
765
+ function projectNameOf(scope) {
766
+ const base = scope && scope.cwd ? path.basename(scope.cwd) : '';
767
+ return String(base || '').replace(/[^\w.\-\u4e00-\u9fff]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 16);
768
+ }
769
+
770
+ function scopeTag(value) {
771
+ if (value && value.scope === 'global') return '[global]';
772
+ if (value && value.scopeName) return `[${value.scopeName}]`;
773
+ return '';
774
+ }
775
+
776
+ function withTag(value, text) {
777
+ const tag = scopeTag(value);
778
+ return tag ? `${tag} ${text}` : text;
496
779
  }
497
780
 
498
781
  function statusLabel(status) {
@@ -515,7 +798,8 @@ const RECALL_HEADER = '已有记忆可能相关(需要细节用 memory_read
515
798
 
516
799
  /** 卡片只放够判断要不要读的几行;必要前提整条在场,装不下就整卡省略。 */
517
800
  function renderRecallCard(card) {
518
- const parts = [`- 「${card.title}」${card.scope === 'global' ? ' [全局]' : ''} · ${card.id} · 第 ${card.revision} 版`];
801
+ const tag = card.scope === 'global' ? '[global]' : card.scopeName ? `[${card.scopeName}]` : '';
802
+ const parts = [`- ${tag ? `${tag} ` : ''}「${card.title}」 · ${card.id}`];
519
803
  if (card.summary) parts.push(`|${card.summary}`);
520
804
  if (card.conditions && card.conditions.length) parts.push(`|前提:${card.conditions.join('、')}`);
521
805
  if (card.selection) parts.push(`|只适用于 ${card.selection}`);
@@ -527,6 +811,29 @@ export function apply(ctx, config) {
527
811
  const tools = ctx.tools;
528
812
  const getService = (name) => (typeof ctx.get === 'function' ? ctx.get(name) : undefined);
529
813
 
814
+ /** 关掉记忆的会话看不到这组工具;每次进入新步骤前按当前开关同步一次。 */
815
+ const hiddenTools = new WeakMap();
816
+ const memoryOnForAgent = (agent) => {
817
+ try {
818
+ return memoryEnabled(scopeOf(cwdOf({ agent })));
819
+ } catch {
820
+ return true;
821
+ }
822
+ };
823
+ const syncMemoryTools = (agent) => {
824
+ const scoped = agent?.ctx?.tools;
825
+ if (!scoped) return;
826
+ const dispose = hiddenTools.get(agent);
827
+ if (memoryOnForAgent(agent)) {
828
+ if (!dispose) return;
829
+ hiddenTools.delete(agent);
830
+ try { dispose(); } catch { /* scope already gone */ }
831
+ return;
832
+ }
833
+ if (dispose) return;
834
+ try { hiddenTools.set(agent, scoped.restrict({ deny: MEMORY_TOOLS })); } catch { /* no global tools to mask */ }
835
+ };
836
+
530
837
  /* ---------------- 自动召回:策略、冻结快照、任务窗口与请求预算 ---------------- */
531
838
 
532
839
  /** 升级不覆盖用户的选择:默认 adaptive,别的模式只由明确配置切换。 */
@@ -543,6 +850,8 @@ export function apply(ctx, config) {
543
850
  const seenHumans = new Map();
544
851
  /** 本轮真人反馈先记在这里,等 turn/end 再决定要不要整理;不跨会话。 */
545
852
  const feedbackCandidates = new Map();
853
+ /** 项目内反馈代次:每来一次新的可整理反馈就加一,旧整理结果必须作废。 */
854
+ const feedbackRevisions = new Map();
546
855
  /** 一个项目同一时间只整理一次;控制器放在模块级供开关路由中止。 */
547
856
  const learningJobs = new Map();
548
857
 
@@ -614,7 +923,8 @@ export function apply(ctx, config) {
614
923
  const index = loadIndex(source);
615
924
  const meta = index.__meta || {};
616
925
  const evolutionEpoch = index.__evolution && index.__evolution.epoch ? index.__evolution.epoch : '';
617
- return `${index.scope}:${meta.storeId || ''}:${index.generation}:${index.epoch}:${evolutionEpoch}`;
926
+ const controlEpoch = index.__control && index.__control.epoch ? index.__control.epoch : '';
927
+ return `${index.scope}:${meta.storeId || ''}:${index.generation}:${index.epoch}:${evolutionEpoch}:${controlEpoch}`;
618
928
  }).join('|');
619
929
  } catch {
620
930
  return '';
@@ -658,6 +968,7 @@ export function apply(ctx, config) {
658
968
  for (const row of ranked) {
659
969
  if (cards.length >= view.CARD.max) break;
660
970
  const card = view.cardOf(row.record, layerOf.get(row.record.id));
971
+ if (card.scope === 'project') card.scopeName = projectNameOf(scope);
661
972
  const line = renderRecallCard(card);
662
973
  const size = Buffer.byteLength(`\n${line}`);
663
974
  if (used + size > view.CARD.bytes) continue;
@@ -683,7 +994,7 @@ export function apply(ctx, config) {
683
994
 
684
995
  /* ---------------- 项目自进化:任务锚点、读取决策、上下文解析 ---------------- */
685
996
 
686
- const EXPERIMENT_INDEX_HEADER = '项目里有这些栏目(先问用户这期做哪个,再用 memory_search 带上 contextId 绑定):';
997
+ const EXPERIMENT_INDEX_HEADER = '项目里这些记忆范围可能适用当前任务(未绑定);需要细节再用 memory_search 带上 contextId:';
687
998
  const EXPERIMENT_RULES_HEADER = '项目实验规则(只在当前任务条件匹配时适用;细节用 memory_read 取正文,别凭标题猜):';
688
999
  const EXPERIMENT_BYTES = 1200;
689
1000
  const EXPERIMENT_SHORT_CHARS = 240;
@@ -740,16 +1051,55 @@ export function apply(ctx, config) {
740
1051
  return text;
741
1052
  }
742
1053
 
1054
+ /** 实验正文按条目记有效期:一条到期只撤一条,不把同请求里其他规则一起清掉。 */
1055
+ function experimentTextFrom(parts, now = Date.now()) {
1056
+ if (!parts || !Array.isArray(parts.items)) return '';
1057
+ const items = parts.items.filter((item) => !item.validUntil || now <= Date.parse(String(item.validUntil)));
1058
+ if (!items.length) return '';
1059
+ return `${parts.header}\n${items.map((item) => item.line).join('\n')}${parts.tail || ''}`;
1060
+ }
1061
+
1062
+ /** 本轮问题与栏目名的字面相关度:只展示有交集的栏目,交集为空就整段跳过。 */
1063
+ function relevantRanges(names, query) {
1064
+ const text = String(query || '').toLowerCase();
1065
+ if (!text) return [];
1066
+ const tokens = new Set();
1067
+ for (const word of text.match(/[a-z][a-z0-9_-]{2,}/g) || []) tokens.add(word);
1068
+ for (const run of text.match(/[\u4e00-\u9fff]+/g) || []) {
1069
+ for (let i = 0; i + 2 <= run.length; i += 1) tokens.add(run.slice(i, i + 2));
1070
+ }
1071
+ if (!tokens.size) return [];
1072
+ return (Array.isArray(names) ? names : []).filter((name) => {
1073
+ const value = String(name || '').toLowerCase();
1074
+ if (!value) return false;
1075
+ if (text.includes(value)) return true;
1076
+ for (const token of tokens) if (value.includes(token)) return true;
1077
+ return false;
1078
+ });
1079
+ }
1080
+
1081
+ /** 实验正文只在「自进化开着,且还是同一代」时可用;关掉再开不能复活旧正文。 */
1082
+ function experimentStillCurrent(scope, held) {
1083
+ let evolution;
1084
+ try { evolution = evolutionStatus(scope); } catch { return false; }
1085
+ if (!evolution || evolution.enabled !== true || evolution.compatible === false) return false;
1086
+ if (held && held.epoch && String(evolution.epoch || '') !== String(held.epoch)) return false;
1087
+ return true;
1088
+ }
1089
+
743
1090
  /** 读决策 → 一段短提示:未绑定只给栏目索引,已绑定才给规则正文。 */
744
1091
  function renderExperiment(scope, decision, context, held, win) {
1092
+ if (held && typeof held === 'object') { held.validUntil = ''; held.experimentParts = null; held.injectedIds = []; }
1093
+ if (!experimentStillCurrent(scope, held)) return '';
745
1094
  if (!decision || decision.kind === 'none') return '';
746
1095
  if (decision.kind === 'conflict') {
747
1096
  return chargeExperiment(win, '这些实验规则互相冲突,先按用户这次说的做,别叠加对立的规则。');
748
1097
  }
749
1098
  if (decision.kind === 'index' || decision.kind === 'clarify') {
750
- const names = Array.isArray(decision.ranges) ? decision.ranges.filter(Boolean).slice(0, 3) : [];
1099
+ const names = relevantRanges(Array.isArray(decision.ranges) ? decision.ranges.filter(Boolean) : [], held && held.query).slice(0, 3);
1100
+ /** 没有适用栏目就什么也不注入:不拿无关范围冒充当前任务,也不催用户选栏目。 */
1101
+ if (!names.length) return '';
751
1102
  const tail = held && held.contextId ? `\ncontextId=${held.contextId}` : '';
752
- if (!names.length) return chargeExperiment(win, `先问用户这期做什么栏目,别自己猜。${tail}`);
753
1103
  return chargeExperiment(win, `${EXPERIMENT_INDEX_HEADER}\n${names.map((name) => `- ${name}`).join('\n')}${tail}`);
754
1104
  }
755
1105
  let index;
@@ -760,9 +1110,12 @@ export function apply(ctx, config) {
760
1110
  let conflict = false;
761
1111
  for (const record of index.entries) {
762
1112
  if (record.origin !== 'project_evolution') continue;
1113
+ if (record.ownerProjectId && record.ownerProjectId !== scope.key) continue;
763
1114
  if (record.reviewState === 'legacy_unreviewed') continue;
764
1115
  const learning = record.learning && typeof record.learning === 'object' ? record.learning : {};
765
1116
  if (learning.state !== 'active' && learning.state !== 'trial') continue;
1117
+ /** 试用到期、暂停或身份失效:自动注入与检索、读取用同一档可用性,不能各判各的。 */
1118
+ if (!view.experimentalUsable(record, Date.now())) continue;
766
1119
  const status = view.statusOf(record, context);
767
1120
  if (status === 'needs_context') { unknown.push(record); continue; }
768
1121
  if (status !== 'ok') continue;
@@ -774,33 +1127,40 @@ export function apply(ctx, config) {
774
1127
  if (conflict) {
775
1128
  return chargeExperiment(win, '这些实验规则互相冲突,先按用户这次说的做,别叠加对立的规则。');
776
1129
  }
777
- const lines = [];
778
- const shown = [];
1130
+ const parts = { header: EXPERIMENT_RULES_HEADER, items: [], tail: held && held.contextId ? `\ncontextId=${held.contextId}` : '' };
779
1131
  let used = Buffer.byteLength(EXPERIMENT_RULES_HEADER) + 1;
780
1132
  for (const record of matched.slice(0, 3)) {
781
1133
  const body = String(record.body || '');
782
1134
  const short = body && body.length <= EXPERIMENT_SHORT_CHARS && body.split('\n').length <= 4;
783
1135
  const conditions = conditionLabel(record.conditions);
784
- const check = Array.isArray(learning.checkpoints) && learning.checkpoints[0]?.text
785
- ? `;核对:${String(learning.checkpoints[0].text).slice(0, 24)}`
1136
+ const checkpoint = record.learning && Array.isArray(record.learning.checkpoints) ? record.learning.checkpoints[0] : null;
1137
+ const check = checkpoint && checkpoint.text
1138
+ ? `;核对:${String(checkpoint.text).slice(0, 40)}`
786
1139
  : '';
787
1140
  const line = short
788
- ? `- 「${record.title}」${conditions ? `(${conditions})` : ''} · ${record.id} · 第 ${record.revision || 1} 版:${body}${check}`
789
- : `- 「${record.title}」 · ${record.id} · 第 ${record.revision || 1} 版(用途:${record.summary || record.title};要用时 memory_read 取正文)`;
1141
+ ? `- 「${record.title}」${conditions ? `(${conditions})` : ''} · ${record.id}:${body}${check}`
1142
+ : `- 「${record.title}」 · ${record.id}(用途:${record.summary || record.title};要用时 memory_read 取正文)`;
790
1143
  const size = Buffer.byteLength(`\n${line}`);
791
1144
  if (used + size > EXPERIMENT_BYTES) break;
792
1145
  used += size;
793
- lines.push(line);
794
- shown.push(record.id);
1146
+ const trialLearning = record.learning && typeof record.learning === 'object' ? record.learning : {};
1147
+ const trialUntil = trialLearning.state === 'trial' && Number.isFinite(Date.parse(String(trialLearning.trialUntil || '')))
1148
+ ? String(trialLearning.trialUntil) : '';
1149
+ parts.items.push({ id: record.id, line, validUntil: trialUntil });
1150
+ }
1151
+ if (held) held.injectedIds = parts.items.map((item) => item.id);
1152
+ if (!parts.items.length && unknown.length) {
1153
+ /** 条件没确认的条目只报数量,绝不在模型侧暴露标题。 */
1154
+ return chargeExperiment(win, '有些实验规则的适用条件还没确认,先按用户这次说的做,别把它们当本轮依据。');
795
1155
  }
796
- if (held) held.injectedIds = shown;
797
- if (!lines.length && unknown.length) {
798
- const nameLine = unknown.slice(0, 3).map((record) => `- 「${record.title}」 · ${record.id}`).join('\n');
799
- return chargeExperiment(win, `有些实验规则的适用条件还没确认,先别当成本轮依据:\n${nameLine}`);
1156
+ if (!parts.items.length) return '';
1157
+ if (held) held.experimentParts = parts;
1158
+ /** 兼容旧读取点:整体最早到期只取本轮真正注入的条目,不再被无关试验拖下水。 */
1159
+ if (held) {
1160
+ const deadlines = parts.items.map((item) => Date.parse(String(item.validUntil))).filter(Number.isFinite);
1161
+ if (deadlines.length) held.validUntil = new Date(Math.min(...deadlines)).toISOString();
800
1162
  }
801
- if (!lines.length) return '';
802
- const tail = held && held.contextId ? `\ncontextId=${held.contextId}` : '';
803
- return chargeExperiment(win, `${EXPERIMENT_RULES_HEADER}\n${lines.join('\n')}${tail}`);
1163
+ return chargeExperiment(win, experimentTextFrom(parts));
804
1164
  }
805
1165
 
806
1166
  /** 一次真人请求只路由一次;同一批次重试复用同一 transitionId。 */
@@ -840,9 +1200,11 @@ export function apply(ctx, config) {
840
1200
  });
841
1201
  const contextId = anchor.contextIdFor(scope.key, batchKey, crypto.randomUUID().slice(0, 8));
842
1202
  const episode = routed.tasks && routed.tasks.episodes ? routed.tasks.episodes[routed.anchor.episodeId] : null;
1203
+ let epoch = '';
1204
+ try { epoch = String(evolutionStatus(scope).epoch || ''); } catch { epoch = ''; }
843
1205
  const held = {
844
- contextId, projectKey: scope.key, sessionId, batchKey, messageIds,
845
- context, anchor: routed.anchor, ranges, decision,
1206
+ contextId, projectKey: scope.key, sessionId, batchKey, messageIds, query,
1207
+ context, anchor: routed.anchor, ranges, decision, epoch,
846
1208
  episodeUpdatedAt: episode ? episode.updatedAt : '', injectedIds: [],
847
1209
  at: Date.now(),
848
1210
  };
@@ -866,42 +1228,77 @@ export function apply(ctx, config) {
866
1228
  if (!win) return { ok: false, status: 'needs_context', reason: '这个请求的上下文已经过期,先重新问一次当前任务' };
867
1229
  const held = win.contexts.get(contextId);
868
1230
  if (!held) return { ok: false, status: 'needs_context', reason: '这个上下文已经过期,先重新问一次当前任务' };
1231
+ /** 凭据只在当轮、当次开关代次内有效:换轮、关闭、重开或过期都不再放行旧正文。 */
1232
+ const expired = (reason) => {
1233
+ win.contexts.delete(contextId);
1234
+ if (win.experiment && win.experiment.contextId === contextId) win.experiment = null;
1235
+ return { ok: false, status: 'needs_context', reason };
1236
+ };
1237
+ if (held.projectKey !== scope.key) return expired('这个上下文属于另一个项目');
1238
+ if (held.sessionId && held.sessionId !== String(win.sessionId || '')) return expired('这个上下文已经过期,先重新问一次当前任务');
1239
+ if (!held.batchKey || held.batchKey !== anchor.requestBatchKey(win.messageIds || [])) {
1240
+ return expired('这个上下文已经过期,先重新问一次当前任务');
1241
+ }
1242
+ let evolution;
1243
+ try { evolution = evolutionStatus(scope); } catch { return expired('当前项目状态读不出来,先重新问一次当前任务'); }
1244
+ if (!evolution || evolution.enabled !== true || evolution.compatible === false) {
1245
+ return expired('项目自进化已经关闭,先重新问一次当前任务');
1246
+ }
1247
+ if (held.epoch && evolution.epoch !== held.epoch) {
1248
+ return expired('项目自进化重新开过,先重新问一次当前任务');
1249
+ }
869
1250
  return { ok: true, scope, win, context: held.context, experimental: true, held, contextId };
870
1251
  }
871
1252
 
872
- /** 工具模式没有自动索引时,按本轮真人消息现开一个请求上下文,仍要前台点名栏目才绑定。 */
1253
+ /** 工具模式没有自动索引时,按本轮真人消息现开一个请求上下文,只按问题与记忆范围的字面关系判断。 */
873
1254
  function toolContextFor(execution) {
874
1255
  const win = windowOf(execution && execution.agent);
875
1256
  if (!win || !win.sessionId || !Array.isArray(win.messageIds) || win.messageIds.length === 0) return '';
876
- let status;
877
- try { status = evolutionStatus(scopeOf(cwdOf(execution))); } catch { return ''; }
878
- if (!status || status.enabled !== true || status.compatible === false) return '';
879
1257
  const scope = scopeOf(cwdOf(execution));
880
1258
  if (scope.confirmed !== true) return '';
1259
+ let status;
1260
+ try { status = evolutionStatus(scope); } catch { return ''; }
1261
+ if (!status || status.enabled !== true || status.compatible === false) return '';
881
1262
  const batchKey = anchor.requestBatchKey(win.messageIds);
882
- const held = win.experiment && win.experiment.batchKey === batchKey ? win.experiment : null;
1263
+ const held = win.experiment && win.experiment.batchKey === batchKey && win.experiment.epoch === status.epoch
1264
+ ? win.experiment
1265
+ : null;
883
1266
  if (held) return held.contextId;
884
1267
  const contextId = anchor.contextIdFor(scope.key, batchKey, crypto.randomUUID().slice(0, 8));
885
1268
  const created = {
886
1269
  contextId, projectKey: scope.key, sessionId: win.sessionId, batchKey,
887
- messageIds: win.messageIds.slice(), context: view.trustedContext(scope, {}),
888
- anchor: null, ranges: rangesOf(scope), decision: { kind: 'none' }, at: Date.now(),
1270
+ messageIds: win.messageIds.slice(), query: trustedQueryOf(win), context: view.trustedContext(scope, {}),
1271
+ anchor: null, ranges: rangesOf(scope), decision: { kind: 'none' }, epoch: status.epoch, at: Date.now(),
889
1272
  };
890
1273
  win.experiment = created;
891
1274
  win.contexts.set(contextId, created);
892
1275
  return contextId;
893
1276
  }
894
1277
 
1278
+ /** 本轮真人消息是唯一可信的任务依据;模型自己写的 query 只用于检索词,不能拿来建绑定。 */
1279
+ function trustedQueryOf(win) {
1280
+ return win && typeof win.query === 'string' ? win.query : '';
1281
+ }
1282
+
895
1283
  /** 前台绑定只升到 proposed:真正执行链还没发生,不冒充 active。 */
896
- function bindSelection(execution, resolved, query) {
1284
+ function bindSelection(execution, resolved) {
897
1285
  const held = resolved.held;
1286
+ if (!held || typeof held !== 'object') return { ok: false, status: 'needs_context', reason: '本轮还没有可核对的用户消息,先重新问一次当前任务' };
1287
+ const query = held.query || trustedQueryOf(resolved.win);
1288
+ if (!query) return { ok: false, status: 'needs_context', reason: '本轮还没有可核对的用户消息,先重新问一次当前任务' };
898
1289
  const ranges = held.ranges || { series: [], taskKinds: [] };
1290
+ /** 自动路由已经选了范围就沿用同一个结果,工具绑定不重新猜一遍,避免两处给出不同任务。 */
1291
+ const routedAnchor = held.anchor && typeof held.anchor === 'object' ? held.anchor : null;
899
1292
  const hits = [];
900
- for (const value of ranges.series || []) if (query.includes(value)) hits.push({ seriesId: value });
901
- for (const value of ranges.taskKinds || []) if (query.includes(value)) hits.push({ taskKind: value });
1293
+ if (routedAnchor && routedAnchor.seriesId) hits.push({ seriesId: routedAnchor.seriesId });
1294
+ else if (routedAnchor && routedAnchor.taskKind) hits.push({ taskKind: routedAnchor.taskKind });
1295
+ else {
1296
+ for (const value of ranges.series || []) if (anchor.seriesChosenBy(query, value)) hits.push({ seriesId: value });
1297
+ for (const value of ranges.taskKinds || []) if (anchor.seriesChosenBy(query, value)) hits.push({ taskKind: value });
1298
+ }
902
1299
  if (hits.length !== 1) {
903
1300
  const names = [...(ranges.series || []), ...(ranges.taskKinds || [])].slice(0, 3);
904
- return { ok: false, status: 'needs_context', reason: names.length ? `先在问题里点名一个栏目:${names.join('、')}` : '先说要做的栏目' };
1301
+ return { ok: false, status: 'needs_context', reason: names.length ? '本轮没有匹配的记忆范围,按当前任务继续' : '本轮没有适用的记忆范围,按当前任务继续' };
905
1302
  }
906
1303
  let tasks;
907
1304
  try { tasks = taskState(resolved.scope); } catch { return { ok: false, status: 'needs_context', reason: '任务状态还没读出来' }; }
@@ -920,7 +1317,11 @@ export function apply(ctx, config) {
920
1317
  resolved.context = held.context;
921
1318
  const text = renderExperiment(resolved.scope, held.decision, held.context, held, resolved.win);
922
1319
  const preparedState = prepared.get(execution && execution.agent);
923
- if (preparedState) preparedState.experimentText = text;
1320
+ if (preparedState) {
1321
+ preparedState.experimentText = text;
1322
+ preparedState.experimentParts = held.experimentParts || null;
1323
+ preparedState.validUntil = held.validUntil || '';
1324
+ }
924
1325
  return { ok: true };
925
1326
  }
926
1327
 
@@ -936,7 +1337,22 @@ export function apply(ctx, config) {
936
1337
  if (state.signal && signal && state.signal !== signal) return '';
937
1338
  const sessionId = agent?.session?.id;
938
1339
  if (state.sessionId !== (typeof sessionId === 'string' ? sessionId : '')) return '';
939
- return [state.experimentText, state.text].filter(Boolean).join('\n\n');
1340
+ /** 开关/自进化换代后,关掉前准备好的正文一律不再输出。 */
1341
+ const scope = scopeOf(cwdOf({ agent }));
1342
+ if (!memoryEnabled(scope)) return '';
1343
+ const epoch = controlStatus(scope).epoch || '';
1344
+ if (state.controlEpoch && epoch && state.controlEpoch !== epoch) return '';
1345
+ /** 自进化关掉或换过代:关掉前的实验正文不再输出,重开也不复活。 */
1346
+ let evolutionText = state.experimentParts ? experimentTextFrom(state.experimentParts) : state.experimentText;
1347
+ if (evolutionText) {
1348
+ let evolution;
1349
+ try { evolution = evolutionStatus(scope); } catch { evolution = null; }
1350
+ if (!evolution || evolution.enabled !== true || evolution.compatible === false
1351
+ || (state.evolutionEpoch && String(evolution.epoch || '') !== state.evolutionEpoch)) evolutionText = '';
1352
+ }
1353
+ /** 没有逐条快照时才退回整段到期判断;有快照就只撤到期的那一条。 */
1354
+ if (evolutionText && !state.experimentParts && state.validUntil && Date.now() > Date.parse(String(state.validUntil))) evolutionText = '';
1355
+ return [evolutionText, state.text].filter(Boolean).join('\n\n');
940
1356
  } catch {
941
1357
  return '';
942
1358
  }
@@ -945,7 +1361,7 @@ export function apply(ctx, config) {
945
1361
 
946
1362
  ctx.effect(() => tools.register({
947
1363
  name: 'memory_save',
948
- description: `保存一条记忆。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。更新同一件事时带 id:身份只看标题、旧标题和前置条件,像不像不授权覆盖。文本相近但标题不同时先不写,返回 needsResolution 与 1–2 条相近条目:确实是同一条就用那个 id 更新,确实是另一条才带 resolution="distinct" 再存。带 id 更新时没给的字段保持原值,正文整段替换,旧版本留在 versions 里(每字都留,不截断)。条数到硬上限时把同层最久没被读的一条移进归档,结果里写明动了哪条,不拒绝新记忆;单条正文超限只报错、不截断。前置条件不同的同一标题是两条。${NOTES}`,
1364
+ description: `保存一条记忆。只在会改变以后判断时才存;先搜已有条目,能更新就不要新建。保存前先蒸馏:标题是结论,正文只写原则与触发条件,不抄原始输出里的具体数值。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。更新同一件事时带 id:身份只看标题、旧标题和前置条件,像不像不授权覆盖。文本相近但标题不同时先不写,返回 needsResolution 与 1–2 条相近条目:确实是同一条就用那个 id 更新,确实是另一条才带 resolution="distinct" 再存。带 id 更新时没给的字段保持原值,正文整段替换,旧正文只留作内部撤回,不展示也不参与检索。条数到硬上限时把同层最久没被读的一条移进归档,结果里写明动了哪条,不拒绝新记忆;单条正文超限只报错、不截断。前置条件不同的同一标题是两条。${NOTES}`,
949
1365
  parameters: SAVE_PARAMS,
950
1366
  output: {
951
1367
  presentationMeta: (_args, value) => ({
@@ -967,10 +1383,13 @@ export function apply(ctx, config) {
967
1383
  title: { type: 'string' },
968
1384
  summary: { type: 'string' },
969
1385
  when: { type: 'string' },
1386
+ summaryShortened: { type: 'boolean' },
1387
+ fullTextInBody: { type: 'boolean' },
970
1388
  revision: { type: 'integer' },
971
1389
  merged: { type: 'boolean' },
972
1390
  changed: { type: 'boolean' },
973
1391
  scope: { type: 'string' },
1392
+ scopeName: { type: 'string' },
974
1393
  needsResolution: { type: 'boolean' },
975
1394
  needsScope: { type: 'boolean' },
976
1395
  reason: { type: 'string' },
@@ -988,20 +1407,30 @@ export function apply(ctx, config) {
988
1407
  render: (_args, value) => [{
989
1408
  type: 'text',
990
1409
  text: value.needsScope
991
- ? value.reason === 'unconfirmed_project'
1410
+ ? value.reason === 'memory_disabled'
1411
+ ? 'memory · 本项目记忆已关闭,先在面板打开'
1412
+ : value.reason === 'unconfirmed_project'
992
1413
  ? `memory · 当前目录还不能确认属于哪个项目:「${value.title}」\n先确认项目根目录,或显式 scope="global"`
993
1414
  : `memory · 归属还没定:「${value.title}」\n补 memoryClass(personal_preference / project_fact / environment_fact / portable_procedure)或显式 scope;设备事实还要给设备条件`
994
1415
  : value.existingId
995
- ? `memory · 已有相同记忆:「${value.title}」 · ${value.existingId}(${value.existingScope})第 ${value.revision} 版\n要改成另一条,加 resolution="distinct"`
1416
+ ? `memory · 已有相同记忆:「${value.title}」 · ${value.existingId}(${value.existingScope})\n要改成另一条,加 resolution="distinct"`
996
1417
  : value.needsResolution
997
1418
  ? `memory · 需要确认:「${value.title}」与已有条目相近${value.crossLayer ? '(在另一层)' : ''}${value.sameTopic ? '(同一主题槽位)' : ''}\n${value.similar}\n要更新它,用 id 重存;确实是另一条,加 resolution="distinct"`
998
1419
  : value.changed === false
999
- ? `memory · unchanged${scopeLabel(value)} "${value.title}" · ${value.id} · 第 ${value.revision} 版`
1000
- : `memory · ${value.merged ? 'merged' : 'saved'}${scopeLabel(value)} "${value.title}"${value.when ? `(${value.when})` : ''} · ${value.id} · 第 ${value.revision} 版${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}`,
1420
+ ? `memory · unchanged ${withTag(value, `"${value.title}"`)} · ${value.id}`
1421
+ : `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''} · ${value.id}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}${value.fullTextInBody ? ' · 摘要超过 60 字,完整原句已存进正文' : ''}`,
1001
1422
  }],
1002
1423
  },
1003
1424
  execute: async (args, execution) => {
1004
1425
  const projectScope = scopeOf(cwdOf(execution));
1426
+ const projectScopeName = projectNameOf(projectScope);
1427
+ if (!memoryEnabled(projectScope)) {
1428
+ return {
1429
+ id: '', title: String(args.title || ''), summary: '', when: '', revision: 0,
1430
+ merged: false, changed: false, scope: '', needsScope: true, reason: 'memory_disabled',
1431
+ needsResolution: false, similarIds: '', similar: '', archived: '',
1432
+ };
1433
+ }
1005
1434
  const saved = saveEntry(projectScope, { ...args, writeMode: args.mode || 'patch' });
1006
1435
  if (saved.needsScope) {
1007
1436
  return {
@@ -1031,6 +1460,7 @@ export function apply(ctx, config) {
1031
1460
  merged: false,
1032
1461
  changed: false,
1033
1462
  scope: saved.existing.scope,
1463
+ scopeName: saved.existing.scope === 'global' ? '' : projectScopeName,
1034
1464
  needsScope: false,
1035
1465
  existingId: saved.existing.id,
1036
1466
  existingScope: saved.existing.scope,
@@ -1066,10 +1496,13 @@ export function apply(ctx, config) {
1066
1496
  title: saved.record.title,
1067
1497
  summary: saved.record.summary,
1068
1498
  when: saved.record.when || '',
1499
+ summaryShortened: Boolean(saved.draft?.summaryShortened),
1500
+ fullTextInBody: Boolean(saved.draft?.fullTextInBody),
1069
1501
  revision: saved.record.revision || 1,
1070
1502
  merged: saved.merged,
1071
1503
  changed: !saved.noop,
1072
1504
  scope: scope.key === 'global' ? 'global' : 'project',
1505
+ scopeName: scope.key === 'global' ? '' : projectScopeName,
1073
1506
  needsScope: false,
1074
1507
  needsResolution: false,
1075
1508
  similarIds: saved.similarList.map((item) => item.id).join('、'),
@@ -1107,6 +1540,7 @@ export function apply(ctx, config) {
1107
1540
  },
1108
1541
  execute: async (args, execution) => {
1109
1542
  const scope = scopeOf(cwdOf(execution));
1543
+ if (!memoryEnabled(scope)) return { asked: false, reason: '本项目记忆已关闭' };
1110
1544
  const gate = suggestionGate(scope, args);
1111
1545
  if (!gate.ok) return { asked: false, reason: gate.reason };
1112
1546
  const noted = noteSuggestion(scope, gate.target ? { ...args, targetId: gate.target.id } : args);
@@ -1121,7 +1555,7 @@ export function apply(ctx, config) {
1121
1555
 
1122
1556
  ctx.effect(() => tools.register({
1123
1557
  name: 'memory_search',
1124
- description: `按需检索已有记忆:先搜,命中再 memory_read 读正文。用户提到「记住了什么 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」,或者眼前反复出现的报错、弹窗、异常时,先查这里有没有记过解法。跨项目的通用条目以 [全局] 开头。中文长句可以直接当查询。每行带 id 与版次,更新时把 id 传回 memory_save。默认只列当前有效的;已经归档或被新版本替换的旧说法不混进来,要查历史就在参数里显式要 history。`,
1558
+ description: `按需检索已有记忆:先搜,命中再 memory_read 读正文。用户提到「记住了什么 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」,或者眼前反复出现的报错、弹窗、异常时,先查这里有没有记过解法。跨项目的通用条目以 [global] 开头,项目条目以 [项目名] 开头。中文长句可以直接当查询。每行带 id,更新时把 id 传回 memory_save。默认只列当前有效的;已经归档或被更新替换的旧说法不混进来,要查历史就在参数里显式要 history。`,
1125
1559
  parameters: SEARCH_PARAMS,
1126
1560
  output: {
1127
1561
  presentationMeta: (_args, value) => ({ kind: 'memory-search', count: value.count, status: value.status || 'ok', refs: value.refs || [] }),
@@ -1132,6 +1566,7 @@ export function apply(ctx, config) {
1132
1566
  chars: { type: 'integer' },
1133
1567
  text: { type: 'string' },
1134
1568
  status: { type: 'string', enum: ['ok', 'budget_exhausted', 'memory_disabled', 'needs_context'] },
1569
+ reasonCode: { type: 'string' },
1135
1570
  refs: {
1136
1571
  type: 'array',
1137
1572
  items: {
@@ -1142,8 +1577,9 @@ export function apply(ctx, config) {
1142
1577
  scope: { type: 'string' },
1143
1578
  revision: { type: 'integer' },
1144
1579
  status: { type: 'string' },
1580
+ origin: { type: 'string' },
1145
1581
  },
1146
- required: ['id', 'title', 'scope', 'revision', 'status'],
1582
+ required: ['id', 'title', 'scope', 'revision', 'status', 'origin'],
1147
1583
  additionalProperties: false,
1148
1584
  },
1149
1585
  },
@@ -1165,7 +1601,7 @@ export function apply(ctx, config) {
1165
1601
  }],
1166
1602
  },
1167
1603
  execute: async (args, execution) => {
1168
- if (recallMode() === 'off') return { count: 0, chars: 0, text: '', status: 'memory_disabled', refs: [] };
1604
+ if (recallMode() === 'off' || !memoryEnabledFor(execution)) return { count: 0, chars: 0, text: '', status: 'memory_disabled', refs: [] };
1169
1605
  const agent = execution?.agent;
1170
1606
  const win = windowOf(agent);
1171
1607
  if (win && !affordable(win, 0)) return { count: 0, chars: 0, text: '', status: 'budget_exhausted', refs: [] };
@@ -1177,21 +1613,50 @@ export function apply(ctx, config) {
1177
1613
  const resolved = resolveRequestContext(execution, contextId);
1178
1614
  if (!resolved.ok) return { count: 0, chars: 0, text: resolved.reason, status: resolved.status || 'needs_context', refs: [] };
1179
1615
  if (wantsBind) {
1180
- const bound = bindSelection(execution, resolved, String(args.query || ''));
1616
+ const bound = bindSelection(execution, resolved);
1181
1617
  if (!bound.ok) return { count: 0, chars: 0, text: bound.reason, status: bound.status || 'needs_context', refs: [] };
1182
1618
  }
1183
1619
  const scope = resolved.scope;
1184
1620
  const context = resolved.context;
1185
- const hits = search(scope, args.query, args.limit ?? 3, {
1621
+ /** 检索只列当前任务真能用的条目;先取全量候选,过滤后再截断,避免不可用条目挤掉后面的命中。 */
1622
+ const limit = args.limit ?? 3;
1623
+ const rawHits = search(scope, args.query, 50, {
1186
1624
  context,
1187
1625
  history: args.history === true,
1188
1626
  allowExperimental: resolved.experimental === true,
1189
- });
1190
- const shown = hits.map((item) => view.toModelView(item, 'search', { status: item.status || (item.archived ? 'archived' : 'ok') }));
1627
+ }).filter((item) => !item.ownerProjectId || item.ownerProjectId === scope.key);
1628
+ const hits = rawHits.filter((item) => item.archived || item.status === 'ok').slice(0, limit);
1629
+ const withheld = rawHits.filter((item) => !item.archived && item.status !== 'ok');
1630
+ const scopeName = scope.key === 'global' ? '' : projectNameOf(scope);
1631
+ const shown = hits.map((item) => ({
1632
+ ...view.toModelView(item, 'search', { status: item.status || (item.archived ? 'archived' : 'ok') }),
1633
+ scopeName: item.scope === 'global' ? '' : scopeName,
1634
+ }));
1191
1635
  const valueOf = (kept) => {
1192
1636
  const rendered = renderIndex(shown.slice(0, kept));
1193
1637
  return { count: kept, chars: rendered.chars, text: rendered.text, status: 'ok', refs: refsOf(hits.slice(0, kept)) };
1194
1638
  };
1639
+ /** 有匹配但当前用不上:只说状态,不把标题、编号或旧规则递给模型,避免它被这些字面带偏。 */
1640
+ if (!hits.length && withheld.length) {
1641
+ const unresolved = withheld.some((item) => item.status === 'needs_context');
1642
+ const value = {
1643
+ count: 0,
1644
+ chars: 0,
1645
+ text: unresolved
1646
+ ? '有相关的项目记忆,但适用条件还没确认。先说明当前任务,或确认这条记忆的适用范围。'
1647
+ : '有相关的项目记忆,但当前任务明确不适用。',
1648
+ status: unresolved ? 'needs_context' : 'ok',
1649
+ reasonCode: unresolved ? 'applicability_unresolved' : 'not_applicable',
1650
+ refs: [],
1651
+ };
1652
+ if (win) {
1653
+ const bytes = jsonBytes(value);
1654
+ if (!affordable(win, bytes)) return { ...value, status: 'budget_exhausted', text: '' };
1655
+ win.bytes += bytes;
1656
+ win.reads += 1;
1657
+ }
1658
+ return value;
1659
+ }
1195
1660
  /** 没命中是正常结果,不是额度问题;空响应同样记一次本请求开销。 */
1196
1661
  if (!hits.length) {
1197
1662
  const empty = { count: 0, chars: 0, text: '', status: 'ok', refs: [] };
@@ -1232,6 +1697,7 @@ export function apply(ctx, config) {
1232
1697
  when: { type: 'string' },
1233
1698
  revision: { type: 'integer' },
1234
1699
  scope: { type: 'string' },
1700
+ scopeName: { type: 'string' },
1235
1701
  origin: { type: 'string', enum: ['user_explicit', 'project_evolution'] },
1236
1702
  status: { type: 'string', enum: ['ok', 'archived', 'needs_context', 'not_applicable', 'gone', 'budget_exhausted', 'memory_disabled'] },
1237
1703
  body: { type: 'string' },
@@ -1248,10 +1714,20 @@ export function apply(ctx, config) {
1248
1714
  : value.status === 'memory_disabled'
1249
1715
  ? 'memory · 记忆检索已关闭'
1250
1716
  : value.repeat
1251
- ? `memory · 本请求已读过「${value.title}」 · ${value.id} · 第 ${value.revision} 版`
1717
+ ? 'memory · 本请求已读过这条记忆'
1252
1718
  : value.body
1253
- ? `memory · 「${value.title}」${value.when ? `(${value.when})` : ''} · ${value.id} · 第 ${value.revision} 版${scopeLabel(value)}${statusLabel(value.status)}\n${value.body}`
1254
- : `memory · 「${value.title}」的相关记忆\n${value.links || '(无)'}`,
1719
+ ? `memory · ${withTag(value, `「${value.title}」`)}${value.when ? `(${value.when})` : ''} · ${value.id}${statusLabel(value.status)}\n${value.body}`
1720
+ : value.links !== undefined && value.status === 'ok'
1721
+ ? `memory · 「${value.title}」的相关记忆\n${value.links || '(无)'}`
1722
+ : value.status === 'needs_context'
1723
+ ? 'memory · 这条记忆的适用条件还没确认'
1724
+ : value.status === 'not_applicable'
1725
+ ? 'memory · 这条记忆不适用于当前任务'
1726
+ : value.status === 'archived'
1727
+ ? 'memory · 这条记忆已归档'
1728
+ : value.status === 'gone'
1729
+ ? 'memory · 这条记忆已不存在'
1730
+ : 'memory · 当前不能读这条记忆',
1255
1731
  }],
1256
1732
  presentationMeta: (_args, value) => ({
1257
1733
  kind: 'memory-read',
@@ -1264,10 +1740,11 @@ export function apply(ctx, config) {
1264
1740
  }),
1265
1741
  },
1266
1742
  execute: async (args, execution) => {
1267
- if (recallMode() === 'off') return { id: '', scope: '', status: 'memory_disabled' };
1743
+ if (recallMode() === 'off' || !memoryEnabledFor(execution)) return { id: '', scope: '', status: 'memory_disabled' };
1268
1744
  const resolved = resolveRequestContext(execution, args.contextId);
1269
1745
  if (!resolved.ok) return { id: '', scope: '', status: resolved.status || 'needs_context' };
1270
1746
  const scope = resolved.scope;
1747
+ const scopeName = scope.key === 'global' ? '' : projectNameOf(scope);
1271
1748
  const context = resolved.context;
1272
1749
  const allowExperimental = resolved.experimental === true;
1273
1750
  const agent = execution?.agent;
@@ -1275,11 +1752,22 @@ export function apply(ctx, config) {
1275
1752
  if (args.view === 'links') {
1276
1753
  const found = readLinks(scope, args.id, { context, allowExperimental });
1277
1754
  if (!found) throw new Error(`没有 ${args.id} 这条记忆`);
1278
- const rows = found.links.map((link) => (link.status === 'invalid'
1755
+ if (found.status !== 'ok') {
1756
+ const withheld = { id: '', scope: '', status: found.status, links: '' };
1757
+ if (win) {
1758
+ const bytes = jsonBytes(withheld);
1759
+ if (!affordable(win, bytes)) return { id: '', scope: '', status: 'budget_exhausted' };
1760
+ win.bytes += bytes;
1761
+ win.reads += 1;
1762
+ }
1763
+ return withheld;
1764
+ }
1765
+ const rows = found.links.filter((link) => link.status === 'invalid' || link.status === 'ok')
1766
+ .map((link) => (link.status === 'invalid'
1279
1767
  ? `- ${link.id} · 已失效`
1280
- : `- 「${link.title}」${link.scope === 'global' ? ' [全局]' : ''} · ${link.id} · 第 ${link.revision} 版`));
1768
+ : `- ${link.scope === 'global' ? '[global] ' : (scopeName ? `[${scopeName}] ` : '')}「${link.title}」 · ${link.id}`));
1281
1769
  const text = rows.join('\n') || '(无)';
1282
- const value = { id: found.id, scope: found.scope, status: found.status, links: text };
1770
+ const value = { id: found.id, scope: found.scope, scopeName, status: found.status, links: text };
1283
1771
  if (win) {
1284
1772
  const bytes = jsonBytes(value);
1285
1773
  if (!affordable(win, bytes)) return { id: '', scope: '', status: 'budget_exhausted' };
@@ -1290,16 +1778,23 @@ export function apply(ctx, config) {
1290
1778
  }
1291
1779
  const record = readEntry(scope, args.id, { context, allowExperimental });
1292
1780
  if (!record) throw new Error(`没有 ${args.id} 这条记忆`);
1781
+ if (record.origin === 'project_evolution' && record.ownerProjectId && record.ownerProjectId !== scope.key) {
1782
+ return { id: '', scope: record.scope, status: 'not_applicable', body: '' };
1783
+ }
1293
1784
  if (record.origin === 'project_evolution' && !allowExperimental) {
1294
- return { id: record.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: 'needs_context', body: '' };
1785
+ return { id: '', scope: record.scope, status: 'needs_context', body: '' };
1786
+ }
1787
+ /** 暂停、过期或条件不匹配的实验条目只给状态,不给标题和正文。 */
1788
+ if (record.origin === 'project_evolution' && !view.experimentalUsable(record)) {
1789
+ return { id: '', scope: record.scope, status: 'needs_context', body: '' };
1295
1790
  }
1296
- /** 暂停或条件不匹配的实验条目只给状态,不给正文。 */
1297
1791
  const learningState = record.learning && typeof record.learning === 'object' ? record.learning : {};
1298
1792
  if (record.origin === 'project_evolution' && (learningState.state === 'paused' || learningState.blockedReason)) {
1299
- return { id: record.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: 'needs_context', body: '' };
1793
+ return { id: '', scope: record.scope, status: 'needs_context', body: '' };
1300
1794
  }
1301
- if (record.origin === 'project_evolution' && record.status !== 'ok') {
1302
- return { id: record.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: record.status === 'needs_context' ? 'needs_context' : 'not_applicable', body: '' };
1795
+ /** 条件未知或不适用的条目只报状态:正文和标题都不能绕过自动召回的门槛。 */
1796
+ if (record.status !== 'ok') {
1797
+ return { id: '', scope: record.scope, status: record.status === 'needs_context' ? 'needs_context' : 'not_applicable', body: '' };
1303
1798
  }
1304
1799
  const revision = record.revision || 1;
1305
1800
  const body = String(record.body ?? '');
@@ -1309,6 +1804,7 @@ export function apply(ctx, config) {
1309
1804
  when: record.when || '',
1310
1805
  revision,
1311
1806
  scope: record.scope,
1807
+ scopeName,
1312
1808
  status: record.status,
1313
1809
  body,
1314
1810
  };
@@ -1346,20 +1842,23 @@ export function apply(ctx, config) {
1346
1842
  id: { type: 'string' },
1347
1843
  title: { type: 'string' },
1348
1844
  scope: { type: 'string' },
1349
- mode: { type: 'string', enum: ['archive', 'purged', 'needs_resolution', 'purge_pending'] },
1845
+ scopeName: { type: 'string' },
1846
+ mode: { type: 'string', enum: ['archive', 'purged', 'needs_resolution', 'purge_pending', 'memory_disabled'] },
1350
1847
  },
1351
1848
  required: ['id', 'mode'],
1352
1849
  additionalProperties: false,
1353
1850
  },
1354
1851
  render: (_args, value) => [{
1355
1852
  type: 'text',
1356
- text: value.mode === 'purged'
1853
+ text: value.mode === 'memory_disabled'
1854
+ ? 'memory · 本项目记忆已关闭,先在面板打开'
1855
+ : value.mode === 'purged'
1357
1856
  ? `memory · forgotten "${value.title}" · ${value.id}`
1358
1857
  : value.mode === 'purge_pending'
1359
1858
  ? `memory · 清除未完成 · ${value.id},重启后会继续`
1360
1859
  : value.mode === 'needs_resolution'
1361
1860
  ? `memory · 先确认是归档还是彻底忘掉 · ${value.id}`
1362
- : `memory · archived${scopeLabel(value)} "${value.title}" · ${value.id},可找回`,
1861
+ : `memory · archived ${withTag(value, `"${value.title}"`)} · ${value.id},可找回`,
1363
1862
  }],
1364
1863
  presentationMeta: (_args, value) => ({
1365
1864
  kind: 'memory-forget',
@@ -1371,6 +1870,8 @@ export function apply(ctx, config) {
1371
1870
  },
1372
1871
  execute: async (args, execution) => {
1373
1872
  const scope = scopeOf(cwdOf(execution));
1873
+ const projectScopeName = projectNameOf(scope);
1874
+ if (!memoryEnabled(scope)) return { id: String(args.id || ''), title: '', scope: '', mode: 'memory_disabled' };
1374
1875
  const located = locate(scope, args.id);
1375
1876
  if (!located && !args.mode) throw new Error(`没有 ${args.id} 这条记忆`);
1376
1877
  if (!args.mode) {
@@ -1392,6 +1893,7 @@ export function apply(ctx, config) {
1392
1893
  title: located ? located.record.title : '',
1393
1894
  mode: 'purge_pending',
1394
1895
  scope: layer.key === 'global' ? 'global' : 'project',
1896
+ scopeName: layer.key === 'global' ? '' : projectScopeName,
1395
1897
  };
1396
1898
  }
1397
1899
  throw again;
@@ -1402,11 +1904,13 @@ export function apply(ctx, config) {
1402
1904
  }
1403
1905
  if (!dropped) throw new Error(`没有 ${args.id} 这条记忆`);
1404
1906
  const layerKey = dropped.scopeKey || (located ? located.scope.key : scope.key);
1907
+ if (dropped.mode === 'purged') revokeLearningUse(layerKey);
1405
1908
  return {
1406
1909
  id: dropped.id,
1407
1910
  title: dropped.title || (located ? located.record.title : ''),
1408
1911
  mode: dropped.mode,
1409
1912
  scope: layerKey === 'global' ? 'global' : 'project',
1913
+ scopeName: layerKey === 'global' ? '' : projectScopeName,
1410
1914
  };
1411
1915
  },
1412
1916
  }), 'lite-memory: memory_forget');
@@ -1445,9 +1949,23 @@ export function apply(ctx, config) {
1445
1949
  }), 'lite-memory: recall context');
1446
1950
  }
1447
1951
 
1952
+ /** 执行入口再拦一次:工具若已出现在旧快照里,调用时也不放行。 */
1953
+ if (typeof ctx.on === 'function') ctx.on('agent/created', (payload) => {
1954
+ const agent = payload?.agent;
1955
+ if (!agent?.ctx?.tools) return;
1956
+ try {
1957
+ agent.ctx.effect(() => agent.ctx.tools.guard((execution) => {
1958
+ if (!MEMORY_TOOLS.includes(execution?.name)) return undefined;
1959
+ return memoryOnForAgent(agent) ? undefined : '本项目记忆已关闭,先在面板打开';
1960
+ }), 'lite-memory: tool guard');
1961
+ } catch { /* older host without guards */ }
1962
+ syncMemoryTools(agent);
1963
+ });
1964
+
1448
1965
  if (typeof ctx.on === 'function') ctx.on('agent/pre-step', async (payload, next) => {
1449
1966
  const startedAt = Date.now();
1450
1967
  const agent = payload?.agent;
1968
+ syncMemoryTools(agent);
1451
1969
  let decision;
1452
1970
  try {
1453
1971
  decision = await next();
@@ -1488,6 +2006,10 @@ export function apply(ctx, config) {
1488
2006
  }
1489
2007
  previous.signal = signal;
1490
2008
  const scope = scopeOf(cwdOf({ agent }));
2009
+ if (!memoryEnabled(scope)) {
2010
+ clearPrepared(agent);
2011
+ return decision;
2012
+ }
1491
2013
  const fingerprint = fingerprintOf(scope);
1492
2014
  /** 指纹拿不到就当作已失效:不拿旧任务内容硬撑这一步。 */
1493
2015
  if (!fingerprint) {
@@ -1506,6 +2028,8 @@ export function apply(ctx, config) {
1506
2028
  previous.text = snapshot ? snapshot.text : '';
1507
2029
  previous.cards = snapshot ? snapshot.cards : [];
1508
2030
  previous.experimentText = experimentText;
2031
+ previous.experimentParts = win && win.experiment ? (win.experiment.experimentParts || null) : null;
2032
+ previous.validUntil = win && win.experiment ? (win.experiment.validUntil || '') : '';
1509
2033
  previous.fingerprint = fingerprint;
1510
2034
  } else {
1511
2035
  clearPrepared(agent);
@@ -1518,13 +2042,15 @@ export function apply(ctx, config) {
1518
2042
  /** 工具模式也要开额度窗口:只是不自动注入,工具调用同样受每请求预算约束。 */
1519
2043
  const ids = fresh.map((message) => String(message?.id ?? '')).filter(Boolean).sort();
1520
2044
  const win = openWindow(agent, { sessionId, turn, messageIds: ids });
2045
+ /** 工具模式也要记下本轮真人原话:只用于核对绑定,不自动注入。 */
2046
+ const query = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
2047
+ if (win && query) win.query = query;
1521
2048
 
1522
2049
  if (recallMode() !== 'adaptive') {
1523
2050
  clearPrepared(agent);
1524
2051
  return decision;
1525
2052
  }
1526
2053
 
1527
- const query = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
1528
2054
  if (!query) {
1529
2055
  clearPrepared(agent);
1530
2056
  return decision;
@@ -1535,36 +2061,51 @@ export function apply(ctx, config) {
1535
2061
  return decision;
1536
2062
  }
1537
2063
  const scope = scopeOf(cwd);
2064
+ if (!memoryEnabled(scope)) {
2065
+ clearPrepared(agent);
2066
+ return decision;
2067
+ }
1538
2068
  const context = view.trustedContext(scope, {});
1539
2069
  const fingerprint = fingerprintOf(scope);
1540
- const experiment = experimentFor(scope, { sessionId, query, batchKey: anchor.requestBatchKey(ids), messageIds: ids, win });
2070
+ const controlEpoch = controlStatus(scope).epoch || '';
2071
+ const batchKey = anchor.requestBatchKey(ids);
2072
+ dropStaleCandidate(sessionId, batchKey);
2073
+ const experiment = experimentFor(scope, { sessionId, query, batchKey, messageIds: ids, win });
1541
2074
  const remembered = experiment && experiment.held
1542
- ? rememberFeedback(scope, sessionId, fresh, experiment.held)
2075
+ ? rememberFeedback(scope, sessionId, fresh, experiment.held, turn)
1543
2076
  : null;
1544
- if (remembered && remembered.paused) {
2077
+ const snapshot = projectRecall(scope, query, context, win);
2078
+ if (generation !== generations.get(agent)) return decision;
2079
+ /** 投影期间开关被改过:丢弃这批内容,不把关闭前的记忆交给当前请求。 */
2080
+ if (!memoryEnabled(scope) || (controlStatus(scope).epoch || '') !== controlEpoch) {
1545
2081
  clearPrepared(agent);
1546
2082
  return decision;
1547
2083
  }
1548
- const snapshot = projectRecall(scope, query, context, win);
1549
- if (generation !== generations.get(agent)) return decision;
1550
2084
  /** 超时或被取消:这一步不留半旧卡片,下一真人请求重新投影。 */
1551
2085
  if (signal?.aborted || Date.now() - startedAt > RECALL_DEADLINE_MS) {
1552
2086
  clearPrepared(agent);
1553
2087
  return decision;
1554
2088
  }
1555
- if (!snapshot && !(experiment && experiment.text)) {
2089
+ /** 实验上下文即使本轮零注入也要留着:工具绑定后同一请求还能补上规则。 */
2090
+ if (!snapshot && !experiment) {
1556
2091
  clearPrepared(agent);
1557
2092
  return decision;
1558
2093
  }
2094
+ let evolutionEpoch = '';
2095
+ try { evolutionEpoch = String(evolutionStatus(scope).epoch || ''); } catch { evolutionEpoch = ''; }
1559
2096
  const state = {
1560
2097
  text: snapshot ? snapshot.text : '',
1561
2098
  experimentText: experiment ? experiment.text : '',
2099
+ experimentParts: experiment && experiment.held ? (experiment.held.experimentParts || null) : null,
2100
+ validUntil: experiment && experiment.held ? (experiment.held.validUntil || '') : '',
1562
2101
  cards: snapshot ? snapshot.cards : [],
1563
2102
  query,
1564
2103
  sessionId,
1565
2104
  turn,
1566
2105
  signal,
1567
2106
  fingerprint,
2107
+ controlEpoch,
2108
+ evolutionEpoch,
1568
2109
  at: Date.now(),
1569
2110
  };
1570
2111
  prepared.set(agent, state);
@@ -1586,8 +2127,19 @@ export function apply(ctx, config) {
1586
2127
 
1587
2128
  /* ---------------- 项目自进化:一次短整理,先扣频次,再走提交门禁 ---------------- */
1588
2129
 
1589
- /** 反馈只来自本轮真人消息;初始任务、工具成功、沉默都不算。 */
1590
- function rememberFeedback(scope, sessionId, messages, held) {
2130
+ /** 只清不属于当前这批人类消息的旧候选:新一轮开始后,上一轮没整理完的反馈不能再落盘。 */
2131
+ function dropStaleCandidate(sessionId, batchKey) {
2132
+ const prior = feedbackCandidates.get(sessionId);
2133
+ if (prior && prior.candidate && prior.candidate.batchKey !== batchKey) {
2134
+ feedbackCandidates.delete(sessionId);
2135
+ }
2136
+ }
2137
+
2138
+ /**
2139
+ * 反馈只来自本轮真人消息;初始任务、工具成功、沉默都不算。
2140
+ * 解析目标由整理器完成:不在这里暂停所有已注入规则,避免一次 BGM 纠正打掉开头和字幕。
2141
+ */
2142
+ function rememberFeedback(scope, sessionId, messages, held, turn) {
1591
2143
  let candidate;
1592
2144
  try {
1593
2145
  candidate = learning.candidateFrom({
@@ -1597,21 +2149,22 @@ export function apply(ctx, config) {
1597
2149
  if (!candidate) return null;
1598
2150
  candidate.cwd = scope.cwd;
1599
2151
  candidate.episodeUpdatedAt = held.episodeUpdatedAt || '';
1600
- let paused = false;
1601
- const injected = Array.isArray(held.injectedIds) ? held.injectedIds.filter(Boolean).slice(0, 8) : [];
1602
- if (candidate.cue.negative && injected.length) {
1603
- try {
1604
- const result = pauseLearningRules(scope, injected, {
1605
- by: 'feedback',
1606
- reason: 'feedback_pending',
1607
- operationId: `pause_${candidate.episodeId}_${candidate.messageIds.join('_')}`.slice(0, 120),
1608
- });
1609
- paused = Boolean(result && result.ok && result.changed);
1610
- } catch { paused = false; }
1611
- }
1612
- feedbackCandidates.set(sessionId, { scope, candidate, held, paused, at: Date.now() });
2152
+ candidate.batchKey = held.batchKey || '';
2153
+ candidate.turn = String(turn || '');
2154
+ const revision = (feedbackRevisions.get(scope.key) || 0) + 1;
2155
+ feedbackRevisions.set(scope.key, revision);
2156
+ candidate.feedbackRevision = revision;
2157
+ const prior = feedbackCandidates.get(sessionId);
2158
+ feedbackCandidates.set(sessionId, {
2159
+ scope,
2160
+ candidate,
2161
+ held,
2162
+ session: prior && prior.session ? prior.session : null,
2163
+ queued: Boolean(prior && prior.queued),
2164
+ at: Date.now(),
2165
+ });
1613
2166
  while (feedbackCandidates.size > 8) feedbackCandidates.delete(feedbackCandidates.keys().next().value);
1614
- return { candidate, paused };
2167
+ return { candidate };
1615
2168
  }
1616
2169
 
1617
2170
  /** 只认前台最近一次真实请求的路线;拿不到就宁可这次不整理。 */
@@ -1695,6 +2248,10 @@ export function apply(ctx, config) {
1695
2248
  else if (chunk && chunk.type === 'finish' && chunk.reason) finish = chunk.reason;
1696
2249
  }
1697
2250
  if (sawTool) throw new Error('整理返回了工具调用');
2251
+ /** 运行期间用户又给了新反馈:旧结果不能覆盖新纠正,直接作废,等排队的下一次整理。 */
2252
+ if ((feedbackRevisions.get(scope.key) || 0) !== candidate.feedbackRevision) {
2253
+ throw new Error('反馈已更新,这次整理作废');
2254
+ }
1698
2255
  const parsed = learning.parseOutput(text, { finish, refs: input.refs, rules: input.rules, ranges });
1699
2256
  if (!parsed.ok) throw new Error(parsed.reason);
1700
2257
  const commit = commitLearning(scope, {
@@ -1737,6 +2294,42 @@ export function apply(ctx, config) {
1737
2294
  }
1738
2295
  }
1739
2296
 
2297
+ /** 启动一次整理;结束后再看同项目是否还排着一条候选。 */
2298
+ function startOrganizer(scope, session, pending) {
2299
+ const job = { controller: learningAborts.get(scope.key) || null };
2300
+ learningJobs.set(scope.key, job);
2301
+ void organize(scope, session, pending)
2302
+ .catch(() => { /* 整理失败不影响会话 */ })
2303
+ .finally(() => {
2304
+ if (learningJobs.get(scope.key) === job) learningJobs.delete(scope.key);
2305
+ drainOrganizer(scope.key);
2306
+ });
2307
+ }
2308
+
2309
+ /** 彻底删除后中止该层在途整理并丢掉候选:不让旧结果复活已删规则。 */
2310
+ function revokeLearningUse(layerKey) {
2311
+ for (const [scopeKey, controller] of learningAborts) {
2312
+ if (layerKey !== 'global' && scopeKey !== layerKey) continue;
2313
+ try { controller.abort(); } catch { /* already settled */ }
2314
+ }
2315
+ for (const [sessionId, pending] of feedbackCandidates) {
2316
+ if (layerKey !== 'global' && pending?.scope?.key !== layerKey) continue;
2317
+ feedbackCandidates.delete(sessionId);
2318
+ }
2319
+ }
2320
+
2321
+ /** 忙碌时保留一条最新候选,不丢用户刚说的纠正;同一项目仍只跑一个整理。 */
2322
+ function drainOrganizer(scopeKey) {
2323
+ if (learningJobs.has(scopeKey)) return;
2324
+ for (const [sessionId, pending] of feedbackCandidates) {
2325
+ if (!pending || !pending.scope || pending.scope.key !== scopeKey) continue;
2326
+ feedbackCandidates.delete(sessionId);
2327
+ if (!pending.session) return;
2328
+ startOrganizer(pending.scope, pending.session, pending);
2329
+ return;
2330
+ }
2331
+ }
2332
+
1740
2333
  /** 只在正常完成的人类回合后调度,一次一个项目;永不阻塞事件派发。 */
1741
2334
  function scheduleOrganizer(session, event) {
1742
2335
  const reason = event && event.data ? event.data.reason : null;
@@ -1745,14 +2338,18 @@ export function apply(ctx, config) {
1745
2338
  if (!sessionId) return;
1746
2339
  const pending = feedbackCandidates.get(sessionId);
1747
2340
  if (!pending) return;
2341
+ const eventTurn = event.data && event.data.turn !== undefined ? String(event.data.turn) : '';
2342
+ if (eventTurn && pending.candidate && pending.candidate.turn && eventTurn !== pending.candidate.turn) {
2343
+ feedbackCandidates.delete(sessionId);
2344
+ return;
2345
+ }
2346
+ pending.session = session;
2347
+ if (learningJobs.has(pending.scope.key)) {
2348
+ pending.queued = true;
2349
+ return;
2350
+ }
1748
2351
  feedbackCandidates.delete(sessionId);
1749
- const scope = pending.scope;
1750
- if (learningJobs.has(scope.key)) return;
1751
- const job = { controller: learningAborts.get(scope.key) || null };
1752
- learningJobs.set(scope.key, job);
1753
- void organize(scope, session, pending)
1754
- .catch(() => { /* 整理失败不影响会话 */ })
1755
- .finally(() => { if (learningJobs.get(scope.key) === job) learningJobs.delete(scope.key); });
2352
+ startOrganizer(pending.scope, session, pending);
1756
2353
  }
1757
2354
 
1758
2355
  if (typeof ctx.on === 'function') ctx.on('agent/disposed', (payload) => {
@@ -1773,6 +2370,11 @@ export function apply(ctx, config) {
1773
2370
  if (!signal) return;
1774
2371
  const agent = signalAgents.get(signal);
1775
2372
  if (agent) clearPrepared(agent);
2373
+ /** 被取消或被中断的回合不保留反馈候选,下一句「谢谢」不能把没说完的纠正写进去。 */
2374
+ if (signal.aborted === true && agent) {
2375
+ const sessionId = String(agent?.session?.id ?? '');
2376
+ if (sessionId) feedbackCandidates.delete(sessionId);
2377
+ }
1776
2378
  signalAgents.delete(signal);
1777
2379
  });
1778
2380