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
@@ -0,0 +1,257 @@
1
+ /**
2
+ * 投影与语义闸门:只测「模型该看到什么、自动召回该不该拿、删了之后还会不会冒出来」。
3
+ * 涉及具体措辞的断言都取用户原话,避免把内部命名写进测试。
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+
10
+ process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-view-'));
11
+ const view = await import('../view.js');
12
+ const store = await import('../store.js');
13
+ const state = await import('../state.js');
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 = fs.mkdtempSync(path.join(os.tmpdir(), `lm-view-${label}-`));
29
+ dirs.push(dir);
30
+ return store.scopeOf(dir);
31
+ };
32
+ const ctxOf = (scope, extra = {}) => view.trustedContext(scope, extra);
33
+
34
+ test('提示只认用户自己说的话,宿主合成的消息不算', () => {
35
+ const human = { role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: '以后视频都给本地链接' }] };
36
+ const goal = { role: 'user', source: { kind: 'goal' }, content: [{ type: 'text', text: '<goal_round>\nObjective: 把插件做稳定' }] };
37
+ const snapshot = { role: 'user', source: { kind: 'time-context' }, content: [{ type: 'text', text: 'Time sampled while preparing turn 1' }] };
38
+ const runtime = { role: 'user', content: [{ type: 'text', text: 'Current runtime context. 已有记忆可能相关:\n- 「插件热重载」' }] };
39
+ const job = { role: 'user', content: [{ type: 'text', text: 'background job bash-1 finished' }] };
40
+ assert.equal(view.newestUserQuery([human]), '以后视频都给本地链接');
41
+ assert.equal(view.newestUserQuery([human, goal, snapshot, runtime, job]), '以后视频都给本地链接');
42
+ assert.equal(view.newestUserQuery([goal, runtime, job]), '');
43
+ assert.equal(view.newestUserQuery([]), '');
44
+ });
45
+
46
+ test('条件与清单只收能核验的写法', () => {
47
+ assert.deepEqual(view.normalizeConditions([{ key: 'targetOs', value: 'macOS' }]), [{ key: 'targetOs', value: 'macOS' }]);
48
+ assert.deepEqual(view.normalizeConditions([]), []);
49
+ assert.throws(() => view.normalizeConditions([{ key: 'favorite', value: 'x' }]), /不认这个条件键/);
50
+ assert.throws(() => view.normalizeConditions([{ key: 'shell', value: 'zsh' }, { key: 'shell', value: 'bash' }]), /重复/);
51
+ assert.throws(() => view.normalizeConditions(new Array(9).fill({ key: 'shell', value: 'zsh' })), /最多 8 条/);
52
+ assert.throws(() => view.normalizeSelection({ mode: 'complete' }), /artifactKey/);
53
+ assert.deepEqual(view.normalizeSelection({ mode: 'examples', items: ['无糖'] }), { mode: 'examples', artifactKey: '', items: ['无糖'] });
54
+ assert.throws(() => view.normalizeTriggers(['a', 'b', 'c', 'd', 'e']), /最多 4 个/);
55
+ assert.throws(() => view.normalizeLinks([{ type: 'related', id: 'mem_000000000001' }], 'mem_000000000001'), /不能关联自己/);
56
+ assert.throws(() => view.normalizeLinks([{ type: 'related', id: 'nope' }]), /不合法/);
57
+ assert.throws(() => view.normalizeLinks([{ type: 'related', id: 'mem_000000000001' }, { type: 'related', id: 'mem_000000000001' }]), /重复/);
58
+ });
59
+
60
+ test('适用范围按字段推断,自相矛盾的组合写不进去', () => {
61
+ assert.equal(view.decideApplicability({ conditions: [], when: '' }), 'all_in_scope');
62
+ assert.equal(view.decideApplicability({ conditions: [{ key: 'shell', value: 'zsh' }] }), 'conditional');
63
+ assert.equal(view.decideApplicability({ when: '周四晚上' }), 'unresolved');
64
+ assert.equal(view.decideApplicability({ applicability: 'unresolved', when: '' }), 'unresolved');
65
+ assert.throws(() => view.validateApplicability('all_in_scope', [{ key: 'shell', value: 'zsh' }]), /普遍适用不能带条件/);
66
+ assert.throws(() => view.validateApplicability('conditional', []), /至少要给一个条件/);
67
+ });
68
+
69
+ test('条件裁决:不把不知道当不存在,也不把未解决当普遍适用', () => {
70
+ const record = { id: 'mem_000000000001', applicability: 'conditional', conditions: [{ key: 'targetOs', value: 'macOS' }], when: '' };
71
+ assert.equal(view.evaluate(record, ctxOf(null, { targetOs: 'macos' })).state, 'match');
72
+ assert.equal(view.evaluate(record, ctxOf(null, { targetOs: 'linux' })).state, 'mismatch');
73
+ assert.equal(view.evaluate(record, ctxOf(null, {})).state, 'unknown');
74
+ const unresolved = { ...record, applicability: 'unresolved', conditions: [] };
75
+ assert.equal(view.statusOf(unresolved, ctxOf(null, {})), 'needs_context');
76
+ assert.equal(view.canAutoRecall(unresolved, ctxOf(null, {})), false);
77
+ const legacy = { ...record, applicability: 'all_in_scope', conditions: [], reviewState: 'legacy_unreviewed' };
78
+ assert.equal(view.canAutoRecall(legacy, ctxOf(null, {})), false);
79
+ });
80
+
81
+ test('complete 只对指定对象成立;对象不明时需上下文,对象不符时不适用', () => {
82
+ const record = {
83
+ id: 'mem_000000000002',
84
+ applicability: 'conditional',
85
+ conditions: [{ key: 'pathPrefix', value: '/tmp/project' }],
86
+ selection: { mode: 'complete', artifactKey: 'menu:main', items: ['无糖'] },
87
+ when: '',
88
+ };
89
+ assert.equal(view.evaluate(record, ctxOf(null, { pathPrefix: '/tmp/project', artifactKey: 'menu:main' })).state, 'match');
90
+ assert.equal(view.evaluate(record, ctxOf(null, { pathPrefix: '/tmp/project', artifactKey: 'menu:other' })).state, 'mismatch');
91
+ assert.equal(view.evaluate(record, ctxOf(null, { pathPrefix: '/tmp/project' })).state, 'unknown');
92
+ assert.equal(view.evaluate(record, ctxOf(null, { pathPrefix: '/tmp/elsewhere', artifactKey: 'menu:main' })).state, 'mismatch');
93
+ });
94
+
95
+ test('召回先过阈值再排序,条件未知的不进自动召回', () => {
96
+ const rows = [
97
+ { id: 'mem_000000000001', title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint', tags: [], when: '', applicability: 'all_in_scope', conditions: [], selection: null, lastUsed: '2026-01-01T00:00:00.000Z' },
98
+ { id: 'mem_000000000002', title: '发布流程', summary: '发版顺序', tags: [], when: '', applicability: 'conditional', conditions: [{ key: 'shell', value: 'zsh' }], selection: null, lastUsed: '2026-02-01T00:00:00.000Z' },
99
+ ];
100
+ const plainHits = view.rank(rows, '发布', { limit: 4 });
101
+ assert.ok(plainHits.every((item) => item.score >= view.MATCH_FLOOR));
102
+ assert.deepEqual(view.rank(rows, '完全无关的主题词').map((item) => item.record.id), []);
103
+ const strict = view.rank(rows, '发布', { requireApplicable: true, context: ctxOf(null, {}) });
104
+ assert.equal(strict.some((item) => item.record.id === 'mem_000000000002'), false);
105
+ const known = view.rank(rows, '发布', { requireApplicable: true, context: ctxOf(null, { shell: 'zsh' }) });
106
+ assert.equal(known.some((item) => item.record.id === 'mem_000000000002'), true);
107
+ });
108
+
109
+ test('触发词进检索与自动召回', () => {
110
+ const record = {
111
+ id: 'mem_000000000009',
112
+ title: '对外链接处理',
113
+ summary: '本地文件怎样进对话',
114
+ tags: [],
115
+ triggers: ['把视频贴给我'],
116
+ when: '',
117
+ applicability: 'all_in_scope',
118
+ conditions: [],
119
+ selection: null,
120
+ lastUsed: '2026-03-01T00:00:00.000Z',
121
+ };
122
+ assert.ok(view.matchScore(record, '帮我把视频贴给我看看').score >= view.MATCH_FLOOR);
123
+ assert.ok(view.matchScore(record, '完全无关的主题词').score < view.MATCH_FLOOR);
124
+ const ranked = view.rank([record], '帮我把视频贴给我看看', { limit: 4, context: ctxOf(null, {}) });
125
+ assert.equal(ranked.length, 1);
126
+ assert.equal(ranked[0].record.id, record.id);
127
+ const scope = fresh('trigger');
128
+ const saved = store.upsert(scope, { title: '对外链接处理', summary: '本地文件怎样进对话', body: '看本地文件', triggers: ['把视频贴给我'] });
129
+ const hits = store.search(scope, '帮我把视频贴给我看看');
130
+ assert.equal(hits.some((item) => item.id === saved.record.id), true);
131
+ });
132
+
133
+ test('模型投影不含维护区、历史版本和旧名', () => {
134
+ const record = {
135
+ id: 'mem_000000000003',
136
+ title: '发布前跑文案检查',
137
+ summary: '发版前先跑 UI 文案检查',
138
+ when: '发版前',
139
+ kind: 'convention',
140
+ revision: 4,
141
+ scope: 'project',
142
+ body: 'node ui-copy-lint.mjs',
143
+ conditions: [],
144
+ selection: null,
145
+ links: [{ type: 'related', id: 'mem_000000000004' }],
146
+ versions: [{ title: '旧名称-哨兵', body: '旧正文-哨兵' }],
147
+ retiredNames: ['旧名称-哨兵'],
148
+ sourceRefs: ['msg_哨兵'],
149
+ receipts: [{ operationId: 'op_哨兵' }],
150
+ checks: [{ note: '哨兵' }],
151
+ hits: 42,
152
+ merges: 3,
153
+ lastUsed: '2026-01-01T00:00:00.000Z',
154
+ reviewState: 'legacy_unreviewed',
155
+ };
156
+ for (const purpose of ['search', 'read', 'save']) {
157
+ const text = JSON.stringify(view.toModelView(record, purpose, { status: 'ok' }));
158
+ assert.equal(text.includes('哨兵'), false, `${purpose} 投影漏了维护区`);
159
+ assert.equal(/versions|retiredNames|sourceRefs|receipts|checks|hits|merges|lastUsed/.test(text), false);
160
+ }
161
+ const links = view.toModelView(record, 'links', { links: [{ id: 'mem_000000000004', title: '相关的一条', revision: 2 }] });
162
+ assert.equal(links.links[0].status, 'ok');
163
+ assert.equal(view.projectionText(view.toModelView(record, 'read', { status: 'needs_context' })).includes('适用范围待确认'), true);
164
+ });
165
+
166
+ test('patch 保留没给的字段;空值显式清空;整份替换不继承旧值', () => {
167
+ const scope = fresh('write');
168
+ const first = store.upsert(scope, {
169
+ title: '菜单里可以有「无糖」',
170
+ summary: '这个菜单允许无糖选项',
171
+ body: '主菜单允许无糖',
172
+ when: '改菜单时',
173
+ tags: ['菜单'],
174
+ applicability: 'conditional',
175
+ conditions: [{ key: 'pathPrefix', value: '/tmp/menu' }],
176
+ selection: { mode: 'complete', artifactKey: 'menu:main', items: ['无糖'] },
177
+ });
178
+ assert.equal(first.record.title, '菜单里可以有「无糖」');
179
+ assert.equal(first.record.applicability, 'conditional');
180
+ const kept = store.upsert(scope, { id: first.record.id, body: '主菜单允许无糖,别处不管' });
181
+ assert.equal(kept.record.title, '菜单里可以有「无糖」');
182
+ assert.equal(kept.record.conditions.length, 1);
183
+ const cleared = store.upsert(scope, { id: first.record.id, conditions: [], when: '', body: '' });
184
+ assert.equal(cleared.record.conditions.length, 0);
185
+ assert.equal(cleared.record.when, '');
186
+ assert.equal(cleared.record.body, '');
187
+ assert.throws(() => store.upsert(scope, { id: first.record.id, writeMode: 'replace_current', title: '换一条', summary: '整份替换', body: 'x' }), /整份替换要给出|不能用来新增/);
188
+ const replaced = store.upsert(scope, {
189
+ id: first.record.id,
190
+ writeMode: 'replace_current',
191
+ title: '全新的一条',
192
+ summary: '整份替换后的内容',
193
+ body: '新正文',
194
+ when: '',
195
+ kind: 'note',
196
+ tags: [],
197
+ });
198
+ assert.equal(replaced.record.title, '全新的一条');
199
+ assert.equal(replaced.record.applicability, 'all_in_scope');
200
+ assert.equal(replaced.record.selection, null);
201
+ });
202
+
203
+ test('拒绝候选留抑制规则;被忘掉的来源不会再自动建议(D07)', () => {
204
+ const scope = fresh('suppress');
205
+ const gate = store.suggestionGate(scope, { title: '发布前跑文案检查', summary: '发版前先跑 lint' });
206
+ assert.equal(gate.ok, true);
207
+ const noted = store.noteSuggestion(scope, { title: '发布前跑文案检查', summary: '发版前先跑 lint', body: 'x' });
208
+ const rejected = store.rejectCandidate(scope, noted.id);
209
+ assert.equal(rejected.ok, true);
210
+ const second = store.suggestionGate(scope, { title: '发布前跑文案检查', summary: '发版前先跑 lint' });
211
+ assert.equal(second.ok, false);
212
+
213
+ const saved = store.upsert(scope, { title: '要忘掉的一条', summary: '这条会被彻底忘掉', body: '旧正文-哨兵' });
214
+ store.upsert(scope, { id: saved.record.id, summary: '这条会被彻底忘掉', body: '新正文-哨兵' });
215
+ store.forget(scope, saved.record.id, { mode: 'purge' });
216
+ assert.equal(store.loadIndex(scope).entries.some((item) => item.id === saved.record.id), false);
217
+ const replay = store.suggestionGate(scope, { title: '要忘掉的一条', summary: '这条会被彻底忘掉' });
218
+ assert.equal(replay.ok, false);
219
+ });
220
+
221
+ test('彻底忘掉后受管副本里搜不到旧内容(D06)', () => {
222
+ const scope = fresh('hygiene');
223
+ const saved = store.upsert(scope, { title: '要清掉的一条', summary: '会被彻底忘掉', body: '旧正文-哨兵' });
224
+ store.upsert(scope, { id: saved.record.id, summary: '会被彻底忘掉', body: '第二版-哨兵' });
225
+ store.upsert(scope, { title: '留下的一条', summary: '留着', body: '留下的正文' });
226
+ store.forget(scope, saved.record.id, { mode: 'purge' });
227
+ const leftovers = [];
228
+ const walk = (dir) => {
229
+ for (const name of fs.readdirSync(dir)) {
230
+ const full = path.join(dir, name);
231
+ if (fs.statSync(full).isDirectory()) walk(full);
232
+ else if (fs.readFileSync(full).includes('哨兵')) leftovers.push(full);
233
+ }
234
+ };
235
+ walk(scope.dir);
236
+ assert.deepEqual(leftovers, []);
237
+ const remains = store.search(scope, '要清掉的一条');
238
+ assert.equal(remains.some((item) => item.id === saved.record.id || item.title === '要清掉的一条'), false);
239
+ });
240
+
241
+ test('没指定归档还是彻底忘掉时不动盘(D05)', () => {
242
+ const scope = fresh('undecided');
243
+ const saved = store.upsert(scope, { title: '先别动', summary: '需要用户点明怎么处理', body: '正文' });
244
+ const decision = store.forget(scope, saved.record.id);
245
+ assert.equal(decision.mode, 'needs_resolution');
246
+ assert.equal(store.readEntry(scope, saved.record.id).body, '正文');
247
+ const archived = store.forget(scope, saved.record.id, { mode: 'archive' });
248
+ assert.equal(archived.mode, 'archive');
249
+ assert.equal(store.loadIndex(scope).entries.length, 0);
250
+ assert.equal(store.loadIndex(scope).archived.some((item) => item.id === saved.record.id), true);
251
+ const suggestedAgain = store.suggestionGate(scope, { title: '先别动', summary: '需要用户点明怎么处理' });
252
+ assert.equal(suggestedAgain.ok, false);
253
+ assert.match(suggestedAgain.reason, /忘掉/);
254
+ });
255
+
256
+ for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
257
+ console.log(`view.test.mjs: ${passed} passed`);