dsh-lite-memory 0.18.1 → 0.18.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -962,6 +962,86 @@ test('L03b 明确长期要求整理为空时也留下规则', () => {
962
962
  assert.equal(learning.pendingChangeFrom({ text: ' ' }), null);
963
963
  });
964
964
 
965
+ test('L03c 兜底不把没说范围的要求抬成全项目规则', () => {
966
+ const vague = learning.pendingChangeFrom({ text: '以后口播开头先给结论。', cue: { explicitLongTerm: true } });
967
+ assert.equal(vague.applicability, 'unresolved');
968
+ const allLines = learning.pendingChangeFrom({ text: '以后所有口播都先给结论。', cue: { explicitLongTerm: true } });
969
+ assert.equal(allLines.applicability, 'all_in_scope', '「所有口播」按项目层全范围收集');
970
+ const always = learning.pendingChangeFrom({ text: '以后一律先给结论。', cue: { explicitLongTerm: true } });
971
+ assert.equal(always.applicability, 'all_in_scope', '「一律」按项目层全范围收集');
972
+ assert.deepEqual(vague.conditions, []);
973
+ const scoped = learning.pendingChangeFrom({ text: '以后口播开头先给结论。', cue: { explicitLongTerm: true, taskKind: 'video' } });
974
+ assert.equal(scoped.applicability, 'conditional');
975
+ assert.deepEqual(scoped.conditions, [{ key: 'taskKind', value: 'video' }]);
976
+ const global = learning.pendingChangeFrom({ text: '以后这个项目默认用 pnpm。', cue: { explicitLongTerm: true } });
977
+ assert.equal(global.applicability, 'all_in_scope');
978
+ assert.deepEqual(global.conditions, []);
979
+ });
980
+
981
+ test('L03d 作用域在收集时固定,整理只能沿用或收窄', () => {
982
+ const human = (id, text) => ({ id, source: { kind: 'user' }, content: [{ type: 'text', text }] });
983
+ const anchor = { episodeId: 'ep_x', status: 'unbound', updatedAt: '2026-01-01T00:00:00.000Z' };
984
+ const narrow = learning.candidateFrom({ messages: [human('m1', '以后开头先给结论')], anchor, projectId: 'p' });
985
+ assert.equal(narrow.expectedTarget.applicability, 'unresolved');
986
+ const wide = learning.candidateFrom({ messages: [human('m2', '以后这个项目默认用 pnpm')], anchor, projectId: 'p' });
987
+ assert.equal(wide.expectedTarget.applicability, 'all_in_scope');
988
+
989
+ const refs = { feedback: { u0: 'm1' }, rules: {} };
990
+ const base = { target: 'new', feedbackType: 'spec_change', topicKey: '开头', currentText: '开头先给结论', evidence: ['u0'] };
991
+ const needsScope = learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: narrow.expectedTarget });
992
+ assert.equal(needsScope.ok, false);
993
+ assert.match(needsScope.reason, /不能.*全项目/);
994
+ assert.equal(learning.validateChange({ ...base, applicability: 'unresolved' }, { refs, rules: [], ranges: RANGES, expectedTarget: narrow.expectedTarget }).ok, true);
995
+ assert.equal(learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: wide.expectedTarget }).ok, true);
996
+
997
+ const video = { scope: 'episode', episodeId: 'ep_x', taskKind: 'video' };
998
+ const episodeWide = learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
999
+ assert.equal(episodeWide.ok, false, '作品规则不能抬成全项目');
1000
+ const droppedKind = learning.validateChange({ ...base, applicability: 'conditional', conditions: [{ key: 'phase', value: 'script' }] }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
1001
+ assert.equal(droppedKind.ok, false, '不能删掉收集时的任务范围');
1002
+ const keptKind = learning.validateChange({ ...base, applicability: 'conditional', conditions: [{ key: 'taskKind', value: 'video' }, { key: 'phase', value: 'script' }] }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
1003
+ assert.equal(keptKind.ok, true, keptKind.reason);
1004
+
1005
+ const fallback = learning.pendingChangeFrom({ text: '以后所有开头都先给结论。', cue: { explicitLongTerm: true }, expectedTarget: narrow.expectedTarget });
1006
+ assert.equal(fallback.applicability, 'unresolved', '兜底不重新推导作用域');
1007
+ assert.equal(learning.pendingChangeFrom({ text: '以后开头都先给结论。', cue: { explicitLongTerm: true }, expectedTarget: wide.expectedTarget }).applicability, 'all_in_scope');
1008
+ });
1009
+
1010
+ test('L03e 先按相关性排序再截断,输入顺序不挤掉真正相关的旧规则', () => {
1011
+ const rules = [];
1012
+ for (let index = 0; index < 32; index += 1) {
1013
+ rules.push({
1014
+ id: `mem_${String(index).padStart(2, '0')}`,
1015
+ state: 'active',
1016
+ updated: `2026-01-${String((index % 28) + 1).padStart(2, '0')}T00:00:00.000Z`,
1017
+ title: index === 31 ? '字幕字号' : `无关规则${index}`,
1018
+ summary: '',
1019
+ body: index === 31 ? '字幕固定 32 号' : '',
1020
+ conditions: [],
1021
+ });
1022
+ }
1023
+ const selected = learning.selectRules(rules, { query: '字幕太小了,改大一点', dimensions: ['字幕'], limit: 6 });
1024
+ assert.equal(selected.length, 6);
1025
+ assert.ok(selected.some((row) => row.id === 'mem_31'), '相关但排在最后的规则要进候选');
1026
+ assert.equal(learning.selectRules(rules, { limit: 32 }).length, 32, '候选池上限是 32 条,不是先砍到 6 条');
1027
+ });
1028
+
1029
+ test('L03f 整理器规则先按状态和最近使用排序再取 8 条', () => {
1030
+ const rules = [];
1031
+ for (let index = 0; index < 12; index += 1) {
1032
+ rules.push({
1033
+ id: `mem_${String(index).padStart(2, '0')}`,
1034
+ state: index === 11 ? 'active' : index === 10 ? 'trial' : 'paused',
1035
+ updatedAt: `2026-01-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`,
1036
+ title: `规则${index}`,
1037
+ });
1038
+ }
1039
+ const ordered = learning.orderOrganizerRules(rules);
1040
+ assert.equal(ordered.length, learning.LIMITS.organizerRules);
1041
+ assert.equal(ordered[0].id, 'mem_11', '生效规则优先于暂停规则');
1042
+ assert.equal(ordered[1].id, 'mem_10', '试用规则排在暂停之前');
1043
+ });
1044
+
965
1045
  test('L04 先算目标状态再查配额,过期候选可恢复', () => {
966
1046
  const base = { existing: null, evidence: { episodeId: 'e1', messageIds: ['m1'], sourceExcerpt: '以后都用参数化查询' }, pending: 12, rules: 0, now: 1000 };
967
1047
  const active = learning.admitChange({ ...base, change: NEW_CHANGE, explicitLongTerm: true });
@@ -90,8 +90,10 @@ const walk = (node, out = [], expand = false) => {
90
90
  };
91
91
  const exhibit = walk(tree, [], true).find((node) => node.type === 'button' && node.props.title === 'A');
92
92
  assert.ok(exhibit, '展品应是原生按钮');
93
+ const beforeClick = JSON.stringify(tree);
93
94
  exhibit.props.onClick();
94
95
  assert.equal(picked, 'mem_a');
96
+ assert.equal(JSON.stringify(tree), beforeClick, '点开展品只换详情,不改宫殿里的记录');
95
97
 
96
98
  // 首帧列表视图不挂载宫殿;空数据时给下一步提示。
97
99
  const firstPaint = MemoryPanel({ sessionId: 'demo', onClose: () => {} });
@@ -1,5 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import fs from 'node:fs';
3
+ import crypto from 'node:crypto';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
5
6
 
@@ -8,8 +9,9 @@ const { apply } = await import('../index.js');
8
9
  const store = await import('../store.js');
9
10
 
10
11
  const HEADER = '已有记忆可能相关';
11
- const CARD_LINE = /^- \[[^\]]+\] 「.+?」.* · mem_[a-z0-9]+(?:|.*)?$/;
12
+ const CARD_LINE = /^- \[[^\]]+\] 「.+?」(?:|.*)?$/;
12
13
  const CARD_LINE_M = new RegExp(CARD_LINE.source, 'm');
14
+ const INTERNAL_ID = /mem_[a-z0-9]{6,}/;
13
15
 
14
16
  const effect = (fn) => {
15
17
  const dispose = fn();
@@ -84,6 +86,17 @@ const save = (scope, fields) => {
84
86
  return { ...result, id: result.record?.id ?? result.id };
85
87
  };
86
88
 
89
+ /** 有效期这类时间不能靠等待制造:直接改热状态,并同步重算校验和。 */
90
+ const patchState = (scope, id, patch) => {
91
+ const file = path.join(scope.dir, 'state.json');
92
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
93
+ const entry = raw.payload.entries.find((row) => row.id === id);
94
+ assert.ok(entry, `state 里没有 ${id}`);
95
+ Object.assign(entry, patch);
96
+ raw.checksum = `sha256:${crypto.createHash('sha256').update(JSON.stringify(raw.payload)).digest('hex')}`;
97
+ fs.writeFileSync(file, `${JSON.stringify(raw, null, 2)}\n`);
98
+ };
99
+
87
100
  const cwdA = mkdir('a');
88
101
  const scopeA = store.scopeOf(cwdA);
89
102
  const global = store.globalScope();
@@ -277,8 +290,7 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
277
290
  const fourth = await bootA.exec('memory_read', { id: bigBodies[3].id }, agent);
278
291
  assert.equal(fourth.status, 'budget_exhausted');
279
292
  assert.equal(fourth.body || '', '');
280
- assert.equal(fourth.id, '');
281
- assert.equal(bootA.render('memory_read', {}, fourth), 'memory · 本请求的记忆额度已用完');
293
+ assert.equal(bootA.render('memory_read', {}, fourth), 'memory · 这条消息已停止读记忆,下一条消息再读');
282
294
  const win = bootA.debug.windowOf(agent);
283
295
  assert.ok(win.bytes <= 8192);
284
296
  assert.equal(win.bodies, 3);
@@ -332,10 +344,11 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
332
344
  const agent = makeAgent('agent-purge', cwdD);
333
345
  const sig = signal();
334
346
  await bootA.preStep({ agent, signal: sig, turn: 't1' }, enter(human('插件改完怎么让它生效?')));
335
- assert.match(bootA.provider.text({ agent, signal: sig }), new RegExp(doomed.id));
336
- const purged = await bootA.exec('memory_forget', { id: doomed.id, mode: 'purge' }, agent);
347
+ assert.match(bootA.provider.text({ agent, signal: sig }), /插件改完要重启应用/);
348
+ const purged = await bootA.exec('memory_forget', { title: '插件改完要重启应用', mode: 'purge' }, agent);
337
349
  assert.equal(purged.mode, 'purged');
338
- assert.match(bootA.provider.text({ agent, signal: sig }), new RegExp(doomed.id), '已提交的旧快照不回头改写');
350
+ assert.match(bootA.provider.text({ agent, signal: sig }), /插件改完要重启应用/, '已提交的旧快照不回头改写');
351
+ assert.doesNotMatch(bootA.provider.text({ agent, signal: sig }), /mem_[a-z0-9]{6,}/, '召回卡从不带内部编号');
339
352
  const sig2 = signal();
340
353
  await bootA.preStep({ agent, signal: sig2, turn: 't2' }, enter(human('插件改完怎么让它生效?')));
341
354
  const after = bootA.provider.text({ agent, signal: sig2 });
@@ -359,7 +372,7 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
359
372
  const agent = makeAgent('agent-global-purge', cwdA);
360
373
  const sig = signal();
361
374
  await bootA.preStep({ agent, signal: sig, turn: 't1' }, enter(human('发布前跑文案检查')));
362
- assert.match(bootA.provider.text({ agent, signal: sig }), new RegExp(globalNote.id));
375
+ assert.match(bootA.provider.text({ agent, signal: sig }), /全局发布前文案核对/);
363
376
  const purged = store.forget(global, globalNote.id, { mode: 'purge' });
364
377
  assert.equal(purged.mode, 'purged');
365
378
  /** 同一 turn 的续步没有新真人消息:不重新问人,但盘上变了必须换掉快照。 */
@@ -387,12 +400,25 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
387
400
  if (row.status === 'budget_exhausted') exhausted = row;
388
401
  }
389
402
  assert.ok(exhausted, '超过额度后拒绝读正文');
390
- assert.equal(exhausted.id, '');
403
+ assert.equal(exhausted.id, undefined);
391
404
  assert.ok(win.bytes <= 8192);
392
405
  assert.ok(win.bodies <= 3);
393
406
  assert.ok(win.reads <= 8);
394
407
  }
395
408
 
409
+ // A21:自动注入把这一轮用得差不多,模型按标题来读正文不该被连带挡掉。
410
+ {
411
+ const agent = makeAgent('agent-read-after-auto', cwdA);
412
+ await prepare(bootA, agent, '大正文样本怎么用');
413
+ const win = bootA.debug.windowOf(agent);
414
+ assert.ok(win, '自动召回开了额度窗口');
415
+ /** 模拟自动注入已经把这轮的 8192 B 用满。 */
416
+ win.bytes = 8192;
417
+ const row = await bootA.exec('memory_read', { title: '大正文样本1' }, agent);
418
+ assert.equal(row.status, 'ok', '自动注入用满之后仍能读正文');
419
+ assert.ok(Buffer.byteLength(row.body) > 900);
420
+ }
421
+
396
422
  // A18:归档/旧正文不进正常检索;只有明确查历史才列归档。
397
423
  {
398
424
  const cwdE = mkdir('e');
@@ -410,7 +436,7 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
410
436
  const oldBody = save(scopeE, { title: '口端口令', tags: ['端口'], summary: '端口以最新为准', body: '第一版:端口是 5000' });
411
437
  store.upsert(scopeE, { id: oldBody.id, title: '口端口令', summary: '端口以最新为准', body: '第二版:端口是 49661' });
412
438
  const current = await bootA.exec('memory_search', { query: '口端口令', history: true }, agent);
413
- assert.ok(current.refs.some((ref) => ref.id === oldBody.id && ref.revision === 2), '检索只给当前版本');
439
+ assert.ok(current.refs.some((ref) => ref.title === '口端口令' && ref.revision === 2), '检索只给当前版本');
414
440
  const latest = await bootA.exec('memory_read', { id: oldBody.id }, agent);
415
441
  assert.match(latest.body, /第二版/);
416
442
  assert.doesNotMatch(latest.body, /第一版/);
@@ -436,6 +462,38 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
436
462
  assert.ok(win.bytes <= 8192);
437
463
  }
438
464
 
465
+ // 生命周期退出默认召回:被取代、过期的条目不再自动注入,也不再进手动检索。
466
+ {
467
+ const runtime = boot();
468
+ const cwdLife = mkdir('lifecycle');
469
+ const scopeLife = store.scopeOf(cwdLife);
470
+ const oldDeploy = save(scopeLife, {
471
+ title: '旧部署目标', summary: '部署到 staging,旧流程。', body: '旧流程只发 staging。', resolution: 'distinct',
472
+ });
473
+ const newDeploy = save(scopeLife, {
474
+ title: '新部署目标', summary: '部署到 production,先跑测试。', body: '新流程先跑测试再发 production。', resolution: 'distinct',
475
+ supersedes: oldDeploy.id,
476
+ });
477
+ const stalePort = save(scopeLife, {
478
+ title: '临时调试端口', summary: '本地服务跑在 18080。', body: '旧端口已经不用了。', resolution: 'distinct',
479
+ });
480
+ patchState(scopeLife, stalePort.id, {
481
+ validFrom: new Date(Date.now() - 7200_000).toISOString(),
482
+ validUntil: new Date(Date.now() - 3600_000).toISOString(),
483
+ });
484
+ const agentLife = makeAgent('agent-lifecycle', cwdLife);
485
+ const lifeSignal = await prepare(runtime, agentLife, '部署目标怎么走,临时调试端口是多少');
486
+ const text = runtime.provider.text({ agent: agentLife, signal: lifeSignal });
487
+ assert.match(text, /新部署目标/);
488
+ assert.doesNotMatch(text, /旧部署目标|旧流程只发 staging/, '被取代的条目退出自动召回');
489
+ assert.doesNotMatch(text, /临时调试端口|旧端口已经不用了/, '过期的条目退出自动召回');
490
+ const found = await runtime.exec('memory_search', { query: '部署目标' }, agentLife);
491
+ assert.equal(found.refs.some((ref) => ref.title === '旧部署目标'), false);
492
+ assert.equal(found.refs.some((ref) => ref.title === '新部署目标'), true);
493
+ const port = await runtime.exec('memory_search', { query: '临时调试端口' }, agentLife);
494
+ assert.equal(port.refs.some((ref) => ref.title === '临时调试端口'), false, '过期条目也不进默认检索');
495
+ }
496
+
439
497
  // A20:没有命中是正常空结果,不是额度用尽。
440
498
  {
441
499
  const agent = makeAgent('agent-empty-search', cwdA);
@@ -84,6 +84,34 @@ test('2 版状态可读,下一次写入升级为 4 版并保留记录', () =>
84
84
  assert.equal(parsed.payload.entries[0].current.body, '升级后的正文');
85
85
  });
86
86
 
87
+ test('D04b 旧实验规则缺作用域指纹时转待复核,正文与来源保留', () => {
88
+ const scope = fresh('legacy-rules');
89
+ transact(scope, { expectCreate: true }, (draft) => {
90
+ addNote('旧规则', '旧正文')(draft);
91
+ const entry = draft.entries[0];
92
+ entry.origin = 'project_evolution';
93
+ entry.maintenance.sourceRefs = [{ sourceType: 'human_feedback', sourceExcerpt: '以后都这样' }];
94
+ entry.maintenance.learning = { state: 'trial', trialUntil: '2026-01-01T00:00:00.000Z' };
95
+ addNote('新规则', '新正文')(draft);
96
+ const freshRule = draft.entries[1];
97
+ freshRule.origin = 'project_evolution';
98
+ freshRule.maintenance.learning = { state: 'active', specDigest: 'digest' };
99
+ return { changed: true };
100
+ });
101
+ const first = transact(scope, {}, (draft) => state.applyEvolution(draft, true));
102
+ assert.equal(first.changed, true);
103
+ const entries = first.state.entries;
104
+ assert.equal(entries[0].maintenance.reviewState, 'legacy_unreviewed');
105
+ assert.equal(entries[0].maintenance.legacyReview.reason, 'scope_unverified');
106
+ assert.equal(entries[0].current.body, '旧正文');
107
+ assert.equal(entries[0].maintenance.sourceRefs[0].sourceExcerpt, '以后都这样');
108
+ assert.equal(entries[0].maintenance.learning.state, 'paused');
109
+ assert.equal(entries[0].maintenance.learning.blockedReason, 'needs_scope_review');
110
+ assert.equal(entries[1].maintenance.reviewState, 'active');
111
+ const second = transact(scope, {}, (draft) => state.applyEvolution(draft, true));
112
+ assert.equal(second.changed, false, '已复核的不再重复改写');
113
+ });
114
+
87
115
  test('D04 旧程序拒绝写新格式,不抹掉实验标记', () => {
88
116
  const scope = fresh('d04-format');
89
117
  transact(scope, { expectCreate: true }, (draft) => {
@@ -190,7 +218,9 @@ test('S01 仓库根与子目录共用一份记忆', () => {
190
218
  const atSub = scopeOf(sub);
191
219
  assert.equal(atSub.key, atRoot.key);
192
220
  assert.equal(atSub.dir, atRoot.dir);
193
- assert.equal(resolveRoot(sub).root, fs.realpathSync(repo));
221
+ /* Windows reports the temp root as an 8.3 short name on one side and the long
222
+ name on the other; compare what both of them resolve to. */
223
+ assert.equal(fs.realpathSync(resolveRoot(sub).root), fs.realpathSync(repo));
194
224
  });
195
225
 
196
226
  test('S02 符号链接、非 Git 目录、嵌套仓库各归各的', () => {
@@ -600,5 +630,31 @@ test('P0-B 坏 state 且没有 prev:报错给出损坏副本与可执行的恢
600
630
  assert.equal(fs.readFileSync(stateFile(scope), 'utf8'), broken);
601
631
  });
602
632
 
633
+ test('D05 学习开关关掉再开,不重置已用次数和整理记录', () => {
634
+ const scope = fresh('d05-learning-budget');
635
+ const at = new Date().toISOString();
636
+ transact(scope, {}, (draft) => {
637
+ const evo = draft.experimental.projectEvolution;
638
+ evo.learning = {
639
+ attempts: { ep_seed: { count: 2, lastAt: at } },
640
+ recent: [at],
641
+ receipts: [{ operationId: 'op_seed', at, inputTokens: 10, outputTokens: 5 }],
642
+ };
643
+ return state.applyEvolution(draft, true);
644
+ });
645
+ const opened = load(scope).state.experimental.projectEvolution;
646
+ assert.equal(opened.enabled, true);
647
+ assert.deepEqual(opened.learning.recent, [], '打开时清掉在途观察');
648
+ assert.equal(opened.learning.receipts.length, 1, '打开不能清整理回执');
649
+ assert.equal(opened.learning.attempts.ep_seed.count, 2, '打开不能清已用次数');
650
+ transact(scope, {}, (draft) => state.applyEvolution(draft, false));
651
+ transact(scope, {}, (draft) => state.applyEvolution(draft, true));
652
+ const again = load(scope).state.experimental.projectEvolution;
653
+ assert.equal(again.enabled, true);
654
+ assert.equal(again.learning.receipts.length, 1, '关掉再开不重置整理回执');
655
+ assert.equal(again.learning.attempts.ep_seed.count, 2, '关掉再开不重置已用次数');
656
+ assert.deepEqual(again.learning.recent, []);
657
+ });
658
+
603
659
  for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
604
660
  console.log(`state.test.mjs: ${passed} passed`);
@@ -10,7 +10,7 @@ const state = await import('../state.js');
10
10
  const { upsert, search, readEntry, forget, undoChange, acceptCandidate, rejectCandidate,
11
11
  suggestionGate, noteSuggestion, findSuggestion, renderIndex, normalizeDraft, loadIndex,
12
12
  scopeOf, globalScope, LIMITS, recordCitations, listCitations, readCitationBody,
13
- evolutionStatus, setEvolution, saveIndex } = store;
13
+ evolutionStatus, setEvolution, saveIndex, readCitationBodyByTitle } = store;
14
14
 
15
15
  let passed = 0;
16
16
  const test = (name, fn) => {
@@ -219,8 +219,8 @@ test('search finds Chinese queries, when clauses and reports counts', () => {
219
219
  const when = search(scope, 'CI 容器');
220
220
  assert.ok(when.some((item) => item.title === '文案检查'));
221
221
  const rendered = renderIndex(hits);
222
- assert.match(rendered.text, /^- mem_[a-z0-9]+ 发布顺序/);
223
- assert.match(rendered.text, /mem_[a-z0-9]+ 发布顺序/);
222
+ assert.match(rendered.text, /^- 「发布顺序」/);
223
+ assert.doesNotMatch(rendered.text, /mem_[a-z0-9]+/);
224
224
  assert.doesNotMatch(rendered.text, /第 .* 版/);
225
225
  assert.ok(rendered.chars <= 400);
226
226
  assert.deepEqual(search(scope, '完全无关的主题词'), []);
@@ -236,7 +236,8 @@ test('archived or 90-day-stale entries render without a summary', () => {
236
236
  assert.equal(hits.length, 1);
237
237
  const rendered = renderIndex(hits);
238
238
  assert.ok(!rendered.text.includes('|'), rendered.text);
239
- assert.equal(rendered.text.split('\n')[0].includes(item.record.id), true);
239
+ assert.equal(rendered.text.split('\n')[0].includes(item.record.id), false, '索引行只给标题');
240
+ assert.match(rendered.text.split('\n')[0], /「旧条目」/);
240
241
  });
241
242
 
242
243
  test('search and read leave hits and lastUsed alone', () => {
@@ -485,7 +486,8 @@ test('projects and the global layer stay separate', () => {
485
486
  assert.equal(global.length, 1);
486
487
  assert.equal(global[0].scope, 'global');
487
488
  const rendered = renderIndex(global);
488
- assert.match(rendered.text, /^- \[global\] mem_/);
489
+ assert.match(rendered.text, /^- \[global\] 「全局条目」/);
490
+ assert.doesNotMatch(rendered.text, /mem_[a-z0-9]{6,}/);
489
491
  const removed = forget(b, 'mem_000000000000', { mode: 'archive' });
490
492
  assert.equal(removed, null);
491
493
  });
@@ -543,6 +545,16 @@ test('citation rows follow the current title and layer', () => {
543
545
  assert.equal(listCitations('session-fresh').items[0].scope, 'global');
544
546
  });
545
547
 
548
+ test('citation body by title picks the requested layer', () => {
549
+ const scope = fresh('cite-layer');
550
+ const local = upsert(scope, { title: '同名规则', summary: '项目层版本', body: '项目正文' }).record;
551
+ const wide = upsert(globalScope(), { title: '同名规则', summary: '全局层版本', body: '全局正文' }).record;
552
+ recordCitations(scope, 'session-layer', [{ ...local, scope: 'project' }, { ...wide, scope: 'global' }]);
553
+ assert.match(readCitationBodyByTitle('session-layer', '同名规则', { scope: 'project' }).body, /项目正文/);
554
+ assert.match(readCitationBodyByTitle('session-layer', '同名规则', { scope: 'global' }).body, /全局正文/);
555
+ assert.equal(readCitationBodyByTitle('session-layer', '没有这条', { scope: 'global' }), null);
556
+ });
557
+
546
558
  test('old citation session files are pruned', () => {
547
559
  const scope = fresh('cite-prune');
548
560
  recordCitations(scope, 'session-old', [{ id: 'mem_000000000001' }]);
@@ -815,6 +827,38 @@ test('项目实验默认关闭;打开才放行实验读取,关闭暂停试
815
827
  assert.equal(search(scope, '实验规则', 4).some((row) => row.id === target.id), false);
816
828
  });
817
829
 
830
+ test('旧实验规则缺作用域指纹时,打开开关只转待复核并保留正文来源', () => {
831
+ const scope = fresh('evolution-legacy-quarantine');
832
+ upsert(scope, { title: '旧实验规则', summary: '旧规则的作用域没冻结过', body: '以前留下的正文' });
833
+ const target = stateOf(scope).entries.find((entry) => entry.current.title === '旧实验规则');
834
+ assert.ok(target);
835
+ state.transact(scope, {}, (draft) => {
836
+ const row = draft.entries.find((entry) => entry.id === target.id);
837
+ row.origin = 'project_evolution';
838
+ row.maintenance.learning = { state: 'trial', trialUntil: '2099-01-01T00:00:00.000Z' };
839
+ row.maintenance.sourceRefs = [{ kind: 'message', id: 'msg_legacy', sessionId: 's-legacy' }];
840
+ return { changed: true };
841
+ });
842
+ const on = setEvolution(scope, true, { operationId: 'op-legacy-on' });
843
+ assert.equal(on.ok, true);
844
+ assert.equal(on.changed, true);
845
+ const row = stateOf(scope).entries.find((entry) => entry.id === target.id);
846
+ assert.equal(row.current.body, '以前留下的正文');
847
+ assert.deepEqual(row.maintenance.sourceRefs, [{ kind: 'message', id: 'msg_legacy', sessionId: 's-legacy' }]);
848
+ assert.equal(row.maintenance.reviewState, 'legacy_unreviewed');
849
+ assert.equal(row.maintenance.learning.state, 'paused');
850
+ assert.equal(row.maintenance.learning.blockedReason, 'needs_scope_review');
851
+ assert.equal(search(scope, '旧实验规则', 4).some((entry) => entry.id === target.id), false);
852
+ assert.equal(search(scope, '旧实验规则', 4, { allowExperimental: true }).some((entry) => entry.id === target.id), false);
853
+ const snap = store.learningSnapshot(scope);
854
+ const shown = snap.rules.find((rule) => rule.title === '旧实验规则');
855
+ assert.ok(shown, '待复核规则仍要出现在面板里');
856
+ assert.equal(shown.reviewState, 'legacy_unreviewed');
857
+ assert.equal(shown.state, 'paused');
858
+ const again = setEvolution(scope, true);
859
+ assert.equal(again.changed, false, '重复打开不再产生写入');
860
+ });
861
+
818
862
  test('实验策略版本不认时保持关闭,开关拒绝改动且盘上不变', () => {
819
863
  const scope = fresh('evolution-policy');
820
864
  upsert(scope, { title: '普通规则', summary: '普通规则', body: '正文' });
@@ -121,6 +121,9 @@ test('触发词进检索与自动召回', () => {
121
121
  };
122
122
  assert.ok(view.matchScore(record, '帮我把视频贴给我看看').score >= view.MATCH_FLOOR);
123
123
  assert.ok(view.matchScore(record, '完全无关的主题词').score < view.MATCH_FLOOR);
124
+ /** 单字重合不算相关:「这」出现在「这个」里,不能让整句沾边就被召回。 */
125
+ const unrelated = { id: 'mem_cccccccccccc', title: '以后都用这个一次性脚本', summary: '这个脚本只跑一次', when: '', aliases: [], tags: [], triggers: [], status: 'active' };
126
+ assert.equal(view.matchScore(unrelated, '把这段视频的字幕导出来').score, 0);
124
127
  const ranked = view.rank([record], '帮我把视频贴给我看看', { limit: 4, context: ctxOf(null, {}) });
125
128
  assert.equal(ranked.length, 1);
126
129
  assert.equal(ranked[0].record.id, record.id);
package/view.js CHANGED
@@ -234,6 +234,10 @@ export function contextWithTask(context = {}, task = {}) {
234
234
  export function eligibility(record, runtime = {}, purpose = 'read', now = Date.now()) {
235
235
  if (!record) return { ok: false, status: 'gone', reason: 'gone' };
236
236
  if (record.status === 'archived') return { ok: false, status: 'gone', reason: 'archived' };
237
+ /** 生命周期与手动检索共用一条:被取代或过期的条目不再像现行规则一样进入召回和正文读取。 */
238
+ if (record.supersededBy) return { ok: false, status: 'gone', reason: 'superseded' };
239
+ const validUntil = Date.parse(record.validUntil || '');
240
+ if (Number.isFinite(validUntil) && validUntil <= now) return { ok: false, status: 'gone', reason: 'expired' };
237
241
  const pid = runtime && runtime.projectId;
238
242
  if (pid && record.ownerProjectId && record.ownerProjectId !== pid) {
239
243
  return { ok: false, status: 'not_applicable', reason: 'owner_mismatch' };
@@ -305,20 +309,48 @@ function wordsOf(text) {
305
309
  } catch {
306
310
  for (const word of plainText.split(/[^\p{L}\p{N}]+/u)) if (word && !STOPWORDS.has(word)) out.push(word);
307
311
  }
308
- return out.slice(0, 24);
312
+ /** 本机 ICU 缺中文词典时整句会被切成单字;补上二元组,别让「这」这种单字当整词。 */
313
+ if (!/[a-z]/i.test(plainText) && out.some((word) => [...word].length === 1)) {
314
+ const chars = plainText.replace(/[^\p{L}\p{N}]+/gu, '');
315
+ for (let i = 0; i + 1 < chars.length; i += 1) {
316
+ const gram = chars.slice(i, i + 2);
317
+ if (!STOPWORDS.has(gram)) out.push(gram);
318
+ }
319
+ }
320
+ return out.slice(0, 48);
309
321
  }
310
322
 
311
323
  /** 纯英文查询按整词判断:git 不该命中 digital。自动召回和手动搜索共用这一条。 */
312
324
  export const asciiWordsOf = (text) => plain(text, 400).toLowerCase().match(/[a-z][a-z0-9_-]{1,}/g) || [];
313
325
  export const wordIn = (pool, word) => new RegExp(`(^|[^a-z0-9])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^a-z0-9])`).test(pool);
314
326
 
315
- const WEIGHT = { title: 3, keywords: 4, summary: 1.5, when: 1 };
327
+ const WEIGHT = { title: 3, keywords: 4, summary: 1.5, when: 1, coverage: 1.6 };
328
+
329
+ /** 最长公共连续片段:四个字以上连着重合,是「在说同一件事」的强证据。 */
330
+ function longestRun(text, raw) {
331
+ /** 只比前 120 个字:够判断「是不是同一句话」,也把每次比对的代价压在常数级。 */
332
+ const left = plain(text, 120).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
333
+ const right = plain(raw, 120).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
334
+ if (!left || !right) return 0;
335
+ let best = 0;
336
+ let prev = new Array(right.length + 1).fill(0);
337
+ for (let i = 1; i <= left.length; i += 1) {
338
+ const row = new Array(right.length + 1).fill(0);
339
+ for (let j = 1; j <= right.length; j += 1) {
340
+ if (left[i - 1] !== right[j - 1]) continue;
341
+ row[j] = prev[j - 1] + 1;
342
+ if (row[j] > best) best = row[j];
343
+ }
344
+ prev = row;
345
+ }
346
+ return best;
347
+ }
316
348
 
317
349
  /**
318
350
  * 轻量匹配:标题 / 旧标题 / 关键词 / 摘要各算各的分,先过阈值再排序。
319
351
  * 排序固定用 id 兜底,同一批数据每次顺序一致。
320
352
  */
321
- export function matchScore(record, query) {
353
+ export function matchScore(record, query, corpus = null) {
322
354
  const raw = plain(query, 400);
323
355
  if (!raw) return { score: 0, hints: [] };
324
356
  if (new RegExp(`\\b${String(record.id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(raw)) {
@@ -357,16 +389,46 @@ export function matchScore(record, query) {
357
389
  score += WEIGHT.keywords;
358
390
  hints.push('trigger');
359
391
  }
392
+ /**
393
+ * 整词命中按字段取一次最大值:一个字/一个词重合只说明沾边,
394
+ * 每个词各加一次会把分数堆到阈值以上(「这」曾让无关记忆被自动召回)。
395
+ */
396
+ /**
397
+ * 语料里到处都有的词(「触发」「文件」「项目」)只算沾边:
398
+ * 一条记忆靠这种通用词挤进提示位,真相关的那条就被挤出去了。
399
+ */
400
+ const common = (text) => {
401
+ if (!corpus || !corpus.df || !corpus.total) return false;
402
+ const df = corpus.df.get(text) || 0;
403
+ return df > Math.max(3, corpus.total * 0.15);
404
+ };
405
+ let titleWordHit = false;
406
+ let keywordWordHit = false;
407
+ /** 标题里同时命中的不同实词:两个以上独立概念都落在标题,比只沾一个词更说明在说同一件事。 */
408
+ const titleWords = new Set();
360
409
  for (const word of words) {
410
+ if ([...word].length < 2) continue;
361
411
  const plainWord = word.toLowerCase();
362
- const inTitle = plain(record.title, 400).toLowerCase().includes(plainWord)
363
- || (record.aliases || []).some((name) => plain(name, 400).toLowerCase().includes(plainWord));
364
- const inKeywords = keywordTexts.some((text) => plain(text, 400).toLowerCase().includes(plainWord));
365
- if (inTitle || inKeywords) {
366
- score += inKeywords ? WEIGHT.keywords : WEIGHT.title;
367
- hints.push(inKeywords ? 'keyword' : 'title');
412
+ if (common(plainWord)) continue;
413
+ if (keywordTexts.some((text) => plain(text, 400).toLowerCase().includes(plainWord))) { keywordWordHit = true; continue; }
414
+ if (plain(record.title, 400).toLowerCase().includes(plainWord)
415
+ || (record.aliases || []).some((name) => plain(name, 400).toLowerCase().includes(plainWord))) {
416
+ titleWordHit = true;
417
+ titleWords.add(plainWord);
368
418
  }
369
419
  }
420
+ if (!keywordWordHit && titleWordHit) { score += WEIGHT.title; hints.push('title'); }
421
+ if (keywordWordHit) { score += WEIGHT.keywords; hints.push('keyword'); }
422
+ if (titleWords.size >= 2) { score += WEIGHT.coverage; hints.push('coverage'); }
423
+ /** 四字以上的连续重合直接算强命中:句子换了说法但词还连着,就该被想起。 */
424
+ /** 分已经够的就不必算连续片段;这一步只是补「换了说法但词连着」的情况。 */
425
+ const run = score >= MATCH_FLOOR ? 0 : Math.max(0, ...[record.title, (record.aliases || []).join(' '), record.summary, record.when]
426
+ .filter(Boolean)
427
+ .map((text) => longestRun(text, raw)));
428
+ if (run >= 4) {
429
+ score += WEIGHT.title;
430
+ hints.push('phrase');
431
+ }
370
432
  const summaryScore = hit(record.summary, WEIGHT.summary);
371
433
  if (summaryScore > 0) {
372
434
  score += summaryScore;
@@ -381,7 +443,7 @@ export function matchScore(record, query) {
381
443
  .filter(Boolean).join('\n').toLowerCase();
382
444
  if (!ascii.some((word) => wordIn(pool, word))) return { score: 0, hints: [] };
383
445
  }
384
- return { score, hints };
446
+ return { score, hints: [...new Set(hints)] };
385
447
  }
386
448
 
387
449
  export const MATCH_FLOOR = 2.4;
@@ -394,6 +456,17 @@ export function rank(records, query, options = {}) {
394
456
  const limit = Number.isInteger(options.limit) ? options.limit : 4;
395
457
  const context = options.context || {};
396
458
  const rows = [];
459
+ /** 先数一遍词出现在多少条记忆里:通用词降权,只在这批候选内统计,不额外读盘。 */
460
+ const corpus = { df: new Map(), total: 0 };
461
+ for (const record of records || []) {
462
+ if (!record) continue;
463
+ corpus.total += 1;
464
+ const seen = new Set();
465
+ for (const word of wordsOf([record.title, (record.aliases || []).join(' '), (record.tags || []).join(' '), (record.triggers || []).join(' '), record.summary, record.when].filter(Boolean).join(' '))) {
466
+ seen.add(word.toLowerCase());
467
+ }
468
+ for (const word of seen) corpus.df.set(word, (corpus.df.get(word) || 0) + 1);
469
+ }
397
470
  for (const record of records || []) {
398
471
  if (!record || record.status === 'archived') continue;
399
472
  if (options.requireApplicable && record.reviewState === 'legacy_unreviewed') continue;
@@ -406,7 +479,7 @@ export function rank(records, query, options = {}) {
406
479
  status = options.requireApplicable ? statusOf(record, context) : 'ok';
407
480
  if (options.requireApplicable && status !== 'ok') continue;
408
481
  }
409
- const matched = matchScore(record, query);
482
+ const matched = matchScore(record, query, corpus);
410
483
  if (matched.score < MATCH_FLOOR) continue;
411
484
  /** 当前任务明确的话题/工作类型优先,但不覆盖查询本身的字面命中。 */
412
485
  const task = options.task || {};
@@ -594,7 +667,7 @@ export function newestUserQuery(messages) {
594
667
  export function projectionText(view) {
595
668
  if (!view) return '';
596
669
  const rows = [];
597
- rows.push(`${view.title || view.id}${view.when ? `(${view.when})` : ''} · ${view.id}`);
670
+ rows.push(`${view.title || '这条记忆'}${view.when ? `(${view.when})` : ''}`);
598
671
  if (view.conditions && view.conditions.length) rows.push(`条件:${view.conditions.map((item) => `${item.key}=${item.value}`).join(',')}`);
599
672
  if (view.selection) rows.push(`仅对 ${view.selection} 生效`);
600
673
  if (view.state === 'needs_context') rows.push('适用范围待确认');