dsh-lite-memory 0.16.4 → 0.18.1

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, TurnMemory, refMetaOf, explainKeyOf, explainModeOf, setExplainMode, agoLabel, checkupOf, titleCountsOf } = 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,189 @@ 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
+ // 每条引用带类型:搜索命中用条目的 kind,读过正文用 recordKind;缺了就只写范围与来源。
284
+ const typedSearch = citationOf({ id: 'mem_aaaaaaaa', title: 'A', scope: 'global', revision: 1, status: 'ok', kind: 'preference' }, 'memory-search');
285
+ assert.equal(typedSearch.entryKind, 'preference');
286
+ assert.equal(refMetaOf(typedSearch), '偏好 · 全局 · 你记的');
287
+ assert.equal(citationOf({ kind: 'memory-read', id: 'mem_bbbbbbbb', recordKind: 'procedure', scope: 'project', revision: 2 }, 'memory-read').entryKind, 'procedure');
288
+ assert.equal(refMetaOf({ scope: 'project', origin: 'user_explicit' }), '项目 · 你记的');
289
+ /** 检索引用也要带类型与更新距今,缺时间时退回两项。 */
290
+ const aged = citationOf({ kind: 'preference', id: 'mem_cccccccc', scope: 'global', revision: 1, updated: new Date(Date.now() - 3 * 86_400_000).toISOString() }, 'memory-search');
291
+ assert.equal(aged.entryKind, 'preference');
292
+ assert.equal(refMetaOf(aged), '偏好 · 全局 · 你记的 · 3 天前更新');
293
+ assert.equal(agoLabel(''), '');
294
+ assert.equal(agoLabel(new Date(Date.now() - 40 * 86_400_000).toISOString()), '');
295
+
296
+ // 解释模式按会话存在本机,缺省关闭;关着零命中不出提示,打开后才说原因。
297
+ assert.equal(explainModeOf('s1'), false);
298
+ const quietRefs = [];
299
+ Object.defineProperty(quietRefs, 'noTriggerReason', { value: '当前任务不适用', enumerable: false, configurable: true });
300
+ assert.equal(TurnMemory({ sessionId: 's1', turn: '7', useRefs: () => quietRefs }), null);
301
+ setExplainMode('s1', true);
302
+ assert.equal(explainModeOf('s1'), true);
303
+ assert.equal(explainModeOf('s2'), false);
304
+ const told = TurnMemory({ sessionId: 's1', turn: '7', useRefs: () => quietRefs });
305
+ assert.ok(told);
306
+ assert.equal(JSON.stringify(told).includes('当前任务不适用'), true);
307
+ assert.equal(explainKeyOf('s1').startsWith('lite-memory:explain:'), true);
308
+
309
+
310
+ const checkupRows = [
311
+ { id: 'mem_c0000001', title: '同一件事', when: '', lastUsed: '' },
312
+ { id: 'mem_c0000002', title: '同一件事', when: '当 A 时', lastUsed: '2026-01-01T00:00:00.000Z' },
313
+ { id: 'mem_c0000003', title: '另一件', when: '当 B 时', lastUsed: '2026-01-01T00:00:00.000Z' },
314
+ ];
315
+ const checkupScopes = [{ key: 'p', kind: 'project', entries: 3 }, { key: 'global', kind: 'global', entries: 213 }];
316
+ assert.deepEqual(checkupOf(checkupRows, checkupScopes, false), { duplicate: 1, unread: 1, globalEntries: 213, globalMissingWhen: null });
317
+ assert.deepEqual(checkupOf(checkupRows, checkupScopes, true), { duplicate: 1, unread: 1, globalEntries: 213, globalMissingWhen: 1 });
318
+ assert.deepEqual(filterRecords(checkupRows, '', { kind: 'duplicate' }).map((row) => row.id), ['mem_c0000001', 'mem_c0000002']);
319
+ assert.deepEqual(filterRecords(checkupRows, '', { kind: 'unread' }).map((row) => row.id), ['mem_c0000001']);
320
+ assert.deepEqual(filterRecords(checkupRows, '', { kind: 'noWhen' }).map((row) => row.id), ['mem_c0000001']);
321
+ assert.deepEqual([...titleCountsOf(checkupRows).values()], [2, 1]);
322
+ assert.equal(checkupOf([], [], false).globalEntries, null);
323
+ console.log('client.test.mjs: 21 passed + palace');
324
+
@@ -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');
@@ -108,13 +110,13 @@ assert.equal(phrased.scope, 'project');
108
110
  assert.equal(store.loadIndex(store.globalScope()).entries.some((item) => item.title === '以后都用这个一次性脚本'), false);
109
111
 
110
112
  assert.equal(typeof saved.id, 'string');
111
- assert.match(render('memory_save', {}, saved), /^memory · saved \[[^\]]+\] "发布顺序" · mem_[a-z0-9]+$/);
113
+ assert.match(render('memory_save', {}, saved), /^memory · saved \[[^\]]+\] "发布顺序"$/);
112
114
 
113
115
  const merged = await exec('memory_save', { title: '发布顺序', summary: '发版前要按顺序做的事', body: '第一步\n第二步' });
114
116
  assert.equal(merged.merged, true);
115
117
  assert.equal(merged.id, saved.id);
116
118
  assert.equal(merged.revision, 2);
117
- assert.match(render('memory_save', {}, merged), /^memory · merged \[[^\]]+\] "发布顺序" · mem_[a-z0-9]+$/);
119
+ assert.match(render('memory_save', {}, merged), /^memory · merged \[[^\]]+\] "发布顺序"$/);
118
120
 
119
121
  const patch = await exec('memory_save', { id: saved.id, title: '发布顺序', summary: '发版前要按顺序做的事', when: '发版当天' });
120
122
  assert.equal(patch.when, '发版当天');
@@ -125,7 +127,7 @@ const again = await exec('memory_save', { title: '发布顺序', summary: '发
125
127
  assert.equal(again.changed, false);
126
128
  assert.equal(again.id, saved.id);
127
129
  assert.equal(again.revision, 3);
128
- assert.match(render('memory_save', {}, again), /^memory · unchanged \[[^\]]+\] "发布顺序" · mem_[a-z0-9]+$/);
130
+ assert.match(render('memory_save', {}, again), /^memory · unchanged \[[^\]]+\] "发布顺序"$/);
129
131
 
130
132
  // ── similar title asks before overwriting ────────────────────────────────────
131
133
  const probe = await exec('memory_save', { title: '发布顺序是啥', summary: '发版前要按顺序做的事', body: '另一条' });
@@ -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 });
@@ -160,23 +163,24 @@ assert.equal(read.body, '');
160
163
  const plain = await exec('memory_read', { id: distinct.id });
161
164
  assert.equal(plain.body, '另一条');
162
165
  assert.equal(plain.revision, 1);
163
- assert.match(render('memory_read', {}, plain), new RegExp(`^memory · \\[[^\\]]+\\] 「发布顺序是啥」 · ${distinct.id}`));
166
+ assert.match(render('memory_read', {}, plain), /^memory · \[[^\]]+\] 「发布顺序是啥」/);
167
+ assert.ok(!render('memory_read', {}, plain).includes(distinct.id));
164
168
 
165
169
  // 没点明归档还是彻底忘掉时不动盘,先返回待确认
166
170
  const undecided = await exec('memory_forget', { id: distinct.id });
167
171
  assert.equal(undecided.mode, 'needs_resolution');
168
- assert.match(render('memory_forget', {}, undecided), new RegExp(`^memory · 先确认是归档还是彻底忘掉 · ${distinct.id}$`));
172
+ assert.match(render('memory_forget', {}, undecided), /^memory · 先确认是归档还是彻底忘掉 · 「发布顺序是啥」$/);
169
173
  assert.ok(store.locate(store.scopeOf(cwd), distinct.id));
170
174
  const archived = await exec('memory_forget', { id: distinct.id, mode: 'archive' });
171
175
  assert.equal(archived.id, distinct.id);
172
- assert.match(render('memory_forget', {}, archived), new RegExp(`^memory · archived \\[[^\\]]+\\] "发布顺序是啥" · ${distinct.id},可找回$`));
176
+ assert.match(render('memory_forget', {}, archived), /^memory · archived \[[^\]]+\] "发布顺序是啥",可找回$/);
173
177
  assert.equal(await exec('memory_save', { title: '发布顺序是啥', summary: '发版前要按顺序做的事', body: '第三条' }).then((v) => v.merged), false);
174
178
 
175
179
  // ── suggestion → keep → replay → reject ──────────────────────────────────────
176
180
  const suggested = await exec('memory_suggest', { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint', body: 'node ui-copy-lint.mjs', when: '发布前' });
177
181
  assert.equal(suggested.asked, true);
178
182
  assert.match(suggested.id, /^sug_[a-z0-9]+$/);
179
- assert.match(render('memory_suggest', {}, suggested), /^memory · suggested "发布前跑文案检查" · sug_[a-z0-9]+$/);
183
+ assert.match(render('memory_suggest', {}, suggested), /^memory · suggested "发布前跑文案检查"$/);
180
184
 
181
185
  const duplicate = await exec('memory_suggest', { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint' });
182
186
  assert.equal(duplicate.asked, false);
@@ -231,7 +235,7 @@ assert.equal((await call('/lite-memory/keep', {}, { method: 'GET' })).status, 40
231
235
  // ── global layer ─────────────────────────────────────────────────────────────
232
236
  const global = await exec('memory_save', { title: '回复用中文', summary: '用户要求回复用中文', scope: 'global' });
233
237
  assert.equal(global.scope, 'global');
234
- assert.match(render('memory_save', {}, global), /^memory · saved \[global\] "回复用中文" · mem_[a-z0-9]+$/);
238
+ assert.match(render('memory_save', {}, global), /^memory · saved \[global\] "回复用中文"$/);
235
239
  const globalSearch = await exec('memory_search', { query: '回复用中文' });
236
240
  assert.equal(globalSearch.count, 1);
237
241
  assert.match(render('memory_search', {}, globalSearch), /\[global\] mem_/);
@@ -519,17 +523,24 @@ assert.equal(reopenedSnapshot.body.rules.length, 1);
519
523
  const metaOf = (name, args, value) => defs.get(name).output.presentationMeta(args, value);
520
524
  const savedMeta = metaOf('memory_save', {}, saved);
521
525
  assert.equal(savedMeta.kind, 'memory-save');
526
+ assert.equal(savedMeta.recordKind, saved.kind);
522
527
  assert.equal(savedMeta.id, saved.id);
523
528
  assert.equal(savedMeta.title, '发布顺序');
524
529
  assert.equal(savedMeta.changed, true);
525
530
  assert.ok(['created', 'updated'].includes(savedMeta.mode));
526
531
  assert.equal('path' in savedMeta, false);
527
532
  const searchMeta = metaOf('memory_search', {}, globalSearch);
528
- assert.deepEqual(Object.keys(searchMeta.refs[0]).sort(), ['id', 'origin', 'revision', 'scope', 'status', 'title']);
533
+ assert.deepEqual(Object.keys(searchMeta.refs[0]).sort(), ['id', 'kind', 'origin', 'revision', 'scope', 'status', 'title', 'updated', 'when']);
529
534
  assert.equal(searchMeta.refs[0].id, global.id);
535
+ /** 引用要能区分偏好/约定/流程/资料,界面才不必猜标题文案。 */
536
+ assert.equal(searchMeta.refs[0].kind, store.loadIndex(store.globalScope()).entries.find((item) => item.id === global.id).kind);
537
+ assert.equal(searchMeta.refs[0].when, '');
538
+ /** 出处行要说「N 天前更新」,只给时间不进正文。 */
539
+ assert.equal(typeof searchMeta.refs[0].updated, 'string');
530
540
  const readValue = await exec('memory_read', { id: global.id });
531
541
  const readMeta = metaOf('memory_read', {}, readValue);
532
542
  assert.equal(readMeta.kind, 'memory-read');
543
+ assert.equal(readMeta.recordKind, readValue.kind);
533
544
  assert.equal(readMeta.title, '回复用中文');
534
545
  assert.equal(readMeta.revision, 1);
535
546
  const suggestValue = await exec('memory_suggest', { title: '元数据建议', summary: '建议行只认元数据里的候选 id' });
@@ -10,7 +10,7 @@ const view = await import('../view.js');
10
10
 
11
11
  const {
12
12
  saveEntry, routeScope, visibleRules, move, resumeMove, movePending,
13
- upsert, forget, readLinks, loadIndex, scopeOf, globalScope, readEntry,
13
+ upsert, forget, readLinks, loadIndex, scopeOf, globalScope, readEntry, pendingConflictsOf,
14
14
  } = store;
15
15
 
16
16
  let passed = 0;
@@ -369,4 +369,25 @@ await test('L06 展示视图不把关联结构塞给模型', () => {
369
369
  });
370
370
 
371
371
  for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
372
+
373
+ // ── S12 冲突队列:相近只记待办,确认后才进库 ───────────────────────────────
374
+ await test('S12 相近保存进待办队列,确认 distinct 后出队', () => {
375
+ const scope = fresh('conflict-queue');
376
+ saveEntry(globalScope(), { title: '发布前检查清单', summary: '发版前逐项确认', body: '清单', scope: 'global', resolution: 'distinct' });
377
+ assert.equal(pendingConflictsOf(globalScope()), 0);
378
+ const near = saveEntry(scope, { title: '发布前检查清单', summary: '发版前逐项确认(项目补充)', body: '项目清单' });
379
+ assert.equal(near.needsResolution, true);
380
+ assert.equal(near.pendingConflicts, 1);
381
+ assert.equal(pendingConflictsOf(scope), 1);
382
+ const queued = loadIndex(scope).conflicts[0];
383
+ assert.equal(queued.title, '发布前检查清单');
384
+ assert.equal(queued.scope, 'project');
385
+ assert.equal(queued.candidates.length >= 1, true);
386
+ const again = saveEntry(scope, { title: '发布前检查清单', summary: '发版前逐项确认(项目补充)', body: '项目清单' });
387
+ assert.equal(pendingConflictsOf(scope), 1);
388
+ const distinct = saveEntry(scope, { title: '发布前检查清单', summary: '发版前逐项确认(项目补充)', body: '项目清单', resolution: 'distinct' });
389
+ assert.equal(distinct.needsResolution, false);
390
+ assert.equal(pendingConflictsOf(scope), 0);
391
+ });
392
+
372
393
  console.log(`crossing.test.mjs: ${passed} passed`);