dsh-lite-memory 0.3.0 → 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.
Files changed (39) hide show
  1. package/README.md +73 -17
  2. package/TRIGGERS.md +18 -14
  3. package/anchor.js +518 -0
  4. package/bench/bench.mjs +145 -0
  5. package/bench/evolution.mjs +123 -0
  6. package/client.js +1519 -33
  7. package/docs/BENCH.md +74 -0
  8. package/docs/RELEASE-0.11.0.md +27 -0
  9. package/docs/RELEASE-0.11.1.md +11 -0
  10. package/docs/RELEASE-0.12.0.md +15 -0
  11. package/docs/RELEASE-0.12.1.md +17 -0
  12. package/docs/RELEASE-0.12.2.md +17 -0
  13. package/docs/RELEASE-0.13.0.md +7 -0
  14. package/docs/RELEASE-0.14.0.md +7 -0
  15. package/docs/RELEASE-0.15.0.md +8 -0
  16. package/index.js +2080 -76
  17. package/learning.js +585 -0
  18. package/package.json +10 -2
  19. package/state.js +1476 -0
  20. package/store.js +2967 -244
  21. package/test/anchor.test.mjs +436 -0
  22. package/test/client.test.mjs +114 -0
  23. package/test/contract.test.mjs +880 -0
  24. package/test/control.test.mjs +278 -0
  25. package/test/crossing.test.mjs +372 -0
  26. package/test/evolution-read.test.mjs +448 -0
  27. package/test/fixtures/legacy-v1/archive/mem_444444444444.json +18 -0
  28. package/test/fixtures/legacy-v1/entries/mem_111111111111.md +2 -0
  29. package/test/fixtures/legacy-v1/entries/mem_222222222222.md +1 -0
  30. package/test/fixtures/legacy-v1/entries/mem_333333333333.md +1 -0
  31. package/test/fixtures/legacy-v1/index.json +63 -0
  32. package/test/learning.test.mjs +772 -0
  33. package/test/lexical.test.mjs +33 -0
  34. package/test/recall.test.mjs +449 -0
  35. package/test/scenario.test.mjs +184 -0
  36. package/test/state.test.mjs +485 -0
  37. package/test/store.test.mjs +816 -115
  38. package/test/view.test.mjs +257 -0
  39. package/view.js +616 -0
@@ -2,118 +2,819 @@ import assert from 'node:assert/strict';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import * as store from '../store.js';
6
-
7
- const home = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-'));
8
- process.env.DSH_HOME = home;
9
-
10
- const scope = store.scopeOf('/tmp/project-a');
11
- const other = store.scopeOf('/tmp/project-b');
12
- const draft = { title: '插件市场条目规范', summary: '市场上传指南的位置与要点', body: '见 UPLOAD-GUIDE.md', kind: 'pointer', tags: ['marketplace'] };
13
-
14
- const first = store.upsert(scope, draft);
15
- assert.equal(first.merged, false);
16
- assert.equal(store.loadIndex(scope).entries.length, 1);
17
-
18
- const second = store.upsert(scope, { ...draft, summary: '市场上传指南的位置与要点(更新)' });
19
- assert.equal(second.merged, true);
20
- assert.equal(second.record.id, first.record.id);
21
- assert.equal(store.loadIndex(scope).entries.length, 1);
22
- assert.equal(store.loadIndex(scope).entries[0].merges, 1);
23
-
24
- assert.deepEqual(store.search(scope, '插件市场').map((item) => item.id), [first.record.id]);
25
- assert.deepEqual(store.search(scope, '量子力学'), []);
26
- assert.deepEqual(store.loadIndex(other).entries, []);
27
-
28
- assert.throws(() => store.upsert(scope, { title: 'x', summary: '你要记得用 tab 缩进' }), /名词短语/);
29
- assert.throws(() => store.upsert(scope, { title: 'y', summary: '凭据位置', body: 'ghp_abcdefghijklmnopqrstuvwxyz0123456789' }), /凭据/);
30
-
31
- const read = store.readEntry(scope, first.record.id);
32
- assert.match(read.body, /UPLOAD-GUIDE/);
33
- assert.ok(store.loadIndex(scope).entries[0].hits >= 2);
34
-
35
- assert.equal(store.suggestionGate(scope, '插件市场条目规范').ok, false);
36
- store.noteSuggestion(scope, '提交前先跑 lint');
37
- assert.equal(store.suggestionGate(scope, '另一条').ok, false);
38
- store.upsert(scope, { title: '提交前先跑 lint', summary: '交付前的文案检查步骤' });
39
- assert.equal(store.suggestionGate(scope, '新的建议').ok, true);
40
-
41
- const old = store.upsert(scope, { title: '很久没用到的条目', summary: '三个月没命中', body: 'x' });
42
- const index = store.loadIndex(scope);
43
- index.entries = index.entries.map((item) =>
44
- item.id === old.record.id ? { ...item, lastUsed: new Date(Date.now() - 400 * 86400000).toISOString() } : item,
45
- );
46
- store.saveIndex(scope, index);
47
- const stale = store.loadIndex(scope).entries.find((item) => item.id === old.record.id);
48
- const rendered = store.renderIndex([stale]);
49
- assert.equal(rendered.text.includes('|'), false);
50
- assert.ok(rendered.text.includes(old.record.title));
51
-
52
- const forgotten = store.forget(scope, first.record.id);
53
- assert.equal(forgotten.title, draft.title);
54
- assert.equal(store.loadIndex(scope).entries.some((item) => item.id === first.record.id), false);
55
- assert.ok(fs.existsSync(path.join(scope.dir, 'archive', `${first.record.id}.md`)));
56
-
57
- for (let i = 0; i < 240; i += 1) {
58
- store.upsert(scope, { title: `条目 ${i}`, summary: `第 ${i} 条`, body: 'x' });
59
- }
60
- const trimmed = store.loadIndex(scope);
61
- assert.ok(trimmed.entries.length <= store.LIMITS.entries);
62
- assert.ok(trimmed.totalBytes <= store.LIMITS.totalBytes);
63
-
64
- fs.rmSync(home, { recursive: true, force: true });
65
- console.log('store: 16 checks passed');
66
-
67
- // 同一主题换个说法:合并,不新增。
68
- const mergeScope = store.scopeOf('/tmp/project-merge');
69
- store.upsert(mergeScope, { title: '插件市场条目规范', summary: '市场上传指南的位置与要点', body: '见 UPLOAD-GUIDE.md' });
70
- const paraphrase = store.upsert(mergeScope, { title: '插件市场条目规范(改写)', summary: '市场上传指南的位置与要点', body: '见 UPLOAD-GUIDE.md#第二节' });
71
- assert.equal(paraphrase.merged, true);
72
- assert.equal(paraphrase.mergedFrom, '插件市场条目规范');
73
- assert.equal(store.loadIndex(mergeScope).entries.length, 1);
74
-
75
- // 同一项目下的另一件事:不合并。
76
- const sibling = store.upsert(mergeScope, { title: '插件市场踩坑清单', summary: '卡片不刷新与抓取失败的已知原因', body: 'y' });
77
- assert.equal(sibling.merged, false);
78
- assert.equal(store.loadIndex(mergeScope).entries.length, 2);
79
-
80
- console.log('store: merge rules ok');
81
-
82
- // 前置条件不同 → 两条,哪怕标题一样。
83
- const mac = store.upsert(mergeScope, { title: '构建耗时长', summary: '本机构建要几分钟', when: 'macOS 本地', body: '- 原因:磁盘加密' });
84
- const ci = store.upsert(mergeScope, { title: '构建耗时长', summary: '本机构建要几分钟', when: 'CI 容器', body: '- 原因:冷缓存' });
85
- assert.equal(mac.merged, false);
86
- assert.equal(ci.merged, false);
87
- assert.notEqual(ci.record.id, mac.record.id);
88
- assert.equal(store.loadIndex(mergeScope).entries.filter((item) => item.title.startsWith('构建耗时长')).length, 2);
89
-
90
- // 同一前置条件下合并:旧要点保留,同键覆盖。
91
- const plus = store.upsert(mergeScope, { title: '构建耗时长', summary: '本机构建要几分钟', when: 'macOS 本地', body: '- 绕过:先跑 cache\n- 原因:磁盘加密拖慢' });
92
- assert.equal(plus.merged, true);
93
- assert.equal(plus.record.id, mac.record.id);
94
- const body = store.readEntry(mergeScope, mac.record.id).body;
95
- assert.match(body, /绕过/);
96
- assert.match(body, /磁盘加密拖慢/);
97
- assert.equal(body.includes('磁盘加密\n'), false);
98
-
99
- // 前置条件参与检索。
100
- const hit = store.search(mergeScope, 'CI 容器');
101
- assert.ok(hit.some((item) => item.id === ci.record.id));
102
-
103
- console.log('store: precondition rules ok');
104
-
105
- // 全局层:一处写入,别的项目查得到;两层同名不合并,全局 id 跨作用域可读可归档。
106
- const projA = store.scopeOf('/tmp/global-a');
107
- const projB = store.scopeOf('/tmp/global-b');
108
- const global = store.globalScope();
109
- store.upsert(global, { title: '回复用中文', summary: '所有项目的中文回复约定', body: '- 回复:中文' });
110
- const seen = store.search(projB, '中文回复');
111
- assert.equal(seen.some((item) => item.scope === 'global'), true);
112
- const local = store.upsert(projA, { title: '回复用中文', summary: '所有项目的中文回复约定', body: '- 回复:中文' });
113
- assert.equal(local.merged, false);
114
- assert.equal(store.loadIndex(global).entries.length, 1);
115
- assert.equal(store.loadIndex(projA).entries.length, 1);
116
- assert.match(store.readEntry(projB, seen[0].id).body, /中文/);
117
- store.forget(projB, seen[0].id);
118
- assert.equal(store.loadIndex(global).entries.length, 0);
119
- console.log('store: global scope ok');
5
+
6
+ process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-'));
7
+ const store = await import('../store.js');
8
+ const state = await import('../state.js');
9
+
10
+ const { upsert, search, readEntry, forget, undoChange, acceptCandidate, rejectCandidate,
11
+ suggestionGate, noteSuggestion, findSuggestion, renderIndex, normalizeDraft, loadIndex,
12
+ scopeOf, globalScope, LIMITS, recordCitations, listCitations, readCitationBody,
13
+ evolutionStatus, setEvolution } = store;
14
+
15
+ let passed = 0;
16
+ const test = (name, fn) => {
17
+ try {
18
+ fn();
19
+ passed += 1;
20
+ } catch (error) {
21
+ console.error(`FAIL ${name}`);
22
+ throw error;
23
+ }
24
+ };
25
+
26
+ const dirs = [];
27
+ const fresh = (label) => {
28
+ const dir = path.join(os.tmpdir(), `lite-memory-case-${label}-${Math.random().toString(16).slice(2, 8)}`);
29
+ fs.mkdirSync(dir, { recursive: true });
30
+ dirs.push(dir);
31
+ return scopeOf(dir);
32
+ };
33
+
34
+ const stateOf = (scope) => JSON.parse(fs.readFileSync(path.join(scope.dir, 'state.json'), 'utf8')).payload;
35
+ const bodyOf = (scope, id) => {
36
+ const entry = stateOf(scope).entries.find((row) => row.id === id);
37
+ return entry ? entry.current.body : undefined;
38
+ };
39
+ const rawState = (scope) => fs.readFileSync(path.join(scope.dir, 'state.json'), 'utf8');
40
+ const countOf = (scope) => loadIndex(scope).entries.length;
41
+ const suggestionsOf = (scope) => loadIndex(scope).suggestions;
42
+
43
+ test('over-specific body is rejected instead of becoming a constraint', () => {
44
+ const scope = fresh('specific');
45
+ assert.throws(
46
+ () => upsert(scope, {
47
+ title: '自绘菜单',
48
+ summary: '菜单用应用自绘,不用系统原生',
49
+ body: '菜单 z-index: 1100;宽度 min(264px, 100vw - 24px);字号 12px;内边距 10px;圆角 8px。',
50
+ }),
51
+ /正文太具体/,
52
+ );
53
+ });
54
+
55
+ test('body over the distilled budget is rejected', () => {
56
+ const scope = fresh('toolong');
57
+ assert.throws(
58
+ () => upsert(scope, { title: '长正文', summary: '太长就不存', body: '记'.repeat(500) }),
59
+ /超过单条 1200 字节/,
60
+ );
61
+ });
62
+
63
+ test('create then same title merges with full old body kept', () => {
64
+ const scope = fresh('create');
65
+ const first = upsert(scope, { title: '发布顺序', summary: '发版前要按顺序做的事', body: '第一步\n- 跑测试' });
66
+ assert.equal(first.merged, false);
67
+ assert.equal(first.needsResolution, false);
68
+ assert.equal(first.record.revision, 1);
69
+ const second = upsert(scope, { title: '发布顺序', summary: '发版前要按顺序做的事', body: '第一步\n- 跑测试\n- 改版本号' });
70
+ assert.equal(second.merged, true);
71
+ assert.equal(second.record.id, first.record.id);
72
+ assert.equal(second.record.revision, 2);
73
+ assert.equal(second.record.versions.at(-1).body, '第一步\n- 跑测试');
74
+ assert.equal(second.record.versions.at(-1).summary, '发版前要按顺序做的事');
75
+ assert.match(readEntry(scope, first.record.id).body, /改版本号/);
76
+ assert.equal(countOf(scope), 1);
77
+ });
78
+
79
+ test('update replaces the body instead of merging markdown sections', () => {
80
+ const scope = fresh('replace');
81
+ const first = upsert(scope, { title: '发布顺序', summary: '发版前按顺序做的事', body: '## 部署\ndeploy 命令' });
82
+ const next = upsert(scope, { id: first.record.id, title: '发布顺序', summary: '发版前按顺序做的事', body: '## 回滚\nrollback 命令' });
83
+ assert.equal(next.merged, true);
84
+ const body = readEntry(scope, first.record.id).body;
85
+ assert.equal(body, '## 回滚\nrollback 命令');
86
+ assert.equal(next.record.versions.at(-1).body, '## 部署\ndeploy 命令');
87
+ });
88
+
89
+ test('update without when/kind/tags/body keeps the old values', () => {
90
+ const scope = fresh('patch');
91
+ const first = upsert(scope, {
92
+ title: '回复用中文',
93
+ summary: '用户要求回复用中文',
94
+ when: '所有对话',
95
+ kind: 'preference',
96
+ tags: ['语言'],
97
+ body: '用中文回复。',
98
+ });
99
+ const next = upsert(scope, { id: first.record.id, title: '回复用中文', summary: '用户要求一律用中文回复' });
100
+ assert.equal(next.record.when, '所有对话');
101
+ assert.equal(next.record.kind, 'preference');
102
+ assert.deepEqual(next.record.tags, ['语言']);
103
+ assert.equal(readEntry(scope, first.record.id).body, '用中文回复。');
104
+ const cleared = upsert(scope, { id: first.record.id, title: '回复用中文', summary: '用户要求一律用中文回复', when: '', tags: [] });
105
+ assert.equal(cleared.record.when, '');
106
+ assert.deepEqual(cleared.record.tags, []);
107
+ });
108
+
109
+ test('similar title without id returns resolution and writes nothing', () => {
110
+ const scope = fresh('similar');
111
+ const base = upsert(scope, { title: 'npm 与 GitHub 发布凭据位置', summary: '两处凭据各在哪取', body: 'keychain 与 gh auth' });
112
+ const before = countOf(scope);
113
+ const entriesBefore = rawState(scope);
114
+ const conflict = upsert(scope, { title: 'npm 与 GitHub 发布凭据在哪', summary: '两处凭据各在哪取', body: '另一条' });
115
+ assert.equal(conflict.needsResolution, true);
116
+ assert.equal(conflict.record, null);
117
+ assert.ok(conflict.similarList.length >= 1 && conflict.similarList.length <= 2);
118
+ assert.equal(conflict.similarList[0].id, base.record.id);
119
+ assert.equal(countOf(scope), before);
120
+ assert.equal(rawState(scope), entriesBefore);
121
+ const distinct = upsert(scope, { title: 'npm 与 GitHub 发布凭据在哪', summary: '两处凭据各在哪取', body: '另一条', resolution: 'distinct' });
122
+ assert.equal(distinct.needsResolution, false);
123
+ assert.equal(distinct.merged, false);
124
+ assert.equal(countOf(scope), before + 1);
125
+ });
126
+
127
+ test('same title with a different when is a second entry', () => {
128
+ const scope = fresh('when');
129
+ const a = upsert(scope, { title: '跑测试', summary: '提交前跑哪条命令', when: '本地', body: 'node test' });
130
+ const b = upsert(scope, { title: '跑测试', summary: '提交前跑哪条命令', when: 'CI', body: 'pnpm test' });
131
+ assert.notEqual(a.record.id, b.record.id);
132
+ assert.equal(countOf(scope), 2);
133
+ const again = upsert(scope, { title: '跑测试', summary: '提交前跑哪条命令', when: 'CI', body: 'pnpm test --watch' });
134
+ assert.equal(again.merged, true);
135
+ assert.equal(again.record.id, b.record.id);
136
+ });
137
+
138
+ test('oversized body is rejected, old data untouched', () => {
139
+ const scope = fresh('oversize');
140
+ const big = 'x'.repeat(LIMITS.entryBytes + 1);
141
+ assert.throws(() => upsert(scope, { title: '太大', summary: '一条超大正文', body: big }), /超过/);
142
+ assert.equal(fs.existsSync(path.join(scope.dir, 'state.json')), false);
143
+ const first = upsert(scope, { title: '正常', summary: '一条正常正文', body: '保留我' });
144
+ assert.throws(() => upsert(scope, { id: first.record.id, title: '正常', summary: '一条正常正文', body: big }), /超过/);
145
+ assert.equal(bodyOf(scope, first.record.id), '保留我');
146
+ assert.equal(loadIndex(scope).entries[0].revision, 1);
147
+ });
148
+
149
+ test('over-long title and summary are errors, not silent slices', () => {
150
+ const scope = fresh('length');
151
+ assert.throws(() => upsert(scope, { title: 't'.repeat(LIMITS.titleChars + 1), summary: '写得短一点' }), /标题超过/);
152
+ assert.throws(() => upsert(scope, { title: '长摘要', summary: 's'.repeat(LIMITS.summaryChars + 1) }), /摘要超过/);
153
+ assert.equal(fs.existsSync(path.join(scope.dir, 'state.json')), false);
154
+ });
155
+
156
+ test('imperative summary and credentials are rejected', () => {
157
+ const scope = fresh('validate');
158
+ assert.throws(() => upsert(scope, { title: '回复语言', summary: '请用中文回复' }), /名词短语/);
159
+ assert.throws(() => upsert(scope, { title: '凭据', summary: '一个 token 的值', body: 'token = sk-abcdefghijklmnopqrst' }), /凭据/);
160
+ assert.equal(normalizeDraft({ title: 'a', summary: '名词短语' }).kind, 'note');
161
+ assert.throws(() => normalizeDraft({ title: 'a', summary: '名词短语', kind: 'guess' }), /类型只能是/);
162
+ });
163
+
164
+ test('正文预算满了也先归档腾位,并在结果里点名', () => {
165
+ const scope = fresh('budget');
166
+ const original = LIMITS.totalBytes;
167
+ LIMITS.totalBytes = 600;
168
+ try {
169
+ upsert(scope, { title: '第一条', summary: '占满预算的正文', body: '记'.repeat(100) });
170
+ const second = upsert(scope, { title: '第二条', summary: '再来一条就超了', body: '记'.repeat(100) });
171
+ assert.equal(second.record.title, '第二条');
172
+ assert.deepEqual(second.archived.map((item) => item.title), ['第一条']);
173
+ assert.equal(countOf(scope), 1);
174
+ } finally {
175
+ LIMITS.totalBytes = original;
176
+ }
177
+ });
178
+
179
+ test('lock file from a dead writer is not stolen: busy instead of a guessed write', () => {
180
+ const scope = fresh('lock');
181
+ fs.mkdirSync(scope.dir, { recursive: true });
182
+ const lock = path.join(scope.dir, '.lock');
183
+ fs.writeFileSync(lock, 'dead:token');
184
+ const old = new Date(Date.now() - 60_000);
185
+ fs.utimesSync(lock, old, old);
186
+ const started = Date.now();
187
+ assert.throws(() => upsert(scope, { title: '拿不到锁', summary: '锁被别人拿着' }), /正忙/);
188
+ const waited = Date.now() - started;
189
+ assert.ok(waited >= 400 && waited < 3000, `锁等待 ${waited}ms`);
190
+ assert.equal(fs.existsSync(path.join(scope.dir, 'state.json')), false);
191
+ fs.rmSync(lock, { force: true });
192
+ const ok = upsert(scope, { title: '拿得到锁', summary: '锁被清掉之后' });
193
+ assert.equal(ok.merged, false);
194
+ });
195
+
196
+ test('search finds Chinese queries, when clauses and reports counts', () => {
197
+ const scope = fresh('search');
198
+ upsert(scope, { title: '发布顺序', summary: '发版前要按顺序做的事', body: 'a' });
199
+ upsert(scope, { title: '文案检查', summary: '改完界面跑一遍 lint', when: 'CI 容器里', body: 'b' });
200
+ const hits = search(scope, '上次发布流程是什么');
201
+ assert.ok(hits.length >= 1);
202
+ assert.equal(hits[0].title, '发布顺序');
203
+ assert.equal(hits[0].scope, 'project');
204
+ const when = search(scope, 'CI 容器');
205
+ assert.ok(when.some((item) => item.title === '文案检查'));
206
+ const rendered = renderIndex(hits);
207
+ assert.match(rendered.text, /^- mem_[a-z0-9]+ 发布顺序/);
208
+ assert.match(rendered.text, /mem_[a-z0-9]+ 发布顺序/);
209
+ assert.doesNotMatch(rendered.text, /第 .* 版/);
210
+ assert.ok(rendered.chars <= 400);
211
+ assert.deepEqual(search(scope, '完全无关的主题词'), []);
212
+ });
213
+
214
+ test('archived or 90-day-stale entries render without a summary', () => {
215
+ const scope = fresh('stale');
216
+ const item = upsert(scope, { title: '旧条目', summary: '很久没用过的条目', body: 'x' });
217
+ const index = loadIndex(scope);
218
+ index.entries[0].lastUsed = new Date(Date.now() - 120 * 24 * 60 * 60 * 1000).toISOString();
219
+ store.saveIndex(scope, index);
220
+ const hits = search(scope, '旧条目');
221
+ assert.equal(hits.length, 1);
222
+ const rendered = renderIndex(hits);
223
+ assert.ok(!rendered.text.includes('|'), rendered.text);
224
+ assert.equal(rendered.text.split('\n')[0].includes(item.record.id), true);
225
+ });
226
+
227
+ test('search and read leave hits and lastUsed alone', () => {
228
+ const scope = fresh('hits');
229
+ const item = upsert(scope, { title: '常用条目', summary: '经常被翻出来的条目', body: 'x' });
230
+ const before = loadIndex(scope).entries.find((entry) => entry.id === item.record.id);
231
+ search(scope, '常用条目');
232
+ readEntry(scope, item.record.id);
233
+ const after = loadIndex(scope).entries.find((entry) => entry.id === item.record.id);
234
+ assert.equal(after.hits, before.hits);
235
+ assert.equal(after.lastUsed, before.lastUsed);
236
+ assert.equal(loadIndex(scope).generation, before.generation ?? loadIndex(scope).generation);
237
+ });
238
+
239
+ test('readEntry returns the body from state, unknown ids are null', () => {
240
+ const scope = fresh('read');
241
+ const item = upsert(scope, { title: '读正文', summary: '正文要读得出来', body: '正文内容' });
242
+ const record = readEntry(scope, item.record.id);
243
+ assert.equal(record.body, '正文内容');
244
+ assert.equal(readEntry(scope, 'mem_deadbeef0000'), null);
245
+ assert.equal(store.readCitationBody('session-read', 'mem_deadbeef0000'), null);
246
+ });
247
+
248
+ test('forget archives both body and record; undo restores the previous version', () => {
249
+ const scope = fresh('undo');
250
+ const first = upsert(scope, { title: '会改两次', summary: '需要撤销的一条', body: '第一版正文' });
251
+ upsert(scope, { id: first.record.id, title: '会改两次', summary: '需要撤销的一条', body: '第二版正文' });
252
+ const restored = undoChange(scope, first.record.id);
253
+ assert.equal(restored.mode, 'restored');
254
+ assert.equal(bodyOf(scope, first.record.id), '第一版正文');
255
+ assert.equal(loadIndex(scope).entries[0].revision, 3);
256
+ const again = undoChange(scope, first.record.id);
257
+ assert.equal(again.mode, 'archived');
258
+ assert.equal(countOf(scope), 0);
259
+ const archived = loadIndex(scope).archived.find((item) => item.id === first.record.id);
260
+ assert.equal(archived.status, 'archived');
261
+ assert.equal(archived.body, '第一版正文');
262
+ const againArchive = forget(scope, first.record.id, { mode: 'archive' });
263
+ assert.equal(againArchive.mode, 'archive');
264
+ assert.equal(againArchive.already, true);
265
+ });
266
+
267
+ test('suggestion gate, keep, replay and reject', () => {
268
+ const scope = fresh('candidate');
269
+ const gate = suggestionGate(scope, { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint' });
270
+ assert.equal(gate.ok, true);
271
+ const noted = noteSuggestion(scope, { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint', body: 'node ui-copy-lint.mjs', when: '发布前' });
272
+ assert.match(noted.id, /^sug_[a-z0-9]+$/);
273
+ assert.equal(noted.targetId, '');
274
+ const found = findSuggestion(scope, noted.id);
275
+ assert.equal(found.when, '发布前');
276
+ assert.equal(found.suggestionId, noted.id);
277
+ const kept = acceptCandidate(scope, noted.id, 'op_first');
278
+ assert.equal(kept.ok, true);
279
+ assert.equal(kept.mode, 'created');
280
+ assert.equal(kept.title, '发布前跑文案检查');
281
+ assert.equal(countOf(scope), 1);
282
+ assert.equal(suggestionsOf(scope).length, 0);
283
+ const replay = acceptCandidate(scope, noted.id, 'op_first');
284
+ assert.equal(replay.ok, true);
285
+ assert.equal(replay.replay, true);
286
+ assert.equal(replay.id, kept.id);
287
+ assert.equal(acceptCandidate(scope, noted.id, 'op_other').ok, false);
288
+ assert.equal(findSuggestion(scope, noted.id), null);
289
+ assert.equal(countOf(scope), 1);
290
+ const third = noteSuggestion(scope, { title: '另一条建议', summary: '用来测试 reject 的候选' });
291
+ const dropped = rejectCandidate(scope, third.id);
292
+ assert.equal(dropped.ok, true);
293
+ assert.equal(rejectCandidate(scope, third.id).ok, false);
294
+ /** 拒绝过的候选在时限内不再冒出来,而不是靠 48 小时节流碰巧挡住。 */
295
+ const retry = suggestionGate(scope, { title: '另一条建议', summary: '用来测试 reject 的候选' });
296
+ assert.equal(retry.ok, false);
297
+ assert.match(retry.reason, /拒绝/);
298
+ });
299
+
300
+ test('未确认的项目根目录不新建命名空间,显式全局仍然可写', () => {
301
+ const project = fresh('unconfirmed');
302
+ const unconfirmed = { ...project, confirmed: false };
303
+ const blocked = store.saveEntry(unconfirmed, { title: '未确认目录里的规则', summary: '不该落进项目层', body: '正文' });
304
+ assert.equal(blocked.needsScope, true);
305
+ assert.equal(blocked.reasonCode, 'unconfirmed_project');
306
+ assert.equal(store.loadIndex(project).entries.length, 0);
307
+ const globalSaved = store.saveEntry(unconfirmed, {
308
+ scope: 'global',
309
+ memoryClass: 'personal_preference',
310
+ title: '跨项目也成立的偏好',
311
+ summary: '不依赖项目根目录',
312
+ body: '正文',
313
+ });
314
+ assert.equal(globalSaved.needsScope, false);
315
+ assert.equal(globalSaved.chosenScope, 'global');
316
+ assert.equal(store.loadIndex(globalScope()).entries.some((item) => item.id === globalSaved.record.id), true);
317
+ const created = upsert(project, { title: '已存在的项目规则', summary: '用来测更新', body: '旧正文' });
318
+ const updateBlocked = store.saveEntry(unconfirmed, { id: created.record.id, title: '已存在的项目规则', summary: '用来测更新', body: '新正文' });
319
+ assert.equal(updateBlocked.needsScope, true);
320
+ assert.equal(updateBlocked.reasonCode, 'unconfirmed_project');
321
+ });
322
+
323
+ test('空正文更新也留版本,撤销不会当成新建', () => {
324
+ const scope = fresh('empty-body');
325
+ const created = upsert(scope, { title: '空正文起步', summary: '正文之后补', body: '' });
326
+ const updated = upsert(scope, { id: created.record.id, title: '空正文起步', summary: '正文之后补', body: '后来补的正文' });
327
+ assert.equal(updated.previousBody, '');
328
+ const undone = undoChange(scope, created.record.id);
329
+ assert.equal(undone.mode, 'restored');
330
+ const current = readEntry(scope, created.record.id);
331
+ assert.equal(current.body, '');
332
+ assert.equal(current.revision, 3);
333
+ });
334
+
335
+ test('suggestion without a body never clears the target body', () => {
336
+ const scope = fresh('candidate-update');
337
+ const original = upsert(scope, { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint', body: '保留这段正文' });
338
+ const gate = suggestionGate(scope, { title: '发布前跑文案检查(含全局)', summary: '发版前先跑 ui 文案 lint' });
339
+ assert.equal(gate.ok, true);
340
+ assert.equal(gate.target.id, original.record.id);
341
+ const noted = noteSuggestion(scope, { title: '发布前跑文案检查(含全局)', summary: '发版前先跑 ui 文案 lint', targetId: gate.target.id });
342
+ const kept = acceptCandidate(scope, noted.id, 'op_update');
343
+ assert.equal(kept.ok, true);
344
+ assert.equal(kept.mode, 'updated');
345
+ assert.equal(kept.id, original.record.id);
346
+ assert.equal(countOf(scope), 1);
347
+ assert.equal(bodyOf(scope, original.record.id), '保留这段正文');
348
+ assert.equal(loadIndex(scope).entries[0].title, '发布前跑文案检查(含全局)');
349
+ assert.deepEqual(loadIndex(scope).entries[0].aliases, ['发布前跑文案检查']);
350
+ });
351
+
352
+ test('candidate pointing at a changed entry is refused, not overwritten', () => {
353
+ const scope = fresh('candidate-stale');
354
+ const original = upsert(scope, { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint', body: '第一版' });
355
+ const noted = noteSuggestion(scope, { title: '发布前跑文案检查(含全局)', summary: '发版前先跑 ui 文案 lint', targetId: original.record.id });
356
+ upsert(scope, { id: original.record.id, title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint', body: '第二版' });
357
+ const kept = acceptCandidate(scope, noted.id, 'op_stale');
358
+ assert.equal(kept.ok, false);
359
+ assert.match(kept.reason, /改过/);
360
+ assert.equal(bodyOf(scope, original.record.id), '第二版');
361
+ });
362
+
363
+ test('expired candidates are invisible and unusable', () => {
364
+ const scope = fresh('candidate-ttl');
365
+ const noted = noteSuggestion(scope, { title: '会过期的建议', summary: '还没确认就过期了' });
366
+ const index = loadIndex(scope);
367
+ index.suggestions[0].expiresAt = new Date(Date.now() - 1000).toISOString();
368
+ store.saveIndex(scope, index);
369
+ assert.equal(findSuggestion(scope, noted.id), null);
370
+ assert.equal(acceptCandidate(scope, noted.id, '').ok, false);
371
+ assert.equal(countOf(scope), 0);
372
+ });
373
+
374
+ test('candidate draft is validated like a real entry', () => {
375
+ const scope = fresh('candidate-bad');
376
+ assert.throws(() => noteSuggestion(scope, { title: '凭据候选', summary: '一个 token 的值', body: 'ghp_abcdefghijklmnopqrstuvwx' }), /凭据/);
377
+ assert.throws(() => noteSuggestion(scope, { title: '超大候选', summary: '正文超过单条上限', body: 'x'.repeat(LIMITS.entryBytes + 1) }), /超过/);
378
+ assert.equal((loadIndex(scope).suggestions || []).length, 0);
379
+ });
380
+
381
+ test('corrupt index stops reads and writes and stays on disk', () => {
382
+ const scope = fresh('corrupt');
383
+ fs.mkdirSync(scope.dir, { recursive: true });
384
+ const file = path.join(scope.dir, 'index.json');
385
+ const broken = '{"entries": [';
386
+ fs.writeFileSync(file, broken);
387
+ assert.throws(() => loadIndex(scope), /已损坏/);
388
+ assert.throws(() => upsert(scope, { title: '不该写', summary: '索引坏了就不能写' }), /已损坏/);
389
+ assert.equal(fs.readFileSync(file, 'utf8'), broken);
390
+ fs.writeFileSync(file, '{"entries": 3}');
391
+ assert.throws(() => loadIndex(scope), /结构不对/);
392
+ });
393
+
394
+ test('条数 200 是提示线,不是拒绝线', () => {
395
+ const scope = fresh('trim');
396
+ for (let i = 0; i < LIMITS.entries; i += 1) {
397
+ upsert(scope, { title: `条目 ${i}`, summary: `第 ${i} 条压容量`, body: `body ${i}`, resolution: 'distinct' });
398
+ }
399
+ assert.equal(countOf(scope), LIMITS.entries);
400
+ const extra = upsert(scope, { title: '第 201 条', summary: '不许因为条数拒绝新记忆' });
401
+ assert.equal(extra.record.title, '第 201 条');
402
+ assert.deepEqual(extra.archived, []);
403
+ assert.equal(countOf(scope), LIMITS.entries + 1);
404
+ assert.equal(fs.existsSync(path.join(scope.dir, 'archive')), false);
405
+ });
406
+
407
+ test('到硬上限时归档最久没读的一条,不拒绝新记忆', () => {
408
+ const scope = fresh('evict');
409
+ fs.mkdirSync(scope.dir, { recursive: true });
410
+ const chunk = Math.ceil(LIMITS.totalBytes / 20);
411
+ const entries = Array.from({ length: 20 }, (_, i) => {
412
+ const day = String(i + 1).padStart(2, '0');
413
+ return {
414
+ id: `mem_${String(i).padStart(12, '0')}`,
415
+ title: `旧条目 ${i}`,
416
+ summary: `第 ${i} 条`,
417
+ when: '',
418
+ kind: 'note',
419
+ tags: [],
420
+ created: `2026-01-${day}T00:00:00.000Z`,
421
+ updated: `2026-01-${day}T00:00:00.000Z`,
422
+ lastUsed: `2026-01-${day}T00:00:00.000Z`,
423
+ revision: 1,
424
+ status: 'active',
425
+ source: 'user_explicit',
426
+ hits: 0,
427
+ merges: 0,
428
+ versions: [],
429
+ bytes: chunk,
430
+ };
431
+ });
432
+ fs.writeFileSync(path.join(scope.dir, 'index.json'), JSON.stringify({
433
+ version: 1, scope: scope.key, entries, suggestions: [], receipts: [],
434
+ }));
435
+ fs.mkdirSync(path.join(scope.dir, 'entries'), { recursive: true });
436
+ for (const entry of entries) {
437
+ fs.writeFileSync(path.join(scope.dir, 'entries', `${entry.id}.md`), 'x'.repeat(chunk));
438
+ }
439
+ const saved = upsert(scope, { title: '新的一条', summary: '满了也要能存', body: '新正文' });
440
+ assert.equal(saved.record.title, '新的一条');
441
+ assert.equal(saved.archived.length, 1);
442
+ assert.equal(saved.archived[0].title, '旧条目 0');
443
+ assert.equal(countOf(scope), 20);
444
+ const archived = stateOf(scope).entries.find((row) => row.id === saved.archived[0].id);
445
+ assert.equal(archived.status, 'archived');
446
+ assert.equal(archived.current.title, '旧条目 0');
447
+ assert.equal(archived.current.body.length, chunk);
448
+ });
449
+
450
+ test('the global layer reports both lines', () => {
451
+ const global = globalScope();
452
+ assert.equal(LIMITS.globalEntries, 200);
453
+ const room = store.capacityOf(global, { entries: new Array(41).fill({}) });
454
+ assert.equal(room.cap, 200);
455
+ assert.equal(room.hard, LIMITS.hardEntries);
456
+ assert.equal(room.used, 41);
457
+ assert.equal(room.remaining, 159);
458
+ assert.equal(room.bytes, 0);
459
+ assert.equal(room.bytesLeft, LIMITS.totalBytes);
460
+ });
461
+
462
+ test('projects and the global layer stay separate', () => {
463
+ const a = fresh('layer-a');
464
+ const b = fresh('layer-b');
465
+ upsert(a, { title: '只在 A 里', summary: '另一个项目看不到' });
466
+ upsert(globalScope(), { title: '全局条目', summary: '所有项目都能搜到' });
467
+ assert.equal(search(b, '只在 A 里').some((item) => item.title === '只在 A 里'), false);
468
+ const global = search(b, '全局条目');
469
+ assert.equal(global.length, 1);
470
+ assert.equal(global[0].scope, 'global');
471
+ const rendered = renderIndex(global);
472
+ assert.match(rendered.text, /^- \[global\] mem_/);
473
+ const removed = forget(b, 'mem_000000000000', { mode: 'archive' });
474
+ assert.equal(removed, null);
475
+ });
476
+
477
+ // ── 会话引用 ────────────────────────────────────────────────────────────────
478
+ test('citations keep one row per id and drop the oldest', () => {
479
+ const scope = fresh('cite');
480
+ const items = Array.from({ length: LIMITS.citations + 5 }, (_, i) => upsert(scope, {
481
+ title: `条目 ${i}`, summary: `引用第 ${i} 条`, body: `正文 ${i}`, resolution: 'distinct',
482
+ }).record);
483
+ recordCitations(scope, 'session-cite', items.slice(0, 3));
484
+ assert.equal(listCitations('session-cite').items.length, 3);
485
+ recordCitations(scope, 'session-cite', [items[1]]);
486
+ const deduped = listCitations('session-cite').items;
487
+ assert.equal(deduped.length, 3);
488
+ assert.equal(deduped[0].id, items[1].id);
489
+ recordCitations(scope, 'session-cite', items);
490
+ const kept = listCitations('session-cite').items;
491
+ assert.equal(kept.length, LIMITS.citations);
492
+ assert.equal(kept[0].id, items[0].id);
493
+ });
494
+
495
+ test('citations refuse a path-like session id', () => {
496
+ const scope = fresh('cite-bad');
497
+ assert.deepEqual(recordCitations(scope, '../escape', [{ id: 'mem_aaaaaaaaaaaa', title: '越界' }]), []);
498
+ assert.deepEqual(listCitations('../../etc/passwd').items, []);
499
+ });
500
+
501
+ test('citation body reads the entry and rejects a foreign path', () => {
502
+ const scope = fresh('cite-body');
503
+ const created = upsert(scope, { title: '引用正文', summary: '点开要看到正文', body: '正文第一行' }).record;
504
+ recordCitations(scope, 'session-body', [{ ...created, scope: 'project' }]);
505
+ const row = listCitations('session-body').items[0];
506
+ assert.equal(row.title, '引用正文');
507
+ assert.equal('path' in row, false);
508
+ assert.match(readCitationBody('session-body', created.id).body, /正文第一行/);
509
+ assert.equal(readCitationBody('session-body', 'mem_999999999999'), null);
510
+
511
+ const dir = path.join(store.memoryRoot(), 'sessions');
512
+ fs.mkdirSync(dir, { recursive: true });
513
+ fs.writeFileSync(path.join(dir, 'session-escape.json'), JSON.stringify({ items: [{ id: 'mem_111111111111', path: '/etc/hosts' }] }));
514
+ assert.equal(readCitationBody('session-escape', 'mem_111111111111'), null);
515
+ });
516
+
517
+ test('citation rows follow the current title and layer', () => {
518
+ const scope = fresh('cite-fresh');
519
+ const created = upsert(scope, { title: '旧标题', summary: '会被改名' }).record;
520
+ recordCitations(scope, 'session-fresh', [{ ...created, scope: 'project' }]);
521
+ assert.equal(listCitations('session-fresh').items[0].title, '旧标题');
522
+ upsert(scope, { id: created.id, title: '新标题', summary: '会被改名' });
523
+ assert.equal(listCitations('session-fresh').items[0].title, '新标题');
524
+
525
+ const global = upsert(globalScope(), { title: '跨项目规则', summary: '换个项目也成立' }).record;
526
+ recordCitations(scope, 'session-fresh', [{ ...global, scope: 'global' }]);
527
+ assert.equal(listCitations('session-fresh').items[0].scope, 'global');
528
+ });
529
+
530
+ test('old citation session files are pruned', () => {
531
+ const scope = fresh('cite-prune');
532
+ recordCitations(scope, 'session-old', [{ id: 'mem_000000000001' }]);
533
+ const file = path.join(store.memoryRoot(), 'sessions', 'session-old.json');
534
+ assert.equal(fs.existsSync(file), true);
535
+ const past = new Date(Date.now() - LIMITS.citationTtlMs - 60_000);
536
+ fs.utimesSync(file, past, past);
537
+ recordCitations(scope, 'session-new', [{ id: 'mem_000000000002' }]);
538
+ assert.equal(fs.existsSync(file), false);
539
+ assert.equal(listCitations('session-new').items.length, 0);
540
+ });
541
+
542
+ test('title is identity: only whitespace is normalized, nothing is cut', () => {
543
+ const scope = fresh('title-clean');
544
+ const saved = upsert(scope, {
545
+ title: 'Chrome 临时 profile 触发「找不到钥匙串」弹窗|macOS 上由',
546
+ summary: '临时 profile 起 Chrome 会弹钥匙串窗口',
547
+ when: 'macOS 上由 agent 启动 Chrome 且用临时 profile 时',
548
+ });
549
+ assert.equal(saved.record.title, 'Chrome 临时 profile 触发「找不到钥匙串」弹窗|macOS 上由');
550
+
551
+ const pasted = normalizeDraft({ title: '发布顺序 (发版前要按顺序做的事)', summary: '发版前要按顺序做的事', when: '发版前要按顺序做的事' });
552
+ assert.equal(pasted.title, '发布顺序 (发版前要按顺序做的事)');
553
+
554
+ const spaced = normalizeDraft({ title: ' 发布 顺序 ', summary: '发版前要按顺序做的事' });
555
+ assert.equal(spaced.title, '发布 顺序');
556
+ });
557
+
558
+ test('distinct keeps two entries whose titles normalize to the same name', () => {
559
+ const scope = fresh('title-distinct');
560
+ const first = upsert(scope, { title: '菜单|工作日', summary: '工作日菜单', body: '周一到周五' });
561
+ const second = upsert(scope, { title: '菜单|周末', summary: '周末菜单', body: '周六周日', resolution: 'distinct' });
562
+ assert.notEqual(first.record.id, second.record.id);
563
+ assert.equal(countOf(scope), 2);
564
+ assert.equal(search(scope, '菜单').length, 2);
565
+
566
+ const sameAgain = upsert(scope, { title: '菜单|工作日', summary: '工作日菜单另存一份', resolution: 'distinct' });
567
+ assert.notEqual(sameAgain.record.id, first.record.id);
568
+ assert.equal(countOf(scope), 3);
569
+ });
570
+
571
+ test('multi-word search keeps only entries the query really hits (no single-common-word noise)', () => {
572
+ const scope = fresh('relevance');
573
+ upsert(scope, { title: '插件刷新崩溃', summary: '改完插件刷新前端会崩', when: '改完插件要刷新时' });
574
+ upsert(scope, { title: '插件市场收录流程', summary: '提交卡片与预检', when: '上架插件市场前' });
575
+ upsert(scope, { title: '插件安装位置', summary: '本地开发的插件装到 profile' });
576
+
577
+ const hits = search(scope, '刷新插件 reload 前端');
578
+ assert.deepEqual(hits.map((item) => item.title), ['插件刷新崩溃']);
579
+
580
+ const none = search(scope, '本地视频 侧边栏 播放');
581
+ assert.deepEqual(none, []);
582
+ });
583
+
584
+ test('peek hints on real title overlap and never writes hits', () => {
585
+ const scope = fresh('peek');
586
+ const saved = upsert(scope, { title: 'Chrome 弹钥匙串窗口', summary: '临时 profile 起 Chrome 会弹钥匙串窗口' });
587
+ const before = loadIndex(scope).entries.find((item) => item.id === saved.record.id);
588
+ const hits = store.peek(scope, '又弹钥匙串窗口了,怎么弄', 3);
589
+ assert.equal(hits.length, 1);
590
+ assert.equal(hits[0].id, saved.record.id);
591
+ assert.equal(hits[0].scope, 'project');
592
+ const after = loadIndex(scope).entries.find((item) => item.id === saved.record.id);
593
+ assert.equal(after.hits, before.hits);
594
+ assert.equal(after.lastUsed, before.lastUsed);
595
+ assert.deepEqual(store.peek(scope, '把这个按钮的圆角改小一点', 3), []);
596
+ });
597
+
598
+ test('candidate keeps field presence: explicit empty body clears, omitted fields stay', () => {
599
+ const scope = fresh('candidate-presence');
600
+ const created = upsert(scope, {
601
+ title: '字段存在', summary: '省略保持原样', when: '项目内', body: '原正文', tags: ['旧标签'],
602
+ }).record;
603
+ const noted = noteSuggestion(scope, {
604
+ title: '字段存在 改名', summary: '换成新摘要', body: '', targetId: created.id,
605
+ });
606
+ const kept = acceptCandidate(scope, noted.id, '');
607
+ assert.equal(kept.ok, true);
608
+ const after = readEntry(scope, created.id);
609
+ assert.equal(after.title, '字段存在 改名');
610
+ assert.equal(after.summary, '换成新摘要');
611
+ assert.equal(after.when, '项目内');
612
+ assert.deepEqual(after.tags, ['旧标签']);
613
+ assert.equal(after.body, '');
614
+ });
615
+
616
+ test('归档条目仍能搜到并标已归档,正文也能读', () => {
617
+ const scope = fresh('archived-search');
618
+ const created = upsert(scope, { title: '旧端口约定', summary: 'unlag 走 49661', body: '49661' }).record;
619
+ forget(scope, created.id, { mode: 'archive' });
620
+ /** 归档后默认检索不到;只有明确查历史才列出来,并如实标成归档。 */
621
+ assert.equal(search(scope, '旧端口约定', 3, {}).length, 0);
622
+ const hits = search(scope, '旧端口约定', 3, { history: true });
623
+ assert.equal(hits.length, 1);
624
+ assert.equal(hits[0].archived, true);
625
+ assert.equal(hits[0].status, 'archived');
626
+ const entry = readEntry(scope, created.id);
627
+ assert.equal(entry.status, 'archived');
628
+ assert.equal(entry.body, '49661');
629
+ });
630
+
631
+ test('归档条目也能彻底删除,旧版本与残留正文一并清掉', () => {
632
+ const scope = fresh('purge-archived');
633
+ const saved = upsert(scope, { title: '归档后再忘掉', summary: '管理动作覆盖归档条目', body: '第一版' });
634
+ upsert(scope, { id: saved.record.id, title: '归档后再忘掉', summary: '管理动作覆盖归档条目', body: '第二版' });
635
+ forget(scope, saved.record.id, { mode: 'archive' });
636
+ assert.equal(loadIndex(scope).archived.length, 1);
637
+ const purged = forget(scope, saved.record.id, { mode: 'purge' });
638
+ assert.equal(purged.mode, 'purged');
639
+ assert.equal(loadIndex(scope).archived.length, 0);
640
+ assert.equal(stateOf(scope).entries.length, 0);
641
+ assert.equal(fs.existsSync(path.join(scope.dir, 'entries', `${saved.record.id}.md`)), false);
642
+ assert.equal(forget(scope, saved.record.id, { mode: 'purge' }), null);
643
+ });
644
+
645
+ test('上次彻底删除没做完时,forget 先按原层收尾再返回原回执', () => {
646
+ const scope = fresh('purge-pending');
647
+ const saved = upsert(scope, { title: '删到一半', summary: '屏障期间的重试', body: 'x' });
648
+ const payload = stateOf(scope);
649
+ const op = 'purge_test_pending';
650
+ fs.writeFileSync(path.join(scope.dir, 'purge.intent.json'), `${JSON.stringify({
651
+ operationId: op, scopeKey: scope.key, storeId: payload.storeId, fromEpoch: payload.epoch,
652
+ toEpoch: 'epoch-after-purge', ids: [saved.record.id], phase: 'planned', at: new Date().toISOString(),
653
+ })}
654
+ `);
655
+ const purged = forget(scope, saved.record.id, { mode: 'purge' });
656
+ assert.equal(purged.mode, 'purged');
657
+ assert.equal(purged.operationId, op);
658
+ assert.equal(stateOf(scope).entries.length, 0);
659
+ assert.equal(fs.existsSync(path.join(scope.dir, 'purge.intent.json')), false);
660
+ });
661
+
662
+ test('归档条目的彻底删除同样先过屏障,断点收尾后不留正文', () => {
663
+ const scope = fresh('purge-archived-pending');
664
+ const saved = upsert(scope, { title: '归档删一半', summary: '归档条目也走屏障', body: '旧正文' });
665
+ upsert(scope, { id: saved.record.id, title: '归档删一半', summary: '归档条目也走屏障', body: '新正文' });
666
+ forget(scope, saved.record.id, { mode: 'archive' });
667
+ const payload = stateOf(scope);
668
+ fs.writeFileSync(path.join(scope.dir, 'purge.intent.json'), `${JSON.stringify({
669
+ operationId: 'purge_archived_test', scopeKey: scope.key, storeId: payload.storeId, fromEpoch: payload.epoch,
670
+ toEpoch: 'epoch-after-archived-purge', ids: [saved.record.id], phase: 'planned', at: new Date().toISOString(),
671
+ })}
672
+ `);
673
+ assert.throws(() => search(scope, '归档删一半', 3, { history: true }), (error) => error.code === 'purge_pending');
674
+ const purged = forget(scope, saved.record.id, { mode: 'purge' });
675
+ assert.equal(purged.mode, 'purged');
676
+ assert.equal(purged.operationId, 'purge_archived_test');
677
+ assert.equal(loadIndex(scope).archived.length, 0);
678
+ assert.equal(fs.existsSync(path.join(scope.dir, 'purge.intent.json')), false);
679
+ assert.equal(search(scope, '归档删一半', 3, {}).length, 0);
680
+ });
681
+
682
+
683
+ test('整层写回不丢来源、检查和学习字段,也不改写未知来源值', () => {
684
+ const scope = fresh('codec-full');
685
+ upsert(scope, { title: '代码往返', summary: '整层写回要保住扩展字段', body: '正文' });
686
+ const saved = stateOf(scope).entries[0];
687
+ state.transact(scope, {}, (draft) => {
688
+ const row = draft.entries.find((entry) => entry.id === saved.id);
689
+ row.origin = 'project_evolution';
690
+ row.maintenance.sourceRefs = [{ kind: 'message', id: 'msg_1', sessionId: 's1' }];
691
+ row.maintenance.checks = [{ at: '2026-01-01T00:00:00.000Z', result: 'passed' }];
692
+ row.maintenance.learning = { state: 'trial', episodeId: 'ep_1', support: 2 };
693
+ return { changed: true };
694
+ });
695
+ const loaded = loadIndex(scope);
696
+ const flat = loaded.entries[0];
697
+ assert.equal(flat.origin, 'project_evolution');
698
+ assert.deepEqual(flat.sourceRefs, [{ kind: 'message', id: 'msg_1', sessionId: 's1' }]);
699
+ assert.deepEqual(flat.checks, [{ at: '2026-01-01T00:00:00.000Z', result: 'passed' }]);
700
+ assert.deepEqual(flat.learning, { state: 'trial', episodeId: 'ep_1', support: 2 });
701
+ upsert(scope, { id: saved.id, summary: '整层写回要保住扩展字段(改过)' });
702
+ const after = stateOf(scope).entries.find((entry) => entry.id === saved.id);
703
+ assert.equal(after.origin, 'project_evolution');
704
+ assert.deepEqual(after.maintenance.sourceRefs, [{ kind: 'message', id: 'msg_1', sessionId: 's1' }]);
705
+ assert.deepEqual(after.maintenance.checks, [{ at: '2026-01-01T00:00:00.000Z', result: 'passed' }]);
706
+ assert.deepEqual(after.maintenance.learning, { state: 'trial', episodeId: 'ep_1', support: 2 });
707
+ });
708
+
709
+ test('只改摘要或正文不会把待核对的范围放宽成普遍适用', () => {
710
+ const scope = fresh('scope-preserve');
711
+ const saved = upsert(scope, {
712
+ title: '发布前检查', summary: '发布前的检查项', when: '发版前', applicability: 'unresolved', body: '旧正文',
713
+ });
714
+ assert.equal(saved.record.applicability, 'unresolved');
715
+ const patched = upsert(scope, { id: saved.record.id, body: '新正文' });
716
+ assert.equal(patched.record.applicability, 'unresolved');
717
+ const summary = upsert(scope, { id: saved.record.id, summary: '发布前的检查项(更新)' });
718
+ assert.equal(summary.record.applicability, 'unresolved');
719
+ const cleared = upsert(scope, { id: saved.record.id, when: '' });
720
+ assert.equal(cleared.record.applicability, 'all_in_scope');
721
+ });
722
+
723
+ test('旧候选 has=true 但条件缺失时拒绝接受并作废候选', () => {
724
+ const scope = fresh('candidate-broken');
725
+ const asked = noteSuggestion(scope, {
726
+ title: '后期配色', summary: '后期统一用暖色', conditions: [{ key: 'taskKind', value: 'video' }],
727
+ });
728
+ state.transact(scope, {}, (draft) => {
729
+ const row = draft.candidates.find((item) => item.id === asked.id);
730
+ delete row.draft.conditions;
731
+ return { changed: true };
732
+ });
733
+ const result = acceptCandidate(scope, asked.id);
734
+ assert.equal(result.ok, false);
735
+ assert.equal(result.invalid, true);
736
+ assert.match(result.reason, /重新建议/);
737
+ assert.equal(suggestionsOf(scope).length, 0);
738
+ assert.equal(countOf(scope), 0);
739
+ });
740
+
741
+ test('实验来源条目默认不进入检索、正文与关联,只有管理面放行', () => {
742
+ const scope = fresh('experimental-gate');
743
+ const normal = upsert(scope, { title: '普通记录', summary: '普通记录可以读', body: '普通正文' });
744
+ upsert(scope, { title: '实验记录', summary: '实验记录默认不可读', body: '实验正文' });
745
+ const target = stateOf(scope).entries.find((entry) => entry.current.title === '实验记录');
746
+ assert.ok(target);
747
+ state.transact(scope, {}, (draft) => {
748
+ const row = draft.entries.find((entry) => entry.id === target.id);
749
+ row.origin = 'project_evolution';
750
+ row.maintenance.learning = { state: 'paused' };
751
+ return { changed: true };
752
+ });
753
+ assert.equal(search(scope, '实验记录', 4).some((row) => row.id === target.id), false);
754
+ assert.equal(readEntry(scope, target.id), null);
755
+ assert.equal(search(scope, '实验记录', 4, { allowExperimental: true }).some((row) => row.id === target.id), false);
756
+ assert.equal(search(scope, '实验记录', 4, { allowExperimental: true, history: true }).some((row) => row.id === target.id), true);
757
+ assert.equal(readEntry(scope, target.id, { allowExperimental: true }).body, '实验正文');
758
+ assert.ok(readEntry(scope, normal.record.id));
759
+ });
760
+
761
+ test('项目实验默认关闭;打开才放行实验读取,关闭暂停试用且不自动复活', () => {
762
+ const scope = fresh('evolution-switch');
763
+ upsert(scope, { title: '实验规则', summary: '实验规则默认不参与读取', body: '实验正文' });
764
+ const target = stateOf(scope).entries.find((entry) => entry.current.title === '实验规则');
765
+ state.transact(scope, {}, (draft) => {
766
+ const row = draft.entries.find((entry) => entry.id === target.id);
767
+ row.origin = 'project_evolution';
768
+ row.maintenance.learning = { state: 'trial', specDigest: 'digest-1', supportedEpisodeIds: [] };
769
+ return { changed: true };
770
+ });
771
+ const initial = evolutionStatus(scope);
772
+ assert.equal(initial.enabled, false);
773
+ assert.equal(initial.compatible, true);
774
+ assert.equal(initial.counts.trial, 1);
775
+ assert.equal(search(scope, '实验规则', 4).some((row) => row.id === target.id), false);
776
+
777
+ const on = setEvolution(scope, true, { operationId: 'op-enable-1' });
778
+ assert.equal(on.ok, true);
779
+ assert.equal(on.changed, true);
780
+ assert.equal(on.enabled, true);
781
+ assert.notEqual(on.epoch, initial.epoch);
782
+ assert.equal(search(scope, '实验规则', 4).some((row) => row.id === target.id), false);
783
+ assert.equal(search(scope, '实验规则', 4, { allowExperimental: true }).some((row) => row.id === target.id), true);
784
+ assert.equal(readEntry(scope, target.id, { allowExperimental: true }).body, '实验正文');
785
+
786
+ const off = setEvolution(scope, false, { operationId: 'op-disable-1' });
787
+ assert.equal(off.ok, true);
788
+ assert.equal(off.enabled, false);
789
+ assert.equal(off.counts.paused, 1);
790
+ const paused = stateOf(scope).entries.find((entry) => entry.id === target.id);
791
+ assert.equal(paused.maintenance.learning.state, 'paused');
792
+ assert.equal(paused.maintenance.learning.blockedReason, 'needs_revalidation');
793
+ assert.equal(search(scope, '实验规则', 4).some((row) => row.id === target.id), false);
794
+
795
+ const again = setEvolution(scope, true);
796
+ assert.equal(again.enabled, true);
797
+ assert.equal(again.counts.paused, 1);
798
+ assert.equal(again.counts.trial, 0);
799
+ assert.equal(search(scope, '实验规则', 4).some((row) => row.id === target.id), false);
800
+ });
801
+
802
+ test('实验策略版本不认时保持关闭,开关拒绝改动且盘上不变', () => {
803
+ const scope = fresh('evolution-policy');
804
+ upsert(scope, { title: '普通规则', summary: '普通规则', body: '正文' });
805
+ state.transact(scope, {}, (draft) => {
806
+ draft.experimental.projectEvolution.policyVersion = 99;
807
+ return { changed: true };
808
+ });
809
+ const before = fs.readFileSync(path.join(scope.dir, 'state.json'), 'utf8');
810
+ const status = evolutionStatus(scope);
811
+ assert.equal(status.compatible, false);
812
+ assert.equal(status.enabled, false);
813
+ const refused = setEvolution(scope, true);
814
+ assert.equal(refused.ok, false);
815
+ assert.equal(refused.compatible, false);
816
+ assert.equal(fs.readFileSync(path.join(scope.dir, 'state.json'), 'utf8'), before);
817
+ });
818
+
819
+ for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
820
+ console.log(`store.test.mjs: ${passed} passed`);