dsh-lite-memory 0.11.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 {
@@ -366,7 +636,7 @@ const SAVE_PARAMS = {
366
636
  id: { type: 'string', description: '要更新的记忆 id(来自搜索结果或相近提示)。同一件事更新时用它,别另建一条' },
367
637
  title: { type: 'string', description: `标题:名词短语,用户一眼能认出是哪件事,不超过 ${LIMITS.titleChars} 字。它就是这条记忆的身份` },
368
638
  summary: { type: 'string', description: `一句话说清要记的内容本身——用户只看这一行决定留不留,名词短语,不超过 ${LIMITS.summaryChars} 字` },
369
- body: { type: 'string', description: `正文 markdown,整段替换旧正文,不超过 ${LIMITS.entryBytes} 字节。只写指针与要点,不抄录长文档` },
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,6 +1051,25 @@ export function apply(ctx, config) {
740
1051
  return text;
741
1052
  }
742
1053
 
1054
+ /** 本轮问题与栏目名的字面相关度:只展示有交集的栏目,交集为空就整段跳过。 */
1055
+ function relevantRanges(names, query) {
1056
+ const text = String(query || '').toLowerCase();
1057
+ if (!text) return [];
1058
+ const tokens = new Set();
1059
+ for (const word of text.match(/[a-z][a-z0-9_-]{2,}/g) || []) tokens.add(word);
1060
+ for (const run of text.match(/[\u4e00-\u9fff]+/g) || []) {
1061
+ for (let i = 0; i + 2 <= run.length; i += 1) tokens.add(run.slice(i, i + 2));
1062
+ }
1063
+ if (!tokens.size) return [];
1064
+ return (Array.isArray(names) ? names : []).filter((name) => {
1065
+ const value = String(name || '').toLowerCase();
1066
+ if (!value) return false;
1067
+ if (text.includes(value)) return true;
1068
+ for (const token of tokens) if (value.includes(token)) return true;
1069
+ return false;
1070
+ });
1071
+ }
1072
+
743
1073
  /** 读决策 → 一段短提示:未绑定只给栏目索引,已绑定才给规则正文。 */
744
1074
  function renderExperiment(scope, decision, context, held, win) {
745
1075
  if (!decision || decision.kind === 'none') return '';
@@ -747,9 +1077,10 @@ export function apply(ctx, config) {
747
1077
  return chargeExperiment(win, '这些实验规则互相冲突,先按用户这次说的做,别叠加对立的规则。');
748
1078
  }
749
1079
  if (decision.kind === 'index' || decision.kind === 'clarify') {
750
- const names = Array.isArray(decision.ranges) ? decision.ranges.filter(Boolean).slice(0, 3) : [];
1080
+ const names = relevantRanges(Array.isArray(decision.ranges) ? decision.ranges.filter(Boolean) : [], held && held.query).slice(0, 3);
1081
+ /** 没有适用栏目就什么也不注入:不拿无关范围冒充当前任务,也不催用户选栏目。 */
1082
+ if (!names.length) return '';
751
1083
  const tail = held && held.contextId ? `\ncontextId=${held.contextId}` : '';
752
- if (!names.length) return chargeExperiment(win, `先问用户这期做什么栏目,别自己猜。${tail}`);
753
1084
  return chargeExperiment(win, `${EXPERIMENT_INDEX_HEADER}\n${names.map((name) => `- ${name}`).join('\n')}${tail}`);
754
1085
  }
755
1086
  let index;
@@ -760,6 +1091,7 @@ export function apply(ctx, config) {
760
1091
  let conflict = false;
761
1092
  for (const record of index.entries) {
762
1093
  if (record.origin !== 'project_evolution') continue;
1094
+ if (record.ownerProjectId && record.ownerProjectId !== scope.key) continue;
763
1095
  if (record.reviewState === 'legacy_unreviewed') continue;
764
1096
  const learning = record.learning && typeof record.learning === 'object' ? record.learning : {};
765
1097
  if (learning.state !== 'active' && learning.state !== 'trial') continue;
@@ -781,12 +1113,13 @@ export function apply(ctx, config) {
781
1113
  const body = String(record.body || '');
782
1114
  const short = body && body.length <= EXPERIMENT_SHORT_CHARS && body.split('\n').length <= 4;
783
1115
  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)}`
1116
+ const checkpoint = record.learning && Array.isArray(record.learning.checkpoints) ? record.learning.checkpoints[0] : null;
1117
+ const check = checkpoint && checkpoint.text
1118
+ ? `;核对:${String(checkpoint.text).slice(0, 40)}`
786
1119
  : '';
787
1120
  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 取正文)`;
1121
+ ? `- 「${record.title}」${conditions ? `(${conditions})` : ''} · ${record.id}:${body}${check}`
1122
+ : `- 「${record.title}」 · ${record.id}(用途:${record.summary || record.title};要用时 memory_read 取正文)`;
790
1123
  const size = Buffer.byteLength(`\n${line}`);
791
1124
  if (used + size > EXPERIMENT_BYTES) break;
792
1125
  used += size;
@@ -840,9 +1173,11 @@ export function apply(ctx, config) {
840
1173
  });
841
1174
  const contextId = anchor.contextIdFor(scope.key, batchKey, crypto.randomUUID().slice(0, 8));
842
1175
  const episode = routed.tasks && routed.tasks.episodes ? routed.tasks.episodes[routed.anchor.episodeId] : null;
1176
+ let epoch = '';
1177
+ try { epoch = String(evolutionStatus(scope).epoch || ''); } catch { epoch = ''; }
843
1178
  const held = {
844
- contextId, projectKey: scope.key, sessionId, batchKey, messageIds,
845
- context, anchor: routed.anchor, ranges, decision,
1179
+ contextId, projectKey: scope.key, sessionId, batchKey, messageIds, query,
1180
+ context, anchor: routed.anchor, ranges, decision, epoch,
846
1181
  episodeUpdatedAt: episode ? episode.updatedAt : '', injectedIds: [],
847
1182
  at: Date.now(),
848
1183
  };
@@ -866,26 +1201,47 @@ export function apply(ctx, config) {
866
1201
  if (!win) return { ok: false, status: 'needs_context', reason: '这个请求的上下文已经过期,先重新问一次当前任务' };
867
1202
  const held = win.contexts.get(contextId);
868
1203
  if (!held) return { ok: false, status: 'needs_context', reason: '这个上下文已经过期,先重新问一次当前任务' };
1204
+ /** 凭据只在当轮、当次开关代次内有效:换轮、关闭、重开或过期都不再放行旧正文。 */
1205
+ const expired = (reason) => {
1206
+ win.contexts.delete(contextId);
1207
+ if (win.experiment && win.experiment.contextId === contextId) win.experiment = null;
1208
+ return { ok: false, status: 'needs_context', reason };
1209
+ };
1210
+ if (held.projectKey !== scope.key) return expired('这个上下文属于另一个项目');
1211
+ if (held.sessionId && held.sessionId !== String(win.sessionId || '')) return expired('这个上下文已经过期,先重新问一次当前任务');
1212
+ if (!held.batchKey || held.batchKey !== anchor.requestBatchKey(win.messageIds || [])) {
1213
+ return expired('这个上下文已经过期,先重新问一次当前任务');
1214
+ }
1215
+ let evolution;
1216
+ try { evolution = evolutionStatus(scope); } catch { return expired('当前项目状态读不出来,先重新问一次当前任务'); }
1217
+ if (!evolution || evolution.enabled !== true || evolution.compatible === false) {
1218
+ return expired('项目自进化已经关闭,先重新问一次当前任务');
1219
+ }
1220
+ if (held.epoch && evolution.epoch !== held.epoch) {
1221
+ return expired('项目自进化重新开过,先重新问一次当前任务');
1222
+ }
869
1223
  return { ok: true, scope, win, context: held.context, experimental: true, held, contextId };
870
1224
  }
871
1225
 
872
- /** 工具模式没有自动索引时,按本轮真人消息现开一个请求上下文,仍要前台点名栏目才绑定。 */
1226
+ /** 工具模式没有自动索引时,按本轮真人消息现开一个请求上下文,只按问题与记忆范围的字面关系判断。 */
873
1227
  function toolContextFor(execution) {
874
1228
  const win = windowOf(execution && execution.agent);
875
1229
  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
1230
  const scope = scopeOf(cwdOf(execution));
880
1231
  if (scope.confirmed !== true) return '';
1232
+ let status;
1233
+ try { status = evolutionStatus(scope); } catch { return ''; }
1234
+ if (!status || status.enabled !== true || status.compatible === false) return '';
881
1235
  const batchKey = anchor.requestBatchKey(win.messageIds);
882
- const held = win.experiment && win.experiment.batchKey === batchKey ? win.experiment : null;
1236
+ const held = win.experiment && win.experiment.batchKey === batchKey && win.experiment.epoch === status.epoch
1237
+ ? win.experiment
1238
+ : null;
883
1239
  if (held) return held.contextId;
884
1240
  const contextId = anchor.contextIdFor(scope.key, batchKey, crypto.randomUUID().slice(0, 8));
885
1241
  const created = {
886
1242
  contextId, projectKey: scope.key, sessionId: win.sessionId, batchKey,
887
1243
  messageIds: win.messageIds.slice(), context: view.trustedContext(scope, {}),
888
- anchor: null, ranges: rangesOf(scope), decision: { kind: 'none' }, at: Date.now(),
1244
+ anchor: null, ranges: rangesOf(scope), decision: { kind: 'none' }, epoch: status.epoch, at: Date.now(),
889
1245
  };
890
1246
  win.experiment = created;
891
1247
  win.contexts.set(contextId, created);
@@ -901,7 +1257,7 @@ export function apply(ctx, config) {
901
1257
  for (const value of ranges.taskKinds || []) if (query.includes(value)) hits.push({ taskKind: value });
902
1258
  if (hits.length !== 1) {
903
1259
  const names = [...(ranges.series || []), ...(ranges.taskKinds || [])].slice(0, 3);
904
- return { ok: false, status: 'needs_context', reason: names.length ? `先在问题里点名一个栏目:${names.join('、')}` : '先说要做的栏目' };
1260
+ return { ok: false, status: 'needs_context', reason: names.length ? '本轮没有匹配的记忆范围,按当前任务继续' : '本轮没有适用的记忆范围,按当前任务继续' };
905
1261
  }
906
1262
  let tasks;
907
1263
  try { tasks = taskState(resolved.scope); } catch { return { ok: false, status: 'needs_context', reason: '任务状态还没读出来' }; }
@@ -936,6 +1292,11 @@ export function apply(ctx, config) {
936
1292
  if (state.signal && signal && state.signal !== signal) return '';
937
1293
  const sessionId = agent?.session?.id;
938
1294
  if (state.sessionId !== (typeof sessionId === 'string' ? sessionId : '')) return '';
1295
+ /** 开关/自进化换代后,关掉前准备好的正文一律不再输出。 */
1296
+ const scope = scopeOf(cwdOf({ agent }));
1297
+ if (!memoryEnabled(scope)) return '';
1298
+ const epoch = controlStatus(scope).epoch || '';
1299
+ if (state.controlEpoch && epoch && state.controlEpoch !== epoch) return '';
939
1300
  return [state.experimentText, state.text].filter(Boolean).join('\n\n');
940
1301
  } catch {
941
1302
  return '';
@@ -945,7 +1306,7 @@ export function apply(ctx, config) {
945
1306
 
946
1307
  ctx.effect(() => tools.register({
947
1308
  name: 'memory_save',
948
- description: `保存一条记忆。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。更新同一件事时带 id:身份只看标题、旧标题和前置条件,像不像不授权覆盖。文本相近但标题不同时先不写,返回 needsResolution 与 1–2 条相近条目:确实是同一条就用那个 id 更新,确实是另一条才带 resolution="distinct" 再存。带 id 更新时没给的字段保持原值,正文整段替换,旧版本留在 versions 里(每字都留,不截断)。条数到硬上限时把同层最久没被读的一条移进归档,结果里写明动了哪条,不拒绝新记忆;单条正文超限只报错、不截断。前置条件不同的同一标题是两条。${NOTES}`,
1309
+ description: `保存一条记忆。只在会改变以后判断时才存;先搜已有条目,能更新就不要新建。保存前先蒸馏:标题是结论,正文只写原则与触发条件,不抄原始输出里的具体数值。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。更新同一件事时带 id:身份只看标题、旧标题和前置条件,像不像不授权覆盖。文本相近但标题不同时先不写,返回 needsResolution 与 1–2 条相近条目:确实是同一条就用那个 id 更新,确实是另一条才带 resolution="distinct" 再存。带 id 更新时没给的字段保持原值,正文整段替换,旧正文只留作内部撤回,不展示也不参与检索。条数到硬上限时把同层最久没被读的一条移进归档,结果里写明动了哪条,不拒绝新记忆;单条正文超限只报错、不截断。前置条件不同的同一标题是两条。${NOTES}`,
949
1310
  parameters: SAVE_PARAMS,
950
1311
  output: {
951
1312
  presentationMeta: (_args, value) => ({
@@ -971,6 +1332,7 @@ export function apply(ctx, config) {
971
1332
  merged: { type: 'boolean' },
972
1333
  changed: { type: 'boolean' },
973
1334
  scope: { type: 'string' },
1335
+ scopeName: { type: 'string' },
974
1336
  needsResolution: { type: 'boolean' },
975
1337
  needsScope: { type: 'boolean' },
976
1338
  reason: { type: 'string' },
@@ -988,20 +1350,30 @@ export function apply(ctx, config) {
988
1350
  render: (_args, value) => [{
989
1351
  type: 'text',
990
1352
  text: value.needsScope
991
- ? value.reason === 'unconfirmed_project'
1353
+ ? value.reason === 'memory_disabled'
1354
+ ? 'memory · 本项目记忆已关闭,先在面板打开'
1355
+ : value.reason === 'unconfirmed_project'
992
1356
  ? `memory · 当前目录还不能确认属于哪个项目:「${value.title}」\n先确认项目根目录,或显式 scope="global"`
993
1357
  : `memory · 归属还没定:「${value.title}」\n补 memoryClass(personal_preference / project_fact / environment_fact / portable_procedure)或显式 scope;设备事实还要给设备条件`
994
1358
  : value.existingId
995
- ? `memory · 已有相同记忆:「${value.title}」 · ${value.existingId}(${value.existingScope})第 ${value.revision} 版\n要改成另一条,加 resolution="distinct"`
1359
+ ? `memory · 已有相同记忆:「${value.title}」 · ${value.existingId}(${value.existingScope})\n要改成另一条,加 resolution="distinct"`
996
1360
  : value.needsResolution
997
1361
  ? `memory · 需要确认:「${value.title}」与已有条目相近${value.crossLayer ? '(在另一层)' : ''}${value.sameTopic ? '(同一主题槽位)' : ''}\n${value.similar}\n要更新它,用 id 重存;确实是另一条,加 resolution="distinct"`
998
1362
  : 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}」腾位` : ''}`,
1363
+ ? `memory · unchanged ${withTag(value, `"${value.title}"`)} · ${value.id}`
1364
+ : `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''} · ${value.id}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}`,
1001
1365
  }],
1002
1366
  },
1003
1367
  execute: async (args, execution) => {
1004
1368
  const projectScope = scopeOf(cwdOf(execution));
1369
+ const projectScopeName = projectNameOf(projectScope);
1370
+ if (!memoryEnabled(projectScope)) {
1371
+ return {
1372
+ id: '', title: String(args.title || ''), summary: '', when: '', revision: 0,
1373
+ merged: false, changed: false, scope: '', needsScope: true, reason: 'memory_disabled',
1374
+ needsResolution: false, similarIds: '', similar: '', archived: '',
1375
+ };
1376
+ }
1005
1377
  const saved = saveEntry(projectScope, { ...args, writeMode: args.mode || 'patch' });
1006
1378
  if (saved.needsScope) {
1007
1379
  return {
@@ -1031,6 +1403,7 @@ export function apply(ctx, config) {
1031
1403
  merged: false,
1032
1404
  changed: false,
1033
1405
  scope: saved.existing.scope,
1406
+ scopeName: saved.existing.scope === 'global' ? '' : projectScopeName,
1034
1407
  needsScope: false,
1035
1408
  existingId: saved.existing.id,
1036
1409
  existingScope: saved.existing.scope,
@@ -1070,6 +1443,7 @@ export function apply(ctx, config) {
1070
1443
  merged: saved.merged,
1071
1444
  changed: !saved.noop,
1072
1445
  scope: scope.key === 'global' ? 'global' : 'project',
1446
+ scopeName: scope.key === 'global' ? '' : projectScopeName,
1073
1447
  needsScope: false,
1074
1448
  needsResolution: false,
1075
1449
  similarIds: saved.similarList.map((item) => item.id).join('、'),
@@ -1107,6 +1481,7 @@ export function apply(ctx, config) {
1107
1481
  },
1108
1482
  execute: async (args, execution) => {
1109
1483
  const scope = scopeOf(cwdOf(execution));
1484
+ if (!memoryEnabled(scope)) return { asked: false, reason: '本项目记忆已关闭' };
1110
1485
  const gate = suggestionGate(scope, args);
1111
1486
  if (!gate.ok) return { asked: false, reason: gate.reason };
1112
1487
  const noted = noteSuggestion(scope, gate.target ? { ...args, targetId: gate.target.id } : args);
@@ -1121,7 +1496,7 @@ export function apply(ctx, config) {
1121
1496
 
1122
1497
  ctx.effect(() => tools.register({
1123
1498
  name: 'memory_search',
1124
- description: `按需检索已有记忆:先搜,命中再 memory_read 读正文。用户提到「记住了什么 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」,或者眼前反复出现的报错、弹窗、异常时,先查这里有没有记过解法。跨项目的通用条目以 [全局] 开头。中文长句可以直接当查询。每行带 id 与版次,更新时把 id 传回 memory_save。默认只列当前有效的;已经归档或被新版本替换的旧说法不混进来,要查历史就在参数里显式要 history。`,
1499
+ description: `按需检索已有记忆:先搜,命中再 memory_read 读正文。用户提到「记住了什么 / 上次 / 规范 / 约定 / 路径 / 凭据怎么取」,或者眼前反复出现的报错、弹窗、异常时,先查这里有没有记过解法。跨项目的通用条目以 [global] 开头,项目条目以 [项目名] 开头。中文长句可以直接当查询。每行带 id,更新时把 id 传回 memory_save。默认只列当前有效的;已经归档或被更新替换的旧说法不混进来,要查历史就在参数里显式要 history。`,
1125
1500
  parameters: SEARCH_PARAMS,
1126
1501
  output: {
1127
1502
  presentationMeta: (_args, value) => ({ kind: 'memory-search', count: value.count, status: value.status || 'ok', refs: value.refs || [] }),
@@ -1142,8 +1517,9 @@ export function apply(ctx, config) {
1142
1517
  scope: { type: 'string' },
1143
1518
  revision: { type: 'integer' },
1144
1519
  status: { type: 'string' },
1520
+ origin: { type: 'string' },
1145
1521
  },
1146
- required: ['id', 'title', 'scope', 'revision', 'status'],
1522
+ required: ['id', 'title', 'scope', 'revision', 'status', 'origin'],
1147
1523
  additionalProperties: false,
1148
1524
  },
1149
1525
  },
@@ -1165,7 +1541,7 @@ export function apply(ctx, config) {
1165
1541
  }],
1166
1542
  },
1167
1543
  execute: async (args, execution) => {
1168
- if (recallMode() === 'off') return { count: 0, chars: 0, text: '', status: 'memory_disabled', refs: [] };
1544
+ if (recallMode() === 'off' || !memoryEnabledFor(execution)) return { count: 0, chars: 0, text: '', status: 'memory_disabled', refs: [] };
1169
1545
  const agent = execution?.agent;
1170
1546
  const win = windowOf(agent);
1171
1547
  if (win && !affordable(win, 0)) return { count: 0, chars: 0, text: '', status: 'budget_exhausted', refs: [] };
@@ -1182,12 +1558,19 @@ export function apply(ctx, config) {
1182
1558
  }
1183
1559
  const scope = resolved.scope;
1184
1560
  const context = resolved.context;
1185
- const hits = search(scope, args.query, args.limit ?? 3, {
1561
+ /** 检索只列当前任务真能用的条目;条件未知或不适用就只留状态,不给索引行。 */
1562
+ const hits = search(scope, args.query, (args.limit ?? 3) + 3, {
1186
1563
  context,
1187
1564
  history: args.history === true,
1188
1565
  allowExperimental: resolved.experimental === true,
1189
- });
1190
- const shown = hits.map((item) => view.toModelView(item, 'search', { status: item.status || (item.archived ? 'archived' : 'ok') }));
1566
+ }).filter((item) => (item.archived || item.status === 'ok')
1567
+ && (!item.ownerProjectId || item.ownerProjectId === scope.key))
1568
+ .slice(0, args.limit ?? 3);
1569
+ const scopeName = scope.key === 'global' ? '' : projectNameOf(scope);
1570
+ const shown = hits.map((item) => ({
1571
+ ...view.toModelView(item, 'search', { status: item.status || (item.archived ? 'archived' : 'ok') }),
1572
+ scopeName: item.scope === 'global' ? '' : scopeName,
1573
+ }));
1191
1574
  const valueOf = (kept) => {
1192
1575
  const rendered = renderIndex(shown.slice(0, kept));
1193
1576
  return { count: kept, chars: rendered.chars, text: rendered.text, status: 'ok', refs: refsOf(hits.slice(0, kept)) };
@@ -1232,6 +1615,7 @@ export function apply(ctx, config) {
1232
1615
  when: { type: 'string' },
1233
1616
  revision: { type: 'integer' },
1234
1617
  scope: { type: 'string' },
1618
+ scopeName: { type: 'string' },
1235
1619
  origin: { type: 'string', enum: ['user_explicit', 'project_evolution'] },
1236
1620
  status: { type: 'string', enum: ['ok', 'archived', 'needs_context', 'not_applicable', 'gone', 'budget_exhausted', 'memory_disabled'] },
1237
1621
  body: { type: 'string' },
@@ -1248,9 +1632,9 @@ export function apply(ctx, config) {
1248
1632
  : value.status === 'memory_disabled'
1249
1633
  ? 'memory · 记忆检索已关闭'
1250
1634
  : value.repeat
1251
- ? `memory · 本请求已读过「${value.title}」 · ${value.id} · 第 ${value.revision} 版`
1635
+ ? `memory · 本请求已读过「${value.title}」 · ${value.id}`
1252
1636
  : value.body
1253
- ? `memory · 「${value.title}」${value.when ? `(${value.when})` : ''} · ${value.id} · 第 ${value.revision} 版${scopeLabel(value)}${statusLabel(value.status)}\n${value.body}`
1637
+ ? `memory · ${withTag(value, `「${value.title}」`)}${value.when ? `(${value.when})` : ''} · ${value.id}${statusLabel(value.status)}\n${value.body}`
1254
1638
  : `memory · 「${value.title}」的相关记忆\n${value.links || '(无)'}`,
1255
1639
  }],
1256
1640
  presentationMeta: (_args, value) => ({
@@ -1264,10 +1648,11 @@ export function apply(ctx, config) {
1264
1648
  }),
1265
1649
  },
1266
1650
  execute: async (args, execution) => {
1267
- if (recallMode() === 'off') return { id: '', scope: '', status: 'memory_disabled' };
1651
+ if (recallMode() === 'off' || !memoryEnabledFor(execution)) return { id: '', scope: '', status: 'memory_disabled' };
1268
1652
  const resolved = resolveRequestContext(execution, args.contextId);
1269
1653
  if (!resolved.ok) return { id: '', scope: '', status: resolved.status || 'needs_context' };
1270
1654
  const scope = resolved.scope;
1655
+ const scopeName = scope.key === 'global' ? '' : projectNameOf(scope);
1271
1656
  const context = resolved.context;
1272
1657
  const allowExperimental = resolved.experimental === true;
1273
1658
  const agent = execution?.agent;
@@ -1275,11 +1660,22 @@ export function apply(ctx, config) {
1275
1660
  if (args.view === 'links') {
1276
1661
  const found = readLinks(scope, args.id, { context, allowExperimental });
1277
1662
  if (!found) throw new Error(`没有 ${args.id} 这条记忆`);
1278
- const rows = found.links.map((link) => (link.status === 'invalid'
1663
+ if (found.status !== 'ok') {
1664
+ const withheld = { id: found.id, scope: found.scope, status: found.status, links: '' };
1665
+ if (win) {
1666
+ const bytes = jsonBytes(withheld);
1667
+ if (!affordable(win, bytes)) return { id: '', scope: '', status: 'budget_exhausted' };
1668
+ win.bytes += bytes;
1669
+ win.reads += 1;
1670
+ }
1671
+ return withheld;
1672
+ }
1673
+ const rows = found.links.filter((link) => link.status === 'invalid' || link.status === 'ok')
1674
+ .map((link) => (link.status === 'invalid'
1279
1675
  ? `- ${link.id} · 已失效`
1280
- : `- 「${link.title}」${link.scope === 'global' ? ' [全局]' : ''} · ${link.id} · 第 ${link.revision} 版`));
1676
+ : `- ${link.scope === 'global' ? '[global] ' : (scopeName ? `[${scopeName}] ` : '')}「${link.title}」 · ${link.id}`));
1281
1677
  const text = rows.join('\n') || '(无)';
1282
- const value = { id: found.id, scope: found.scope, status: found.status, links: text };
1678
+ const value = { id: found.id, scope: found.scope, scopeName, status: found.status, links: text };
1283
1679
  if (win) {
1284
1680
  const bytes = jsonBytes(value);
1285
1681
  if (!affordable(win, bytes)) return { id: '', scope: '', status: 'budget_exhausted' };
@@ -1290,15 +1686,22 @@ export function apply(ctx, config) {
1290
1686
  }
1291
1687
  const record = readEntry(scope, args.id, { context, allowExperimental });
1292
1688
  if (!record) throw new Error(`没有 ${args.id} 这条记忆`);
1689
+ if (record.origin === 'project_evolution' && record.ownerProjectId && record.ownerProjectId !== scope.key) {
1690
+ return { id: args.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: 'not_applicable', body: '' };
1691
+ }
1293
1692
  if (record.origin === 'project_evolution' && !allowExperimental) {
1294
1693
  return { id: record.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: 'needs_context', body: '' };
1295
1694
  }
1296
- /** 暂停或条件不匹配的实验条目只给状态,不给正文。 */
1695
+ /** 暂停、过期或条件不匹配的实验条目只给状态,不给正文。 */
1696
+ if (record.origin === 'project_evolution' && !view.experimentalUsable(record)) {
1697
+ return { id: record.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: 'needs_context', body: '' };
1698
+ }
1297
1699
  const learningState = record.learning && typeof record.learning === 'object' ? record.learning : {};
1298
1700
  if (record.origin === 'project_evolution' && (learningState.state === 'paused' || learningState.blockedReason)) {
1299
1701
  return { id: record.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: 'needs_context', body: '' };
1300
1702
  }
1301
- if (record.origin === 'project_evolution' && record.status !== 'ok') {
1703
+ /** 条件未知或不适用的条目只报状态:正文绝不能绕过自动召回的门槛。 */
1704
+ if (record.status !== 'ok') {
1302
1705
  return { id: record.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: record.status === 'needs_context' ? 'needs_context' : 'not_applicable', body: '' };
1303
1706
  }
1304
1707
  const revision = record.revision || 1;
@@ -1309,6 +1712,7 @@ export function apply(ctx, config) {
1309
1712
  when: record.when || '',
1310
1713
  revision,
1311
1714
  scope: record.scope,
1715
+ scopeName,
1312
1716
  status: record.status,
1313
1717
  body,
1314
1718
  };
@@ -1346,20 +1750,23 @@ export function apply(ctx, config) {
1346
1750
  id: { type: 'string' },
1347
1751
  title: { type: 'string' },
1348
1752
  scope: { type: 'string' },
1349
- mode: { type: 'string', enum: ['archive', 'purged', 'needs_resolution', 'purge_pending'] },
1753
+ scopeName: { type: 'string' },
1754
+ mode: { type: 'string', enum: ['archive', 'purged', 'needs_resolution', 'purge_pending', 'memory_disabled'] },
1350
1755
  },
1351
1756
  required: ['id', 'mode'],
1352
1757
  additionalProperties: false,
1353
1758
  },
1354
1759
  render: (_args, value) => [{
1355
1760
  type: 'text',
1356
- text: value.mode === 'purged'
1761
+ text: value.mode === 'memory_disabled'
1762
+ ? 'memory · 本项目记忆已关闭,先在面板打开'
1763
+ : value.mode === 'purged'
1357
1764
  ? `memory · forgotten "${value.title}" · ${value.id}`
1358
1765
  : value.mode === 'purge_pending'
1359
1766
  ? `memory · 清除未完成 · ${value.id},重启后会继续`
1360
1767
  : value.mode === 'needs_resolution'
1361
1768
  ? `memory · 先确认是归档还是彻底忘掉 · ${value.id}`
1362
- : `memory · archived${scopeLabel(value)} "${value.title}" · ${value.id},可找回`,
1769
+ : `memory · archived ${withTag(value, `"${value.title}"`)} · ${value.id},可找回`,
1363
1770
  }],
1364
1771
  presentationMeta: (_args, value) => ({
1365
1772
  kind: 'memory-forget',
@@ -1371,6 +1778,8 @@ export function apply(ctx, config) {
1371
1778
  },
1372
1779
  execute: async (args, execution) => {
1373
1780
  const scope = scopeOf(cwdOf(execution));
1781
+ const projectScopeName = projectNameOf(scope);
1782
+ if (!memoryEnabled(scope)) return { id: String(args.id || ''), title: '', scope: '', mode: 'memory_disabled' };
1374
1783
  const located = locate(scope, args.id);
1375
1784
  if (!located && !args.mode) throw new Error(`没有 ${args.id} 这条记忆`);
1376
1785
  if (!args.mode) {
@@ -1392,6 +1801,7 @@ export function apply(ctx, config) {
1392
1801
  title: located ? located.record.title : '',
1393
1802
  mode: 'purge_pending',
1394
1803
  scope: layer.key === 'global' ? 'global' : 'project',
1804
+ scopeName: layer.key === 'global' ? '' : projectScopeName,
1395
1805
  };
1396
1806
  }
1397
1807
  throw again;
@@ -1402,11 +1812,13 @@ export function apply(ctx, config) {
1402
1812
  }
1403
1813
  if (!dropped) throw new Error(`没有 ${args.id} 这条记忆`);
1404
1814
  const layerKey = dropped.scopeKey || (located ? located.scope.key : scope.key);
1815
+ if (dropped.mode === 'purged') revokeLearningUse(layerKey);
1405
1816
  return {
1406
1817
  id: dropped.id,
1407
1818
  title: dropped.title || (located ? located.record.title : ''),
1408
1819
  mode: dropped.mode,
1409
1820
  scope: layerKey === 'global' ? 'global' : 'project',
1821
+ scopeName: layerKey === 'global' ? '' : projectScopeName,
1410
1822
  };
1411
1823
  },
1412
1824
  }), 'lite-memory: memory_forget');
@@ -1445,9 +1857,23 @@ export function apply(ctx, config) {
1445
1857
  }), 'lite-memory: recall context');
1446
1858
  }
1447
1859
 
1860
+ /** 执行入口再拦一次:工具若已出现在旧快照里,调用时也不放行。 */
1861
+ if (typeof ctx.on === 'function') ctx.on('agent/created', (payload) => {
1862
+ const agent = payload?.agent;
1863
+ if (!agent?.ctx?.tools) return;
1864
+ try {
1865
+ agent.ctx.effect(() => agent.ctx.tools.guard((execution) => {
1866
+ if (!MEMORY_TOOLS.includes(execution?.name)) return undefined;
1867
+ return memoryOnForAgent(agent) ? undefined : '本项目记忆已关闭,先在面板打开';
1868
+ }), 'lite-memory: tool guard');
1869
+ } catch { /* older host without guards */ }
1870
+ syncMemoryTools(agent);
1871
+ });
1872
+
1448
1873
  if (typeof ctx.on === 'function') ctx.on('agent/pre-step', async (payload, next) => {
1449
1874
  const startedAt = Date.now();
1450
1875
  const agent = payload?.agent;
1876
+ syncMemoryTools(agent);
1451
1877
  let decision;
1452
1878
  try {
1453
1879
  decision = await next();
@@ -1488,6 +1914,10 @@ export function apply(ctx, config) {
1488
1914
  }
1489
1915
  previous.signal = signal;
1490
1916
  const scope = scopeOf(cwdOf({ agent }));
1917
+ if (!memoryEnabled(scope)) {
1918
+ clearPrepared(agent);
1919
+ return decision;
1920
+ }
1491
1921
  const fingerprint = fingerprintOf(scope);
1492
1922
  /** 指纹拿不到就当作已失效:不拿旧任务内容硬撑这一步。 */
1493
1923
  if (!fingerprint) {
@@ -1535,24 +1965,33 @@ export function apply(ctx, config) {
1535
1965
  return decision;
1536
1966
  }
1537
1967
  const scope = scopeOf(cwd);
1968
+ if (!memoryEnabled(scope)) {
1969
+ clearPrepared(agent);
1970
+ return decision;
1971
+ }
1538
1972
  const context = view.trustedContext(scope, {});
1539
1973
  const fingerprint = fingerprintOf(scope);
1540
- const experiment = experimentFor(scope, { sessionId, query, batchKey: anchor.requestBatchKey(ids), messageIds: ids, win });
1974
+ const controlEpoch = controlStatus(scope).epoch || '';
1975
+ const batchKey = anchor.requestBatchKey(ids);
1976
+ dropStaleCandidate(sessionId, batchKey);
1977
+ const experiment = experimentFor(scope, { sessionId, query, batchKey, messageIds: ids, win });
1541
1978
  const remembered = experiment && experiment.held
1542
- ? rememberFeedback(scope, sessionId, fresh, experiment.held)
1979
+ ? rememberFeedback(scope, sessionId, fresh, experiment.held, turn)
1543
1980
  : null;
1544
- if (remembered && remembered.paused) {
1981
+ const snapshot = projectRecall(scope, query, context, win);
1982
+ if (generation !== generations.get(agent)) return decision;
1983
+ /** 投影期间开关被改过:丢弃这批内容,不把关闭前的记忆交给当前请求。 */
1984
+ if (!memoryEnabled(scope) || (controlStatus(scope).epoch || '') !== controlEpoch) {
1545
1985
  clearPrepared(agent);
1546
1986
  return decision;
1547
1987
  }
1548
- const snapshot = projectRecall(scope, query, context, win);
1549
- if (generation !== generations.get(agent)) return decision;
1550
1988
  /** 超时或被取消:这一步不留半旧卡片,下一真人请求重新投影。 */
1551
1989
  if (signal?.aborted || Date.now() - startedAt > RECALL_DEADLINE_MS) {
1552
1990
  clearPrepared(agent);
1553
1991
  return decision;
1554
1992
  }
1555
- if (!snapshot && !(experiment && experiment.text)) {
1993
+ /** 实验上下文即使本轮零注入也要留着:工具绑定后同一请求还能补上规则。 */
1994
+ if (!snapshot && !experiment) {
1556
1995
  clearPrepared(agent);
1557
1996
  return decision;
1558
1997
  }
@@ -1565,6 +2004,7 @@ export function apply(ctx, config) {
1565
2004
  turn,
1566
2005
  signal,
1567
2006
  fingerprint,
2007
+ controlEpoch,
1568
2008
  at: Date.now(),
1569
2009
  };
1570
2010
  prepared.set(agent, state);
@@ -1586,8 +2026,19 @@ export function apply(ctx, config) {
1586
2026
 
1587
2027
  /* ---------------- 项目自进化:一次短整理,先扣频次,再走提交门禁 ---------------- */
1588
2028
 
1589
- /** 反馈只来自本轮真人消息;初始任务、工具成功、沉默都不算。 */
1590
- function rememberFeedback(scope, sessionId, messages, held) {
2029
+ /** 只清不属于当前这批人类消息的旧候选:新一轮开始后,上一轮没整理完的反馈不能再落盘。 */
2030
+ function dropStaleCandidate(sessionId, batchKey) {
2031
+ const prior = feedbackCandidates.get(sessionId);
2032
+ if (prior && prior.candidate && prior.candidate.batchKey !== batchKey) {
2033
+ feedbackCandidates.delete(sessionId);
2034
+ }
2035
+ }
2036
+
2037
+ /**
2038
+ * 反馈只来自本轮真人消息;初始任务、工具成功、沉默都不算。
2039
+ * 解析目标由整理器完成:不在这里暂停所有已注入规则,避免一次 BGM 纠正打掉开头和字幕。
2040
+ */
2041
+ function rememberFeedback(scope, sessionId, messages, held, turn) {
1591
2042
  let candidate;
1592
2043
  try {
1593
2044
  candidate = learning.candidateFrom({
@@ -1597,21 +2048,22 @@ export function apply(ctx, config) {
1597
2048
  if (!candidate) return null;
1598
2049
  candidate.cwd = scope.cwd;
1599
2050
  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() });
2051
+ candidate.batchKey = held.batchKey || '';
2052
+ candidate.turn = String(turn || '');
2053
+ const revision = (feedbackRevisions.get(scope.key) || 0) + 1;
2054
+ feedbackRevisions.set(scope.key, revision);
2055
+ candidate.feedbackRevision = revision;
2056
+ const prior = feedbackCandidates.get(sessionId);
2057
+ feedbackCandidates.set(sessionId, {
2058
+ scope,
2059
+ candidate,
2060
+ held,
2061
+ session: prior && prior.session ? prior.session : null,
2062
+ queued: Boolean(prior && prior.queued),
2063
+ at: Date.now(),
2064
+ });
1613
2065
  while (feedbackCandidates.size > 8) feedbackCandidates.delete(feedbackCandidates.keys().next().value);
1614
- return { candidate, paused };
2066
+ return { candidate };
1615
2067
  }
1616
2068
 
1617
2069
  /** 只认前台最近一次真实请求的路线;拿不到就宁可这次不整理。 */
@@ -1695,6 +2147,10 @@ export function apply(ctx, config) {
1695
2147
  else if (chunk && chunk.type === 'finish' && chunk.reason) finish = chunk.reason;
1696
2148
  }
1697
2149
  if (sawTool) throw new Error('整理返回了工具调用');
2150
+ /** 运行期间用户又给了新反馈:旧结果不能覆盖新纠正,直接作废,等排队的下一次整理。 */
2151
+ if ((feedbackRevisions.get(scope.key) || 0) !== candidate.feedbackRevision) {
2152
+ throw new Error('反馈已更新,这次整理作废');
2153
+ }
1698
2154
  const parsed = learning.parseOutput(text, { finish, refs: input.refs, rules: input.rules, ranges });
1699
2155
  if (!parsed.ok) throw new Error(parsed.reason);
1700
2156
  const commit = commitLearning(scope, {
@@ -1737,6 +2193,42 @@ export function apply(ctx, config) {
1737
2193
  }
1738
2194
  }
1739
2195
 
2196
+ /** 启动一次整理;结束后再看同项目是否还排着一条候选。 */
2197
+ function startOrganizer(scope, session, pending) {
2198
+ const job = { controller: learningAborts.get(scope.key) || null };
2199
+ learningJobs.set(scope.key, job);
2200
+ void organize(scope, session, pending)
2201
+ .catch(() => { /* 整理失败不影响会话 */ })
2202
+ .finally(() => {
2203
+ if (learningJobs.get(scope.key) === job) learningJobs.delete(scope.key);
2204
+ drainOrganizer(scope.key);
2205
+ });
2206
+ }
2207
+
2208
+ /** 彻底删除后中止该层在途整理并丢掉候选:不让旧结果复活已删规则。 */
2209
+ function revokeLearningUse(layerKey) {
2210
+ for (const [scopeKey, controller] of learningAborts) {
2211
+ if (layerKey !== 'global' && scopeKey !== layerKey) continue;
2212
+ try { controller.abort(); } catch { /* already settled */ }
2213
+ }
2214
+ for (const [sessionId, pending] of feedbackCandidates) {
2215
+ if (layerKey !== 'global' && pending?.scope?.key !== layerKey) continue;
2216
+ feedbackCandidates.delete(sessionId);
2217
+ }
2218
+ }
2219
+
2220
+ /** 忙碌时保留一条最新候选,不丢用户刚说的纠正;同一项目仍只跑一个整理。 */
2221
+ function drainOrganizer(scopeKey) {
2222
+ if (learningJobs.has(scopeKey)) return;
2223
+ for (const [sessionId, pending] of feedbackCandidates) {
2224
+ if (!pending || !pending.scope || pending.scope.key !== scopeKey) continue;
2225
+ feedbackCandidates.delete(sessionId);
2226
+ if (!pending.session) return;
2227
+ startOrganizer(pending.scope, pending.session, pending);
2228
+ return;
2229
+ }
2230
+ }
2231
+
1740
2232
  /** 只在正常完成的人类回合后调度,一次一个项目;永不阻塞事件派发。 */
1741
2233
  function scheduleOrganizer(session, event) {
1742
2234
  const reason = event && event.data ? event.data.reason : null;
@@ -1745,14 +2237,18 @@ export function apply(ctx, config) {
1745
2237
  if (!sessionId) return;
1746
2238
  const pending = feedbackCandidates.get(sessionId);
1747
2239
  if (!pending) return;
2240
+ const eventTurn = event.data && event.data.turn !== undefined ? String(event.data.turn) : '';
2241
+ if (eventTurn && pending.candidate && pending.candidate.turn && eventTurn !== pending.candidate.turn) {
2242
+ feedbackCandidates.delete(sessionId);
2243
+ return;
2244
+ }
2245
+ pending.session = session;
2246
+ if (learningJobs.has(pending.scope.key)) {
2247
+ pending.queued = true;
2248
+ return;
2249
+ }
1748
2250
  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); });
2251
+ startOrganizer(pending.scope, session, pending);
1756
2252
  }
1757
2253
 
1758
2254
  if (typeof ctx.on === 'function') ctx.on('agent/disposed', (payload) => {
@@ -1773,6 +2269,11 @@ export function apply(ctx, config) {
1773
2269
  if (!signal) return;
1774
2270
  const agent = signalAgents.get(signal);
1775
2271
  if (agent) clearPrepared(agent);
2272
+ /** 被取消或被中断的回合不保留反馈候选,下一句「谢谢」不能把没说完的纠正写进去。 */
2273
+ if (signal.aborted === true && agent) {
2274
+ const sessionId = String(agent?.session?.id ?? '');
2275
+ if (sessionId) feedbackCandidates.delete(sessionId);
2276
+ }
1776
2277
  signalAgents.delete(signal);
1777
2278
  });
1778
2279