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,436 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import * as anchor from '../anchor.js';
4
+ import * as view from '../view.js';
5
+
6
+ const base = () => ({
7
+ projectId: '/work/demo',
8
+ sessionId: 's1',
9
+ messageIds: ['m1'],
10
+ query: '帮我把这期视频的开头改快一点',
11
+ registeredSeries: ['口播', 'Vlog'],
12
+ now: Date.now(),
13
+ });
14
+
15
+ test('R01 首个自由任务只建 unbound 作品壳,读取只给索引', () => {
16
+ const routed = anchor.routeTask(anchor.emptyTasks(), base());
17
+ assert.equal(routed.anchor.status, 'unbound');
18
+ assert.equal(routed.anchor.taskKind, undefined);
19
+ assert.equal(routed.anchor.seriesId, undefined);
20
+ const decision = anchor.decideRead({
21
+ mode: 'adaptive', enabled: true, identityConfirmed: true,
22
+ anchor: routed.anchor, ranges: ['口播', 'Vlog', '访谈'],
23
+ });
24
+ assert.equal(decision.kind, 'index');
25
+ assert.deepEqual(decision.ranges, ['口播', 'Vlog', '访谈']);
26
+ assert.ok(!('rules' in decision));
27
+ });
28
+
29
+ test('R02 前台选择只能把锚点升为 proposed,不冒充 active', () => {
30
+ const first = anchor.routeTask(anchor.emptyTasks(), base());
31
+ const selected = anchor.applySelection(first.tasks, {
32
+ sessionId: 's1', batchKey: first.anchor.batchKey, messageIds: ['m1'],
33
+ selection: { seriesId: '口播' }, now: base().now,
34
+ });
35
+ assert.equal(selected.anchor.status, 'proposed');
36
+ assert.equal(selected.anchor.seriesId, '口播');
37
+ assert.equal(selected.episode.status, 'proposed');
38
+ });
39
+
40
+ test('R03 再做一期创建新 episode,继承系列并回到 brief', () => {
41
+ const first = anchor.routeTask(anchor.emptyTasks(), {
42
+ ...base(), selection: { seriesId: '口播' },
43
+ });
44
+ const next = anchor.routeTask(first.tasks, {
45
+ ...base(), messageIds: ['m2'], query: '再做一期口播,讲同一套开场',
46
+ });
47
+ assert.equal(next.decision.kind, 'continuation');
48
+ assert.notEqual(next.anchor.episodeId, first.anchor.episodeId);
49
+ assert.equal(next.anchor.seriesId, '口播');
50
+ assert.equal(next.anchor.phase, 'brief');
51
+ assert.equal(next.episode.previousEpisodeId, first.anchor.episodeId);
52
+ assert.equal(next.episode.status, 'continuable');
53
+ });
54
+
55
+ test('R04 新一期不继承上一期的主题、脚本和临时例外', () => {
56
+ const first = anchor.routeTask(anchor.emptyTasks(), {
57
+ ...base(), selection: { seriesId: '口播' },
58
+ });
59
+ const next = anchor.routeTask(first.tasks, {
60
+ ...base(), messageIds: ['m2'], query: '再来一期',
61
+ });
62
+ for (const key of ['theme', 'script', 'client', 'music', 'exceptions']) {
63
+ assert.equal(next.anchor[key], undefined, `${key} 不应被继承`);
64
+ }
65
+ });
66
+
67
+ test('R05 新会话有明确项目默认时建立 active 锚点', () => {
68
+ const tasks = anchor.setDefault(anchor.emptyTasks(), { seriesId: '口播', taskKind: 'video' });
69
+ const routed = anchor.routeTask(tasks, { ...base(), sessionId: 's2' });
70
+ assert.equal(routed.anchor.status, 'active');
71
+ assert.equal(routed.anchor.seriesId, '口播');
72
+ assert.equal(routed.anchor.taskKind, 'video');
73
+ });
74
+
75
+ test('R06 明确改做别的任务时退出旧焦点,不把视频规则带过去', () => {
76
+ const first = anchor.routeTask(anchor.emptyTasks(), {
77
+ ...base(), selection: { seriesId: '口播' },
78
+ });
79
+ const switched = anchor.routeTask(first.tasks, {
80
+ ...base(), messageIds: ['m2'], query: '改做 README 和计费说明',
81
+ });
82
+ assert.equal(switched.anchor.episodeId === first.anchor.episodeId, false);
83
+ assert.equal(switched.anchor.seriesId, undefined);
84
+ assert.equal(switched.anchor.status, 'unbound');
85
+ assert.equal(switched.decision.switched, true);
86
+ });
87
+
88
+ test('R07 只说谢谢不注入规则,但保留后续可续指针', () => {
89
+ const first = anchor.routeTask(anchor.emptyTasks(), {
90
+ ...base(), selection: { seriesId: '口播' },
91
+ });
92
+ const thanks = anchor.routeTask(first.tasks, { ...base(), messageIds: ['m2'], query: '谢谢!' });
93
+ assert.equal(thanks.decision.kind, 'none');
94
+ assert.equal(thanks.decision.pointer, true);
95
+ const decision = anchor.decideRead({
96
+ mode: 'adaptive', enabled: true, identityConfirmed: true,
97
+ anchor: thanks.anchor, ranges: ['口播'], thanks: true,
98
+ });
99
+ assert.equal(decision.kind, 'none');
100
+ });
101
+
102
+ test('R08 未知阶段的条件只算 needs_context,不当成适用', () => {
103
+ const record = {
104
+ id: 'mem_x', title: '口播开头', applicability: 'conditional',
105
+ conditions: [{ key: 'seriesId', value: '口播' }, { key: 'phase', value: 'editing' }],
106
+ };
107
+ const context = view.trustedContext({ cwd: '/work/demo' }, { seriesId: '口播' });
108
+ const evaluated = view.evaluate(record, context);
109
+ assert.equal(evaluated.state, 'unknown');
110
+ assert.deepEqual(evaluated.missing, ['phase']);
111
+ });
112
+
113
+ test('R09 任务条件键可由已确认绑定进入可信上下文', () => {
114
+ const context = view.trustedContext({ cwd: '/work/demo' }, {
115
+ taskKind: 'video', seriesId: '口播', phase: 'brief',
116
+ });
117
+ assert.equal(context.taskKind, 'video');
118
+ assert.equal(context.seriesId, '口播');
119
+ assert.equal(context.phase, 'brief');
120
+ });
121
+
122
+ test('R10 同一批次重放只生成一个 episode', () => {
123
+ const first = anchor.routeTask(anchor.emptyTasks(), {
124
+ ...base(), selection: { seriesId: '口播' },
125
+ });
126
+ const again = anchor.routeTask(first.tasks, {
127
+ ...base(), selection: { seriesId: '口播' },
128
+ });
129
+ assert.equal(again.anchor.episodeId, first.anchor.episodeId);
130
+ assert.equal(Object.keys(again.tasks.episodes).length, 1);
131
+ });
132
+
133
+ test('R10b 同一延续请求重试只生成一个新 episode', () => {
134
+ const first = anchor.routeTask(anchor.emptyTasks(), {
135
+ ...base(), selection: { seriesId: '口播' },
136
+ });
137
+ const a = anchor.routeTask(first.tasks, { ...base(), messageIds: ['m2', 'm3'], query: '再做一期' });
138
+ const b = anchor.routeTask(a.tasks, { ...base(), messageIds: ['m3', 'm2'], query: '再做一期' });
139
+ assert.equal(b.anchor.episodeId, a.anchor.episodeId);
140
+ assert.equal(Object.keys(b.tasks.episodes).length, 2);
141
+ });
142
+
143
+ test('R11 请求批次只由本轮新人类消息决定', () => {
144
+ assert.equal(anchor.requestBatchKey(['b', 'a']), anchor.requestBatchKey(['a', 'b', 'a']));
145
+ assert.notEqual(anchor.requestBatchKey(['a']), anchor.requestBatchKey(['a', 'b']));
146
+ assert.equal(anchor.requestBatchKey([]), '');
147
+ });
148
+
149
+ test('R12 contextId 带项目摘要,跨项目直接识别', () => {
150
+ const id = anchor.contextIdFor('/work/demo', 'rb_1', 'n');
151
+ assert.equal(anchor.projectTagOf(id), anchor.projectTag('/work/demo'));
152
+ assert.notEqual(anchor.projectTagOf(id), anchor.projectTag('/work/other'));
153
+ });
154
+
155
+ test('R13 未绑定的索引最多三个栏目且不超过 600 字节', () => {
156
+ const decision = anchor.decideRead({
157
+ mode: 'adaptive', enabled: true, identityConfirmed: true, anchor: { status: 'unbound' },
158
+ ranges: ['口播', 'Vlog', '访谈', '广告'],
159
+ });
160
+ assert.equal(decision.ranges.length, 3);
161
+ assert.ok(Buffer.byteLength(decision.ranges.join('、'), 'utf8') <= anchor.TASK_LIMITS.indexBytes);
162
+ });
163
+
164
+ test('R14 实验关闭与身份未确认都不读', () => {
165
+ assert.equal(anchor.decideRead({ mode: 'adaptive', enabled: false, identityConfirmed: true, anchor: { status: 'active' } }).kind, 'none');
166
+ assert.equal(anchor.decideRead({ mode: 'adaptive', enabled: true, identityConfirmed: false, anchor: { status: 'active' } }).kind, 'none');
167
+ assert.equal(anchor.decideRead({ mode: 'off', enabled: true, identityConfirmed: true, anchor: { status: 'active' } }).kind, 'none');
168
+ });
169
+
170
+ test('R15 proposed 自动路径只给索引,前台绑定后才给规则', () => {
171
+ const auto = anchor.decideRead({
172
+ mode: 'adaptive', enabled: true, identityConfirmed: true,
173
+ anchor: { status: 'proposed', seriesId: '口播' }, ranges: ['口播'],
174
+ });
175
+ assert.equal(auto.kind, 'index');
176
+ const bound = anchor.decideRead({
177
+ mode: 'adaptive', enabled: true, identityConfirmed: true, boundForRequest: true,
178
+ anchor: { status: 'proposed', seriesId: '口播' }, ranges: ['口播'],
179
+ });
180
+ assert.equal(bound.kind, 'rules');
181
+ });
182
+
183
+ test('R16 会话指针硬上限 8,超出按最近更新保留', () => {
184
+ let tasks = anchor.emptyTasks();
185
+ for (let i = 0; i < 12; i += 1) {
186
+ const routed = anchor.routeTask(tasks, {
187
+ ...base(), sessionId: `s${i}`, messageIds: [`m${i}`], now: base().now + i * 1000,
188
+ });
189
+ tasks = routed.tasks;
190
+ }
191
+ const normalized = anchor.normalizeTasks(tasks);
192
+ assert.equal(Object.keys(normalized.sessions).length, 8);
193
+ assert.equal(normalized.sessions.s11.seriesId, undefined);
194
+ assert.equal(normalized.sessions.s0, undefined);
195
+ });
196
+
197
+ test('R17 切换任务后旧 episode 不再被当前会话引用', () => {
198
+ const first = anchor.routeTask(anchor.emptyTasks(), {
199
+ ...base(), selection: { seriesId: '口播' },
200
+ });
201
+ const switched = anchor.routeTask(first.tasks, {
202
+ ...base(), messageIds: ['m2'], query: '换个任务,做代码审查',
203
+ });
204
+ assert.notEqual(switched.anchor.episodeId, first.anchor.episodeId);
205
+ assert.equal(switched.tasks.sessions.s1.episodeId, switched.anchor.episodeId);
206
+ assert.equal(switched.tasks.episodes[first.anchor.episodeId].status, 'proposed');
207
+ });
208
+
209
+ test('R18 自由措辞不能把未注册栏目变成已绑定', () => {
210
+ const routed = anchor.routeTask(anchor.emptyTasks(), {
211
+ ...base(), query: '做一期美食节目', registeredSeries: ['口播', 'Vlog'],
212
+ });
213
+ assert.equal(routed.anchor.status, 'unbound');
214
+ assert.equal(routed.anchor.seriesId, undefined);
215
+ });
216
+
217
+ test('R12 无切换词的新任务撤销旧作品绑定,不继承栏目和规则', () => {
218
+ const sessionId = 'sess-r12';
219
+ let tasks = anchor.emptyTasks();
220
+ const first = anchor.routeTask(tasks, {
221
+ sessionId,
222
+ batchKey: 'rb_first',
223
+ messageIds: ['m1'],
224
+ query: '帮我把这期口播视频的开头改一下',
225
+ selection: { seriesId: '口播', taskKind: '口播视频' },
226
+ now: 1000,
227
+ });
228
+ assert.equal(first.anchor.status, 'proposed');
229
+ const bound = anchor.applySelection(first.tasks, { sessionId, selection: { seriesId: '口播' }, batchKey: 'rb_first', messageIds: ['m1'], now: 1500 });
230
+ assert.equal(bound.anchor.status, 'proposed');
231
+ const active = bound.tasks.sessions[sessionId];
232
+ active.status = 'active';
233
+ tasks = bound.tasks;
234
+ const next = anchor.routeTask(tasks, {
235
+ sessionId,
236
+ batchKey: 'rb_next',
237
+ messageIds: ['m2'],
238
+ query: '帮我排查 PostgreSQL 连接超时',
239
+ registeredSeries: ['口播'],
240
+ now: 2000,
241
+ });
242
+ assert.equal(next.anchor.status, 'unbound');
243
+ assert.equal(next.decision.kind, 'unbound');
244
+ assert.equal(next.decision.reason, 'new_task');
245
+ assert.ok(next.episode.previousEpisodeId);
246
+
247
+ /** 新任务已撤销旧绑定:旧栏目规则不能再自动进入这一步。 */
248
+ const read = anchor.decideRead({
249
+ enabled: true,
250
+ identityConfirmed: true,
251
+ anchor: next.anchor,
252
+ ranges: [{ seriesId: '口播', taskKind: '口播视频', reason: '旧任务' }],
253
+ });
254
+ assert.equal(read.kind, 'index');
255
+ assert.equal(read.rules, undefined);
256
+ });
257
+
258
+ test('R13 继续改/接着做仍继承同一任务', () => {
259
+ let tasks = anchor.emptyTasks();
260
+ const first = anchor.routeTask(tasks, {
261
+ sessionId: 'sess-r13',
262
+ batchKey: 'rb_a',
263
+ messageIds: ['m1'],
264
+ query: '帮我把这期口播视频的开头改一下',
265
+ selection: { seriesId: '口播' },
266
+ now: 1000,
267
+ });
268
+ tasks = first.tasks;
269
+ const next = anchor.routeTask(tasks, {
270
+ sessionId: 'sess-r13',
271
+ batchKey: 'rb_b',
272
+ messageIds: ['m2'],
273
+ query: '继续改,节奏再快一点',
274
+ now: 2000,
275
+ });
276
+ assert.equal(next.anchor.status, 'continuable');
277
+ assert.equal(next.decision.kind, 'continuation');
278
+ });
279
+
280
+ test('R14 无候选栏目时返回 none,不再要求选栏目', () => {
281
+ const read = anchor.decideRead({ enabled: true, identityConfirmed: true, anchor: null, ranges: [] });
282
+ assert.equal(read.kind, 'none');
283
+ assert.equal(read.reason, 'no_range');
284
+ });
285
+
286
+ /** 已绑定的口播任务:用于验证换任务时旧范围确实被撤销。 */
287
+ function activeVideoSession() {
288
+ const first = anchor.routeTask(anchor.emptyTasks(), {
289
+ sessionId: 'sess-switch',
290
+ batchKey: 'rb_video',
291
+ messageIds: ['m1'],
292
+ query: '帮我把这期口播视频的开头改一下',
293
+ selection: { seriesId: '口播', taskKind: '口播视频' },
294
+ now: 1000,
295
+ });
296
+ const anchorState = first.tasks.sessions['sess-switch'];
297
+ anchorState.status = 'active';
298
+ return first.tasks;
299
+ }
300
+
301
+ test('R20 带「继续」但出现新对象时,先撤销旧范围', () => {
302
+ const tasks = activeVideoSession();
303
+ const next = anchor.routeTask(tasks, {
304
+ sessionId: 'sess-switch',
305
+ batchKey: 'rb_pg_continue',
306
+ messageIds: ['m2'],
307
+ query: '接下来继续排查 PostgreSQL 连接超时',
308
+ registeredSeries: ['口播'],
309
+ now: 2000,
310
+ });
311
+ assert.equal(next.decision.kind, 'unbound');
312
+ assert.equal(next.decision.reason, 'new_task');
313
+ assert.equal(next.anchor.seriesId, undefined);
314
+ assert.equal(next.episode.previousEpisodeId, tasks.sessions['sess-switch'].episodeId);
315
+ });
316
+
317
+ test('R21 带「这个」但出现新对象时,不沿用旧范围', () => {
318
+ const tasks = activeVideoSession();
319
+ const next = anchor.routeTask(tasks, {
320
+ sessionId: 'sess-switch',
321
+ batchKey: 'rb_pg_this',
322
+ messageIds: ['m2'],
323
+ query: '现在帮我排查这个 PostgreSQL 连接超时',
324
+ registeredSeries: ['口播'],
325
+ now: 2000,
326
+ });
327
+ assert.equal(next.decision.kind, 'unbound');
328
+ assert.equal(next.decision.reason, 'new_task');
329
+ assert.equal(next.anchor.seriesId, undefined);
330
+ });
331
+
332
+ test('R22 真正的续改保留原任务范围', () => {
333
+ const tasks = activeVideoSession();
334
+ const next = anchor.routeTask(tasks, {
335
+ sessionId: 'sess-switch',
336
+ batchKey: 'rb_video_continue',
337
+ messageIds: ['m2'],
338
+ query: '继续改刚才那版脚本,开头再直接一点',
339
+ registeredSeries: ['口播'],
340
+ now: 2000,
341
+ });
342
+ assert.equal(next.decision.kind, 'continuation');
343
+ assert.equal(next.anchor.seriesId, '口播');
344
+ assert.equal(next.episode.previousEpisodeId, tasks.sessions['sess-switch'].episodeId);
345
+ });
346
+
347
+ test('R23 只指代旧稿的局部修改不被当成换任务', () => {
348
+ const tasks = activeVideoSession();
349
+ const next = anchor.routeTask(tasks, {
350
+ sessionId: 'sess-switch',
351
+ batchKey: 'rb_video_tweak',
352
+ messageIds: ['m2'],
353
+ query: '现在把开头改短一点',
354
+ registeredSeries: ['口播'],
355
+ now: 2000,
356
+ });
357
+ assert.equal(next.decision.kind, 'focus');
358
+ assert.equal(next.anchor.seriesId, '口播');
359
+ });
360
+
361
+ test('R24 继续做新领域的中文任务也算换任务', () => {
362
+ const tasks = activeVideoSession();
363
+ const next = anchor.routeTask(tasks, {
364
+ sessionId: 'sess-switch',
365
+ batchKey: 'rb_research',
366
+ messageIds: ['m2'],
367
+ query: '继续看看竞品定价',
368
+ registeredSeries: ['口播'],
369
+ now: 2000,
370
+ });
371
+ assert.equal(next.decision.kind, 'unbound');
372
+ assert.equal(next.decision.reason, 'new_task');
373
+ });
374
+
375
+ test('R25 单独说「继续」不沿用旧任务范围', () => {
376
+ const tasks = activeVideoSession();
377
+ const previous = tasks.sessions['sess-switch'];
378
+ const next = anchor.routeTask(tasks, {
379
+ sessionId: 'sess-switch',
380
+ batchKey: 'rb_weak_continue',
381
+ messageIds: ['m2'],
382
+ query: '继续',
383
+ registeredSeries: ['口播'],
384
+ now: 2000,
385
+ });
386
+ assert.equal(next.decision.kind, 'unbound');
387
+ assert.equal(next.decision.reason, 'new_task');
388
+ assert.equal(next.anchor.seriesId, undefined);
389
+ assert.equal(next.episode.previousEpisodeId, previous.episodeId);
390
+ });
391
+
392
+ test('R26 单独说「这个」不沿用旧任务范围', () => {
393
+ const tasks = activeVideoSession();
394
+ const next = anchor.routeTask(tasks, {
395
+ sessionId: 'sess-switch',
396
+ batchKey: 'rb_weak_this',
397
+ messageIds: ['m2'],
398
+ query: '这个',
399
+ registeredSeries: ['口播'],
400
+ now: 2000,
401
+ });
402
+ assert.equal(next.decision.kind, 'unbound');
403
+ assert.equal(next.decision.reason, 'new_task');
404
+ assert.equal(next.anchor.seriesId, undefined);
405
+ });
406
+
407
+ test('R27 单独说「之前」不沿用旧任务范围', () => {
408
+ const tasks = activeVideoSession();
409
+ const next = anchor.routeTask(tasks, {
410
+ sessionId: 'sess-switch',
411
+ batchKey: 'rb_weak_before',
412
+ messageIds: ['m2'],
413
+ query: '之前',
414
+ registeredSeries: ['口播'],
415
+ now: 2000,
416
+ });
417
+ assert.equal(next.decision.kind, 'unbound');
418
+ assert.equal(next.decision.reason, 'new_task');
419
+ assert.equal(next.anchor.seriesId, undefined);
420
+ });
421
+
422
+ test('R28 指代加动作的续接仍保留旧任务范围', () => {
423
+ const tasks = activeVideoSession();
424
+ const previous = tasks.sessions['sess-switch'];
425
+ const next = anchor.routeTask(tasks, {
426
+ sessionId: 'sess-switch',
427
+ batchKey: 'rb_strong_continue',
428
+ messageIds: ['m2'],
429
+ query: '把刚才那个再调一下',
430
+ registeredSeries: ['口播'],
431
+ now: 2000,
432
+ });
433
+ assert.notEqual(next.decision.kind, 'unbound');
434
+ assert.equal(next.anchor.episodeId, previous.episodeId);
435
+ assert.equal(next.anchor.seriesId, '口播');
436
+ });
@@ -0,0 +1,114 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ let seen = null;
4
+ let plugin = null;
5
+ const fakeReact = { createElement: () => null, useRef: (value) => ({ current: value }), useState: (value) => [value, () => {}], useEffect: () => {}, useMemo: (fn) => fn() };
6
+ globalThis.window = { __ModuleLoader__: { load: (definition) => { plugin = definition.factory((name) => (name === 'react' ? fakeReact : null)); } } };
7
+ globalThis.__LITE_MEMORY_TEST__ = (helpers) => { seen = helpers; };
8
+ await import('../client.js');
9
+ assert.ok(seen, 'client test seam missing');
10
+
11
+ // 会话头的项目开关:注册到宿主认的位置,状态只能问服务端。
12
+ const registrations = [];
13
+ const slots = {
14
+ inject: (_name, callback) => callback(),
15
+ register: (definition) => { registrations.push(definition); return () => {}; },
16
+ };
17
+ plugin.apply({ slots, sessions: { binding: () => undefined }, uiConversation: {} });
18
+ const evolution = registrations.find((item) => item.id === 'lite-memory:project-evolution');
19
+ assert.ok(evolution, '项目开关没有注册');
20
+ assert.equal(evolution.name, 'conversation.session.header.utilities');
21
+ assert.equal(evolution.order, 30);
22
+
23
+ // 记忆面板占整框浮层,不挤在会话列里。
24
+ const panel = registrations.find((item) => item.id === 'lite-memory:panel');
25
+ assert.ok(panel, '记忆面板没有注册');
26
+ assert.equal(panel.name, 'shell.overlay');
27
+ assert.equal(panel.order, 30);
28
+
29
+ const { metaOf, citationOf, citationsOfMeta, citationsOfTurn, dedupe, undoRefOf, graphOf, ruleGraphOf } = seen;
30
+
31
+ // 图谱只按真实数据画:共享标签成枢纽,显式关联成边,位置由 id 决定。
32
+ const graph = graphOf([
33
+ { id: 'mem_aaaaaaaa', title: 'A', tags: ['发布'], topicKey: '上线', experimental: false, links: [{ id: 'mem_bbbbbbbb' }] },
34
+ { id: 'mem_bbbbbbbb', title: 'B', tags: ['发布'], topicKey: '上线', experimental: true, links: [] },
35
+ { id: 'mem_cccccccc', title: 'C', tags: ['环境'], topicKey: '', experimental: false, links: [] },
36
+ ]);
37
+ assert.equal(graph.nodes.filter((node) => node.kind === 'memory').length, 3);
38
+ assert.equal(graph.nodes.some((node) => node.id === 'tag:发布'), true);
39
+ assert.equal(graph.nodes.some((node) => node.id === 'tag:环境'), true);
40
+ assert.equal(graph.nodes.some((node) => node.id === 'topic:上线'), true);
41
+ assert.equal(graph.edges.filter((edge) => edge.type === 'tag').length, 3);
42
+ assert.equal(graph.edges.filter((edge) => edge.type === 'link').length, 1);
43
+ assert.deepEqual(graphOf([{ id: 'mem_aaaaaaaa', title: 'A' }]).nodes[0].x, graphOf([{ id: 'mem_aaaaaaaa', title: 'A' }]).nodes[0].x);
44
+ const pair = graphOf([{ id: 'mem_aaaaaaaa', title: 'A' }, { id: 'mem_bbbbbbbb', title: 'B' }]);
45
+ assert.notEqual(pair.nodes[0].x, pair.nodes[1].x);
46
+ assert.notEqual(pair.nodes[0].y, pair.nodes[1].y);
47
+
48
+ // 经验图谱只按共享的适用条件相连,不画没有依据的关系。
49
+ const rules = [
50
+ { id: 'ev_aaaaaaaa', title: 'A', state: 'active', conditions: [{ key: 'seriesId', value: '口播' }] },
51
+ { id: 'ev_bbbbbbbb', title: 'B', state: 'trial', conditions: [{ key: 'seriesId', value: '口播' }, { key: 'phase', value: '初稿' }] },
52
+ { id: 'ev_cccccccc', title: 'C', state: 'pending', conditions: [{ key: 'phase', value: '初稿' }] },
53
+ ];
54
+ const ruleGraph = ruleGraphOf(rules);
55
+ assert.equal(ruleGraph.nodes.filter((node) => node.kind === 'rule').length, 3);
56
+ assert.equal(ruleGraph.nodes.some((node) => node.id === 'condition:seriesId:口播'), true);
57
+ assert.equal(ruleGraph.nodes.some((node) => node.id === 'condition:phase:初稿'), true);
58
+ assert.equal(ruleGraph.edges.length, 4);
59
+ assert.equal(ruleGraph.edges.every((edge) => edge.type === 'condition'), true);
60
+ assert.equal(ruleGraph.nodes.some((node) => node.kind === 'tag' || node.kind === 'topic'), false);
61
+ assert.deepEqual(ruleGraphOf(rules).nodes.map((node) => [node.id, node.x, node.y]), ruleGraph.nodes.map((node) => [node.id, node.x, node.y]));
62
+ assert.equal(ruleGraphOf([{ id: 'ev_aaaaaaaa', title: 'A', state: 'active', conditions: [] }]).edges.length, 0);
63
+
64
+ const settled = (meta, extra = {}) => ({ kind: 'tool-result', meta, isError: false, content: [], ...extra });
65
+
66
+ // 元数据只从落定结果里取,错误块和旧块一律不认。
67
+ assert.equal(metaOf(settled({ kind: 'memory-read', id: 'mem_aaaaaaaa' })).kind, 'memory-read');
68
+ assert.equal(metaOf({ phase: 'preparing' }), null);
69
+ assert.equal(metaOf(settled({ kind: 'memory-read', id: 'mem_aaaaaaaa' }, { isError: true })), null);
70
+ assert.equal(metaOf(settled(null)), null);
71
+ assert.equal(metaOf(settled([{ kind: 'memory-read' }])), null);
72
+
73
+ // 保存只在真的改了时进引用;正文保存不冒充引用。
74
+ const save = citationOf({ kind: 'memory-save', id: 'mem_aaaaaaaa', title: 'A', scope: 'project', revision: 3, changed: false, mode: 'unchanged' }, 'memory-save');
75
+ assert.equal(save, null);
76
+ const changed = citationOf({ kind: 'memory-save', id: 'mem_aaaaaaaa', title: 'A', scope: 'global', revision: 2, changed: true, mode: 'updated' }, 'memory-save');
77
+ assert.equal(changed.exposure, 'update');
78
+ assert.equal(changed.scope, 'global');
79
+
80
+ // 没有真改动、缺版本、缺工作目录的写入行不产生撤销入口。
81
+ assert.equal(undoRefOf({ kind: 'memory-save', id: 'mem_aaaaaaaa', revision: 3, changed: false }, '/tmp/p'), null);
82
+ assert.equal(undoRefOf({ kind: 'memory-save', id: 'mem_aaaaaaaa', revision: 3 }, '/tmp/p'), null);
83
+ assert.equal(undoRefOf({ kind: 'memory-save', id: 'mem_aaaaaaaa', revision: 3, changed: true }, ''), null);
84
+ assert.equal(undoRefOf({ kind: 'memory-save', id: 'mem_aaaaaaaa', revision: 2, changed: true }, '/tmp/p').revision, 2);
85
+
86
+ // 搜索的多条引用按 refs 展开;删除/归档状态原样保留。
87
+ const refs = citationsOfMeta({ kind: 'memory-search', refs: [
88
+ { id: 'mem_aaaaaaaa', title: 'A', scope: 'project', revision: 1, status: 'ok' },
89
+ { id: 'mem_bbbbbbbb', title: 'B', scope: 'global', revision: 4, status: 'archived' },
90
+ { id: 'sug_cccccccc', title: 'C', scope: 'global', revision: 1, status: 'ok' },
91
+ ] });
92
+ assert.equal(refs.length, 2);
93
+ assert.equal(refs[1].status, 'archived');
94
+ assert.equal(refs[0].exposure, 'reference');
95
+
96
+ // 一轮里 PTC 子结果也算;同 id 同用途只留一条,位置不变、内容取最新。
97
+ const nodes = [
98
+ { root: settled({ kind: 'memory-search', refs: [
99
+ { id: 'mem_aaaaaaaa', title: 'A', scope: 'project', revision: 1, status: 'ok' },
100
+ ] }) },
101
+ { root: settled({ kind: 'memory-read', id: 'mem_aaaaaaaa', title: 'A2', scope: 'project', revision: 2, status: 'ok' }, { subCalls: [settled({ kind: 'memory-read', id: 'mem_aaaaaaaa', title: 'A3', scope: 'project', revision: 2, status: 'ok' })] }) },
102
+ { root: settled({ kind: 'memory-read', id: 'mem_bbbbbbbb', title: 'B', scope: 'global', revision: 1, status: 'ok' }) },
103
+ ];
104
+ const turn = dedupe(citationsOfTurn(nodes));
105
+ assert.equal(turn.length, 2);
106
+ assert.equal(turn[0].id, 'mem_aaaaaaaa');
107
+ assert.equal(turn[0].revision, 2);
108
+ assert.equal(turn[0].title, 'A3');
109
+ assert.equal(turn[1].id, 'mem_bbbbbbbb');
110
+
111
+ // 旧行没有元数据:什么也不产出,绝不从文案猜 id。
112
+ assert.deepEqual(citationsOfTurn([{ root: null }, { root: { kind: 'tool-result', content: [{ type: 'text', text: 'memory · saved "A" mem_aaaaaaaa' }] } }]), []);
113
+
114
+ console.log('client.test.mjs: 11 passed');