dsh-lite-memory 0.16.2 → 0.17.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.
@@ -524,3 +524,243 @@ test('R42 栏目只被提到、否定或搁置时都不算用户选中', () => {
524
524
  assert.equal(anchor.seriesChosenBy('日志里提到口播吗', '口播'), false);
525
525
  assert.equal(anchor.seriesChosenBy('先放着口播,改数据库', '口播'), false);
526
526
  });
527
+
528
+ test('R43 任务类型判断:工程词优先于创作词,纯创作才归 content', () => {
529
+ assert.equal(anchor.taskKindOf('审阅口播插件的源码,修复上下文串扰问题'), 'code');
530
+ assert.equal(anchor.taskKindOf('日志里提到口播'), 'code');
531
+ assert.equal(anchor.taskKindOf('先不做口播,排查 PostgreSQL 连接池故障'), 'code');
532
+ assert.equal(anchor.taskKindOf('再做一期口播'), 'content');
533
+ assert.equal(anchor.taskKindOf('改成口播'), 'content');
534
+ assert.equal(anchor.taskKindOf('帮我看看这个'), '');
535
+ assert.equal(anchor.taskKindOf(''), '');
536
+ });
537
+
538
+ test('R44 栏目名后跟代码对象时只算读源码,不算选中栏目', () => {
539
+ assert.equal(anchor.seriesChosenBy('审阅口播插件的源码,修复上下文串扰问题', '口播'), false);
540
+ assert.equal(anchor.seriesChosenBy('检查口播插件代码', '口播'), false);
541
+ assert.equal(anchor.seriesChosenBy('口播源码里日志有问题', '口播'), false);
542
+ assert.equal(anchor.seriesChosenBy('再做一期口播', '口播'), true);
543
+ assert.equal(anchor.seriesChosenBy('改成口播', '口播'), true);
544
+ });
545
+
546
+ test('R45 明确撤销栏目:先不做口播优先于续作和旧栏目', () => {
547
+ const tasks = activeVideoSession();
548
+ const next = anchor.routeTask(tasks, {
549
+ sessionId: 'sess-switch',
550
+ batchKey: 'rb_revoke',
551
+ messageIds: ['m2'],
552
+ query: '先不做口播,排查 PostgreSQL 连接池故障',
553
+ registeredSeries: ['口播'],
554
+ now: 2000,
555
+ });
556
+ assert.equal(next.decision.kind, 'revoked');
557
+ assert.deepEqual(next.decision.revoked, ['口播']);
558
+ assert.equal(next.anchor.status, 'active');
559
+ assert.equal(next.anchor.seriesId, undefined);
560
+ assert.equal(next.anchor.taskKind, 'code');
561
+ assert.equal(next.episode.taskKind, 'code');
562
+ assert.equal(next.episode.previousEpisodeId, tasks.sessions['sess-switch'].episodeId);
563
+ assert.equal(anchor.revokeSeriesBy('不要按口播的规矩来', ['口播']).length, 0);
564
+ assert.deepEqual(anchor.revokeSeriesBy('先不做口播,排查故障', ['口播', 'Vlog']), ['口播']);
565
+ });
566
+
567
+ test('R46 读源码/提到栏目都不建内容绑定,项目默认值不能趁机补栏目', () => {
568
+ const tasks = anchor.emptyTasks();
569
+ tasks.defaults = { taskKind: 'video', seriesId: '口播' };
570
+ const reading = anchor.routeTask(tasks, {
571
+ sessionId: 'sess-reading', batchKey: 'rb_reading', messageIds: ['m1'],
572
+ query: '审阅口播插件的源码,修复上下文串扰问题', registeredSeries: ['口播'], now: 1000,
573
+ });
574
+ assert.equal(reading.anchor.seriesId, undefined);
575
+ assert.equal(reading.episode.taskKind, 'code');
576
+ assert.notEqual(reading.decision.kind, 'selected');
577
+ assert.notEqual(reading.decision.kind, 'default');
578
+
579
+ const mention = anchor.routeTask(tasks, {
580
+ sessionId: 'sess-mention', batchKey: 'rb_mention', messageIds: ['m1'],
581
+ query: '日志里提到口播', registeredSeries: ['口播'], now: 1000,
582
+ });
583
+ assert.equal(mention.anchor.seriesId, undefined);
584
+ assert.equal(mention.anchor.taskKind, 'code');
585
+ });
586
+
587
+ test('R47 项目默认值只补缺失字段,不覆盖显式任务类型和撤销', () => {
588
+ const tasks = anchor.emptyTasks();
589
+ tasks.defaults = { taskKind: 'video', seriesId: '口播' };
590
+ const filled = anchor.routeTask(tasks, {
591
+ sessionId: 'sess-default', batchKey: 'rb_default', messageIds: ['m1'],
592
+ query: '继续改上次那期的开头', registeredSeries: ['口播'], now: 1000,
593
+ });
594
+ assert.equal(filled.decision.kind, 'default');
595
+ assert.equal(filled.anchor.seriesId, '口播');
596
+ assert.equal(filled.anchor.taskKind, 'video');
597
+
598
+ const explicit = anchor.routeTask(tasks, {
599
+ sessionId: 'sess-explicit-kind', batchKey: 'rb_explicit_kind', messageIds: ['m1'],
600
+ query: '帮我看看 PostgreSQL 连接池', taskKind: 'code', registeredSeries: ['口播'], now: 1000,
601
+ });
602
+ assert.equal(explicit.anchor.taskKind, 'code');
603
+ assert.equal(explicit.anchor.seriesId, undefined);
604
+ assert.equal(explicit.decision.taskKind, 'code');
605
+ assert.notEqual(explicit.decision.kind, 'default');
606
+ });
607
+
608
+ test('R48 RuntimeContext 形状确定,任务字段来自锚点与本句判断', () => {
609
+ const routed = anchor.routeTask(anchor.emptyTasks(), {
610
+ sessionId: 'sess-ctx', batchKey: 'rb_ctx', messageIds: ['m1'],
611
+ query: '审阅口播插件的源码,修复上下文串扰问题', registeredSeries: ['口播'], now: 1000,
612
+ });
613
+ const ctx = anchor.buildRuntimeContext({
614
+ projectId: '/work/demo', sessionId: 'sess-ctx', batchKey: 'rb_ctx', messageIds: ['m1'],
615
+ controlEpoch: 7, query: '继续看这段代码', anchor: routed.anchor, episode: routed.episode,
616
+ });
617
+ assert.deepEqual(Object.keys(ctx).sort(), ['controlEpoch', 'projectId', 'requestId', 'sessionId', 'task']);
618
+ assert.deepEqual(Object.keys(ctx.task).sort(), ['action', 'actionEvidence', 'artifactKey', 'conflict', 'evidence', 'kind', 'phase', 'seriesEvidence', 'seriesId', 'topic', 'topicEvidence']);
619
+ assert.equal(ctx.projectId, '/work/demo');
620
+ assert.equal(ctx.sessionId, 'sess-ctx');
621
+ assert.equal(ctx.requestId, anchor.contextIdFor('/work/demo', 'rb_ctx', routed.episode.episodeId));
622
+ assert.equal(ctx.controlEpoch, '7');
623
+ assert.equal(ctx.task.kind, 'code');
624
+ assert.equal(ctx.task.seriesId, '');
625
+ assert.equal(ctx.task.phase, 'brief');
626
+ assert.equal(ctx.task.evidence, 'query');
627
+
628
+ const inferred = anchor.buildRuntimeContext({
629
+ projectId: '/work/demo', sessionId: 'sess-ctx', batchKey: 'rb_ctx', messageIds: ['m1'],
630
+ query: '审阅口播插件的源码',
631
+ });
632
+ assert.equal(inferred.task.kind, 'code');
633
+ assert.equal(inferred.task.evidence, 'query');
634
+ });
635
+
636
+ test('R49 动作/交付物/话题分开:代码 bug 不把「口播」绑成栏目', () => {
637
+ const query = '口播功能最近有 bug,请修复代码';
638
+ const facets = anchor.taskFacetsOf(query);
639
+ assert.equal(facets.action.kind, 'fix');
640
+ assert.equal(facets.deliverable.kind, 'code');
641
+ assert.equal(facets.topic.text, '口播功能');
642
+ assert.equal(facets.topic.evidence, 'inferred');
643
+ assert.equal(anchor.seriesChosenBy(query, '口播'), false);
644
+ assert.deepEqual(anchor.revokeSeriesBy(query, ['口播']), []);
645
+
646
+ const routed = anchor.routeTask(anchor.emptyTasks(), {
647
+ sessionId: 'sess-boundary-fix', batchKey: 'rb_boundary_fix', messageIds: ['m1'],
648
+ query, registeredSeries: ['口播'], now: 1000,
649
+ });
650
+ assert.notEqual(routed.decision.kind, 'selected');
651
+ assert.equal(routed.episode.taskKind, 'code');
652
+ assert.equal(routed.episode.kindEvidence, 'query');
653
+ assert.equal(routed.anchor.seriesId, undefined);
654
+ assert.equal(routed.episode.actionKind, 'fix');
655
+
656
+ const ctx = anchor.buildRuntimeContext({
657
+ projectId: '/work/demo', sessionId: 'sess-boundary-fix', batchKey: 'rb_boundary_fix',
658
+ messageIds: ['m1'], query: '继续修', anchor: routed.anchor, episode: routed.episode,
659
+ });
660
+ assert.equal(ctx.task.kind, 'code');
661
+ assert.equal(ctx.task.evidence, 'query');
662
+ assert.equal(ctx.task.seriesId, '');
663
+ assert.equal(ctx.task.action, 'fix');
664
+ });
665
+
666
+ test('R50 内容任务:数据库事务只是话题,口播才是交付栏目', () => {
667
+ const query = '做一期口播,讲解数据库事务,给普通观众听';
668
+ const facets = anchor.taskFacetsOf(query);
669
+ assert.equal(facets.action.kind, 'create');
670
+ assert.equal(facets.deliverable.kind, 'content');
671
+ assert.equal(facets.topic.text, '数据库事务');
672
+ assert.equal(facets.topic.evidence, 'query');
673
+ assert.equal(facets.conflict, false);
674
+ assert.equal(anchor.taskKindOf(query), 'content');
675
+ assert.equal(anchor.seriesChosenBy(query, '口播'), true);
676
+ assert.equal(anchor.seriesChosenBy(query, '数据库'), false);
677
+ assert.equal(anchor.seriesChosenBy(query, '数据库事务'), false);
678
+
679
+ const routed = anchor.routeTask(anchor.emptyTasks(), {
680
+ sessionId: 'sess-boundary-content', batchKey: 'rb_boundary_content', messageIds: ['m1'],
681
+ query, registeredSeries: ['口播', '数据库', '数据库事务'], now: 1000,
682
+ });
683
+ assert.equal(routed.decision.kind, 'selected');
684
+ assert.equal(routed.anchor.seriesId, '口播');
685
+ assert.equal(routed.anchor.taskKind, 'content');
686
+ assert.equal(routed.episode.kindEvidence, 'query');
687
+ assert.equal(routed.episode.seriesEvidence, 'query');
688
+ assert.equal(routed.episode.topicText, '数据库事务');
689
+ assert.equal(routed.episode.topicEvidence, 'query');
690
+
691
+ /** 落过一次盘之后,query 推断仍不能自升成用户确认。 */
692
+ const ctx = anchor.buildRuntimeContext({
693
+ projectId: '/work/demo', sessionId: 'sess-boundary-content', batchKey: 'rb_boundary_content',
694
+ messageIds: ['m1'], query: '继续', anchor: routed.anchor, episode: routed.episode,
695
+ });
696
+ assert.equal(ctx.task.kind, 'content');
697
+ assert.equal(ctx.task.evidence, 'query');
698
+ assert.equal(ctx.task.seriesEvidence, 'query');
699
+ assert.equal(ctx.task.topic, '数据库事务');
700
+ assert.equal(ctx.task.topicEvidence, 'query');
701
+ });
702
+
703
+ test('R51 句末语气词不是撤销;只有明确的停止动作才撤销栏目', () => {
704
+ const query = '做一期口播吧';
705
+ assert.deepEqual(anchor.revokeSeriesBy(query, ['口播']), []);
706
+ assert.equal(anchor.seriesChosenBy(query, '口播'), true);
707
+ assert.equal(anchor.taskKindOf(query), 'content');
708
+
709
+ const routed = anchor.routeTask(anchor.emptyTasks(), {
710
+ sessionId: 'sess-boundary-particle', batchKey: 'rb_boundary_particle', messageIds: ['m1'],
711
+ query, registeredSeries: ['口播'], now: 1000,
712
+ });
713
+ assert.equal(routed.decision.kind, 'selected');
714
+ assert.equal(routed.anchor.seriesId, '口播');
715
+ assert.equal(routed.anchor.taskKind, 'content');
716
+ assert.equal(routed.episode.kindEvidence, 'query');
717
+
718
+ for (const soft of ['做一期口播吧', '做一期口播啦', '做一期口播了', '做一期口播吧!']) {
719
+ assert.deepEqual(anchor.revokeSeriesBy(soft, ['口播']), [], soft);
720
+ }
721
+ for (const hard of ['先不做口播了', '口播不做了', '暂停口播', '放一边口播', '口播先不做']) {
722
+ assert.deepEqual(anchor.revokeSeriesBy(hard, ['口播']), ['口播'], hard);
723
+ }
724
+ });
725
+
726
+ test('R52 中英文标点与同义词:代码对象、话题前缀、内容用法都按同一规则', () => {
727
+ for (const query of [
728
+ '口播功能最近有 bug, 请修复代码.',
729
+ '口播模块报错,帮我排查',
730
+ '口播页面有个故障!',
731
+ '口播组件接口流程逻辑都改一下',
732
+ ]) {
733
+ assert.equal(anchor.seriesChosenBy(query, '口播'), false, query);
734
+ assert.equal(anchor.taskKindOf(query), 'code', query);
735
+ }
736
+ for (const soft of ['做一期口播吧!', '做一期口播啦。', '做一期口播了?']) {
737
+ assert.equal(anchor.seriesChosenBy(soft, '口播'), true, soft);
738
+ assert.equal(anchor.taskKindOf(soft), 'content', soft);
739
+ }
740
+ /** 「做一期口播功能」是内容用法,不能因为「功能」就否掉栏目。 */
741
+ assert.equal(anchor.seriesChosenBy('做一期口播功能', '口播'), true);
742
+ assert.equal(anchor.taskKindOf('做一期口播功能'), 'content');
743
+
744
+ for (const query of ['做一期口播, 讲解数据库事务, 给普通观众听.', '做一期口播;介绍数据库事务;给普通观众听']) {
745
+ assert.equal(anchor.taskKindOf(query), 'content', query);
746
+ assert.equal(anchor.seriesChosenBy(query, '口播'), true, query);
747
+ assert.equal(anchor.seriesChosenBy(query, '数据库'), false, query);
748
+ }
749
+ for (const query of ['聊聊数据库事务', '主题是数据库事务', '围绕数据库事务讲讲']) {
750
+ assert.equal(anchor.seriesChosenBy(query, '数据库'), false, query);
751
+ assert.equal(anchor.seriesChosenBy(query, '数据库事务'), false, query);
752
+ }
753
+
754
+ const conflict = anchor.taskFacetsOf('日志里提到口播,顺便讲数据库事务');
755
+ assert.equal(conflict.conflict, true);
756
+ const routed = anchor.routeTask(anchor.emptyTasks(), {
757
+ sessionId: 'sess-boundary-conflict', batchKey: 'rb_boundary_conflict', messageIds: ['m1'],
758
+ query: '日志里提到口播,顺便讲数据库事务', registeredSeries: ['口播'], now: 1000,
759
+ });
760
+ assert.notEqual(routed.anchor.seriesId, '口播');
761
+ const ctx = anchor.buildRuntimeContext({
762
+ projectId: '/work/demo', sessionId: 'sess-boundary-conflict', batchKey: 'rb_boundary_conflict',
763
+ messageIds: ['m1'], query: '日志里提到口播,顺便讲数据库事务', anchor: routed.anchor, episode: routed.episode,
764
+ });
765
+ assert.equal(ctx.task.conflict, true);
766
+ });
@@ -2,7 +2,8 @@ import assert from 'node:assert/strict';
2
2
 
3
3
  let seen = null;
4
4
  let plugin = null;
5
- const fakeReact = { createElement: () => null, useRef: (value) => ({ current: value }), useState: (value) => [value, () => {}], useEffect: () => {}, useMemo: (fn) => fn() };
5
+ const makeNode = (type, props, ...children) => ({ type, props: props || {}, children: children.flat(Infinity) });
6
+ const fakeReact = { createElement: makeNode, useRef: (value) => ({ current: value }), useState: (value) => [value, () => {}], useEffect: () => {}, useMemo: (fn) => fn() };
6
7
  globalThis.window = { __ModuleLoader__: { load: (definition) => { plugin = definition.factory((name) => (name === 'react' ? fakeReact : null)); } } };
7
8
  globalThis.__LITE_MEMORY_TEST__ = (helpers) => { seen = helpers; };
8
9
  await import('../client.js');
@@ -26,7 +27,8 @@ assert.ok(panel, '记忆面板没有注册');
26
27
  assert.equal(panel.name, 'shell.overlay');
27
28
  assert.equal(panel.order, 30);
28
29
 
29
- const { metaOf, citationOf, citationsOfMeta, citationsOfTurn, dedupe, undoRefOf, graphOf, ruleGraphOf } = seen;
30
+ const EMPTY_CHILDREN = [];
31
+ const { metaOf, citationOf, citationsOfMeta, citationsOfTurn, dedupe, undoRefOf, graphOf, ruleGraphOf, processingStatusOf, currentBodyOf, historyDiffsOf, MemoryPanel, MemoryGraph, displayKeyOf, readDisplaySettings, writeDisplaySettings, graphViewOf, viewModeOf, filterRecords, projectionOf, failureStateOf, evidenceTextOf, evidenceMetaOf, MemoryPalace, palaceRoomsOf, palaceRoomPageOf, palacePageWindow, palaceToggleOf, palaceMotionOf, palaceRoomIdOf } = seen;
30
32
 
31
33
  // 图谱只按真实数据画:共享标签成枢纽,显式关联成边,位置由 id 决定。
32
34
  const graph = graphOf([
@@ -36,15 +38,38 @@ const graph = graphOf([
36
38
  ]);
37
39
  assert.equal(graph.nodes.filter((node) => node.kind === 'memory').length, 3);
38
40
  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);
41
+ assert.equal(graph.nodes.some((node) => node.id === 'tag:环境'), false);
42
+ assert.equal(graph.nodes.some((node) => node.id === 'topic:上线'), false);
43
+ assert.equal(graph.edges.filter((edge) => edge.type === 'tag').length, 2);
42
44
  assert.equal(graph.edges.filter((edge) => edge.type === 'link').length, 1);
43
45
  assert.deepEqual(graphOf([{ id: 'mem_aaaaaaaa', title: 'A' }]).nodes[0].x, graphOf([{ id: 'mem_aaaaaaaa', title: 'A' }]).nodes[0].x);
44
46
  const pair = graphOf([{ id: 'mem_aaaaaaaa', title: 'A' }, { id: 'mem_bbbbbbbb', title: 'B' }]);
45
47
  assert.notEqual(pair.nodes[0].x, pair.nodes[1].x);
46
48
  assert.notEqual(pair.nodes[0].y, pair.nodes[1].y);
47
49
 
50
+ // 共享标签是组:枢纽点比成员大,成员围着它,孤立记忆留在外圈;被更强组吸收的次要标签不另画。
51
+ const hubNode = graph.nodes.find((node) => node.id === 'tag:发布');
52
+ const memberA = graph.nodes.find((node) => node.id === 'mem_aaaaaaaa');
53
+ const memberC = graph.nodes.find((node) => node.id === 'mem_cccccccc');
54
+ assert.ok(hubNode && memberA && memberC);
55
+ assert.ok(hubNode.radius > memberA.radius + 4, '共享标签枢纽应比成员大');
56
+ assert.ok(Math.hypot(memberA.x - hubNode.x, memberA.y - hubNode.y) < 90, '成员应靠近所属枢纽');
57
+ assert.ok(Math.hypot(memberC.x - hubNode.x, memberC.y - hubNode.y) > 90, '无共享标签的记忆应留在外圈');
58
+
59
+ // 一条记忆只连自己所属组的枢纽:别的共享标签不画跨组虚线。
60
+ const overlap = graphOf([
61
+ { id: 'mem_dddddddd', title: 'D', tags: ['X', 'Y'] },
62
+ { id: 'mem_eeeeeeee', title: 'E', tags: ['X', 'Y'] },
63
+ { id: 'mem_ffffffff', title: 'F', tags: ['X'] },
64
+ { id: 'mem_gggggggg', title: 'G', tags: ['Y'] },
65
+ { id: 'mem_hhhhhhhh', title: 'H', tags: ['Y'] },
66
+ { id: 'mem_iiiiiiii', title: 'I', tags: ['X'] },
67
+ ]);
68
+ const overlapTags = overlap.edges.filter((edge) => edge.type === 'tag').map((edge) => `${edge.from}>${edge.to}`);
69
+ assert.equal(overlapTags.length, 6);
70
+ assert.equal(overlapTags.includes('mem_dddddddd>tag:Y'), false);
71
+ assert.equal(overlapTags.includes('mem_eeeeeeee>tag:Y'), false);
72
+
48
73
  // 经验图谱只按共享的适用条件相连,不画没有依据的关系。
49
74
  const rules = [
50
75
  { id: 'ev_aaaaaaaa', title: 'A', state: 'active', conditions: [{ key: 'seriesId', value: '口播' }] },
@@ -111,4 +136,149 @@ assert.equal(turn[1].id, 'mem_bbbbbbbb');
111
136
  // 旧行没有元数据:什么也不产出,绝不从文案猜 id。
112
137
  assert.deepEqual(citationsOfTurn([{ root: null }, { root: { kind: 'tool-result', content: [{ type: 'text', text: 'memory · saved "A" mem_aaaaaaaa' }] } }]), []);
113
138
 
114
- console.log('client.test.mjs: 11 passed');
139
+ // 处理状态只认服务端枚举;中文 reason 不参与猜测。
140
+ assert.equal(processingStatusOf({ processingStatus: 'applied' }), 'applied');
141
+ assert.equal(processingStatusOf({ outcome: { status: 'superseded' } }), 'superseded');
142
+ assert.equal(processingStatusOf({ status: 'failed' }), 'failed');
143
+ assert.equal(processingStatusOf({ status: 'ok', reason: '处理失败' }), '');
144
+ assert.equal(processingStatusOf({ reason: '已应用' }), '');
145
+
146
+ // 当前正文与历史版本分开:历史行只给 old → new,不拼进当前正文。
147
+ const memoryPayload = {
148
+ currentBody: '新的正文',
149
+ versions: [
150
+ { revision: 1, body: '旧的正文', at: '2026-01-01' },
151
+ { revision: 2, body: '中间正文', at: '2026-02-01' },
152
+ ],
153
+ };
154
+ assert.equal(currentBodyOf(memoryPayload), '新的正文');
155
+ const diffs = historyDiffsOf(memoryPayload);
156
+ assert.equal(diffs.length, 2);
157
+ assert.equal(diffs[0].old, '旧的正文');
158
+ assert.equal(diffs[0].next, '中间正文');
159
+ assert.equal(diffs[1].next, '新的正文');
160
+ assert.equal(currentBodyOf({ record: { current: { body: '记录正文' }, body: '旧字段' } }), '记录正文');
161
+ assert.deepEqual(historyDiffsOf({ body: '单版本' }), []);
162
+
163
+ // 图谱截断时保留真实总数,60 是绘制上限而不是总数。
164
+ const many = Array.from({ length: 65 }, (_, index) => ({ id: `mem_${String(index).padStart(8, '0')}`, title: `T${index}` }));
165
+ const big = graphOf(many);
166
+ assert.equal(big.shown, 60);
167
+ assert.equal(big.total, 65);
168
+ assert.equal(big.nodes.filter((node) => node.kind === 'memory').length, 60);
169
+ assert.equal(ruleGraphOf(Array.from({ length: 65 }, (_, index) => ({ id: `ev_${String(index).padStart(8, '0')}`, title: `R${index}`, conditions: [] }))).total, 65);
170
+
171
+ // 显示设置只决定画不画;按项目 key 分开存,默认都关。
172
+ const memoryStore = new Map();
173
+ globalThis.localStorage = { getItem: (key) => (memoryStore.has(key) ? memoryStore.get(key) : null), setItem: (key, value) => memoryStore.set(key, String(value)) };
174
+ assert.equal(displayKeyOf('project:/a'), 'lite-memory:display:project:/a');
175
+ assert.notEqual(displayKeyOf('project:/a'), displayKeyOf('project:/b'));
176
+ assert.deepEqual(readDisplaySettings('project:/a'), { graph: false, palace: false });
177
+ writeDisplaySettings('project:/a', { graph: true, palace: false });
178
+ assert.deepEqual(readDisplaySettings('project:/a'), { graph: true, palace: false });
179
+ assert.deepEqual(readDisplaySettings('project:/b'), { graph: false, palace: false });
180
+ memoryStore.set(displayKeyOf('project:/c'), '{oops');
181
+ assert.deepEqual(readDisplaySettings('project:/c'), { graph: false, palace: false });
182
+ assert.equal(graphViewOf(false, 'graph'), 'list');
183
+ assert.equal(graphViewOf(true, 'graph'), 'graph');
184
+ assert.equal(graphViewOf(undefined, 'graph'), 'list');
185
+
186
+ // 点标签/主题按精确值筛,不把「发布」发散成「发布会」。
187
+ const tagged = [
188
+ { id: 'mem_aaaaaaaa', title: '发布记录', tags: ['发布'], topicKey: '上线' },
189
+ { id: 'mem_bbbbbbbb', title: '发布会记录', tags: ['发布会'], topicKey: '上线复盘' },
190
+ ];
191
+ assert.deepEqual(filterRecords(tagged, '', { kind: 'tag', value: '发布' }).map((row) => row.id), ['mem_aaaaaaaa']);
192
+ assert.deepEqual(filterRecords(tagged, '', { kind: 'topic', value: '上线' }).map((row) => row.id), ['mem_aaaaaaaa']);
193
+ assert.deepEqual(filterRecords(tagged, '发布会', null).map((row) => row.id), ['mem_bbbbbbbb']);
194
+
195
+ // 60 条投影外也要保住当前选中项,不能点开就消失。
196
+ const long = Array.from({ length: 65 }, (_, index) => ({ id: `mem_${String(index).padStart(8, '0')}`, title: `T${index}` }));
197
+ const kept = projectionOf(long, long[64].id);
198
+ assert.equal(kept.length, 60);
199
+ assert.equal(kept[59].id, long[64].id);
200
+ assert.equal(graphOf(long, long[64].id).nodes.some((node) => node.id === long[64].id), true);
201
+ assert.equal(graphOf(long).nodes.some((node) => node.id === long[64].id), false);
202
+
203
+ // 失败分当前和过去:只有最近一次失败才提示现有经验没更新。
204
+ assert.equal(failureStateOf({ economy: { recent: [{ status: 'failed' }, { status: 'applied' }] } }), 'current');
205
+ assert.equal(failureStateOf({ economy: { recent: [{ status: 'applied' }, { status: 'failed' }] } }), 'past');
206
+ assert.equal(failureStateOf({ economy: { recent: [{ status: 'applied' }] } }), '');
207
+ assert.equal(failureStateOf(null), '');
208
+
209
+ // 学习依据读真实摘录与样例元数据,不再拿「样例」占位。
210
+ assert.equal(evidenceTextOf({ sourceExcerpt: '用户说别用渐变' }), '用户说别用渐变');
211
+ assert.equal(evidenceTextOf({ excerpt: '原文' }), '原文');
212
+ assert.equal(evidenceTextOf({ note: '备注' }), '备注');
213
+ assert.equal(evidenceTextOf({ feedbackType: 'negative' }), '');
214
+ assert.equal(evidenceMetaOf({ artifactVersion: 2, dimension: '排版', verified: true, at: '2026-03-04T00:00:00Z' }), '第 2 版 · 排版 · 已核对 · 2026-03-04');
215
+ assert.equal(evidenceMetaOf({ verified: false }), '未核对');
216
+
217
+ // 显示设置默认关闭:面板首帧只出列表,不挂载图谱组件。
218
+ const nodeTypes = (node, out = []) => {
219
+ if (!node || typeof node !== 'object') return out;
220
+ if (node.type) out.push(node.type);
221
+ for (const child of node.children || EMPTY_CHILDREN) nodeTypes(child, out);
222
+ return out;
223
+ };
224
+ const firstPaint = MemoryPanel({ sessionId: 'demo', onClose: () => {} });
225
+ assert.ok(firstPaint, '面板首帧应渲染');
226
+ assert.equal(nodeTypes(firstPaint).includes(MemoryGraph), false);
227
+ assert.equal(JSON.stringify(nodeTypes(firstPaint)).includes('MemoryGraph'), false);
228
+ assert.equal(nodeTypes(firstPaint).includes(MemoryPalace), false);
229
+ assert.equal(JSON.stringify(nodeTypes(firstPaint)).includes('MemoryPalace'), false);
230
+
231
+
232
+ // 记忆宫殿:固定四间,空房保留位置,未知类型有归处。
233
+ const palaceRecord = (id, kind, title = id) => ({ id, kind, title });
234
+ const palaceRooms = palaceRoomsOf([
235
+ palaceRecord('mem_1', 'note'), palaceRecord('mem_2', 'pointer'), palaceRecord('mem_3', 'procedure'),
236
+ palaceRecord('mem_4', 'preference'), palaceRecord('mem_5', 'convention'), palaceRecord('mem_6', 'pitfall'),
237
+ palaceRecord('mem_7', 'credential_hint'), palaceRecord('mem_8', 'unknown_kind'),
238
+ ]);
239
+ assert.deepEqual(palaceRooms.map((room) => room.name), ['习惯与规范', '方法与经验', '资料与入口', '其他记录']);
240
+ assert.deepEqual(palaceRooms.map((room) => room.total), [2, 2, 2, 2]);
241
+ assert.equal(palaceRoomIdOf('unknown_kind'), 'notes');
242
+ assert.deepEqual(palaceRoomsOf([palaceRecord('mem_9', 'note')]).map((room) => room.total), [0, 0, 0, 1]);
243
+ assert.equal(palaceRoomsOf([]).length, 4);
244
+ // 每页 24 条;页码夹在有效范围内。
245
+ const palaceMany = Array.from({ length: 25 }, (_, index) => palaceRecord(`mem_${index}`, 'preference', `P${index}`));
246
+ assert.equal(palaceRoomsOf(palaceMany)[0].pages, 2);
247
+ assert.equal(palaceRoomPageOf(palaceMany, 'habits', 1).items.length, 24);
248
+ assert.equal(palaceRoomPageOf(palaceMany, 'habits', 2).items.length, 1);
249
+ assert.equal(palaceRoomPageOf(palaceMany, 'habits', 99).page, 2);
250
+ assert.equal(palaceRoomPageOf(palaceMany, 'habits', 0).page, 1);
251
+ assert.deepEqual(palacePageWindow(1, 3), [1, 2, 3]);
252
+ assert.deepEqual(palacePageWindow(5, 12), [2, 3, 4, 5, 6, 7, 8]);
253
+ assert.equal(palacePageWindow(12, 12).includes(12), true);
254
+ // 同一时刻只展开一间,再点收起。
255
+ assert.equal(palaceToggleOf('', 'methods'), 'methods');
256
+ assert.equal(palaceToggleOf('methods', 'methods'), '');
257
+ assert.equal(palaceToggleOf('methods', 'habits'), 'habits');
258
+ // 关闭时回列表;减少动态时 0ms,否则 180ms。
259
+ assert.equal(viewModeOf({ graph: true, palace: false }, 'palace'), 'list');
260
+ assert.equal(viewModeOf({ graph: false, palace: true }, 'palace'), 'palace');
261
+ assert.equal(palaceMotionOf(true).duration, 0);
262
+ assert.equal(palaceMotionOf(false).duration, 180);
263
+ // 展品点开只把 id 交给现有详情入口,不另造详情。
264
+ let palacePicked = '';
265
+ const palaceTree = MemoryPalace({
266
+ records: [palaceRecord('mem_a', 'preference', 'A')],
267
+ selectedId: '', onPick: (id) => { palacePicked = id; }, emptyText: '这个项目还没有记忆',
268
+ });
269
+ const expandNodes = (node, out = []) => {
270
+ if (!node || typeof node !== 'object') return out;
271
+ if (typeof node.type === 'function') return expandNodes(node.type(node.props), out);
272
+ out.push(node);
273
+ for (const child of node.children || []) expandNodes(child, out);
274
+ return out;
275
+ };
276
+ const palaceExhibit = expandNodes(palaceTree).find((node) => node.type === 'button' && node.props.title === 'A');
277
+ assert.ok(palaceExhibit, '展品应是原生按钮');
278
+ palaceExhibit.props.onClick();
279
+ assert.equal(palacePicked, 'mem_a');
280
+ const palaceEmpty = MemoryPalace({ records: [], selectedId: '', onPick: () => {}, emptyText: '这个项目还没有记忆' });
281
+ assert.equal(expandNodes(palaceEmpty).some((node) => node.children.includes('这个项目还没有记忆')), true);
282
+
283
+ console.log('client.test.mjs: 19 passed + palace');
284
+
@@ -87,6 +87,8 @@ assert.equal(JSON.stringify(saveParams.applicability.enum), JSON.stringify(['all
87
87
  assert.deepEqual(saveParams.resolution.enum, ['distinct']);
88
88
  /** 一次检索最多回 4 条:列表是给下一步取正文用的,不是索引。 */
89
89
  assert.equal(defs.get('memory_search').parameters.properties.limit.maximum, 4);
90
+ /** 空检索结果会带 noTriggerReason,输出 schema 必须声明它,否则宿主校验直接报错。 */
91
+ assert.equal(defs.get('memory_search').output.schema.properties.noTriggerReason.type, 'string');
90
92
  assert.equal(saveParams.id.type, 'string');
91
93
  assert.deepEqual(saveParams.scope.enum, ['auto', 'project', 'global']);
92
94
  assert.equal(saveParams.memoryClass.type, 'string');
@@ -152,6 +154,7 @@ assert.doesNotMatch(hitText, /第 .* 版/);
152
154
  /** 「发版当天」这类自由文本条件没有可信上下文,不能当成适用条目塞给模型。 */
153
155
  const miss = await exec('memory_search', { query: '完全没有出现过的主题' });
154
156
  assert.equal(miss.count, 0);
157
+ assert.equal(typeof miss.noTriggerReason, 'string');
155
158
  assert.equal(render('memory_search', {}, miss), 'memory · 没有命中');
156
159
 
157
160
  const read = await exec('memory_read', { id: saved.id });
@@ -519,17 +522,22 @@ assert.equal(reopenedSnapshot.body.rules.length, 1);
519
522
  const metaOf = (name, args, value) => defs.get(name).output.presentationMeta(args, value);
520
523
  const savedMeta = metaOf('memory_save', {}, saved);
521
524
  assert.equal(savedMeta.kind, 'memory-save');
525
+ assert.equal(savedMeta.recordKind, saved.kind);
522
526
  assert.equal(savedMeta.id, saved.id);
523
527
  assert.equal(savedMeta.title, '发布顺序');
524
528
  assert.equal(savedMeta.changed, true);
525
529
  assert.ok(['created', 'updated'].includes(savedMeta.mode));
526
530
  assert.equal('path' in savedMeta, false);
527
531
  const searchMeta = metaOf('memory_search', {}, globalSearch);
528
- assert.deepEqual(Object.keys(searchMeta.refs[0]).sort(), ['id', 'origin', 'revision', 'scope', 'status', 'title']);
532
+ assert.deepEqual(Object.keys(searchMeta.refs[0]).sort(), ['id', 'kind', 'origin', 'revision', 'scope', 'status', 'title', 'when']);
529
533
  assert.equal(searchMeta.refs[0].id, global.id);
534
+ /** 引用要能区分偏好/约定/流程/资料,界面才不必猜标题文案。 */
535
+ assert.equal(searchMeta.refs[0].kind, store.loadIndex(store.globalScope()).entries.find((item) => item.id === global.id).kind);
536
+ assert.equal(searchMeta.refs[0].when, '');
530
537
  const readValue = await exec('memory_read', { id: global.id });
531
538
  const readMeta = metaOf('memory_read', {}, readValue);
532
539
  assert.equal(readMeta.kind, 'memory-read');
540
+ assert.equal(readMeta.recordKind, readValue.kind);
533
541
  assert.equal(readMeta.title, '回复用中文');
534
542
  assert.equal(readMeta.revision, 1);
535
543
  const suggestValue = await exec('memory_suggest', { title: '元数据建议', summary: '建议行只认元数据里的候选 id' });