@modusensus/dsh-mneme 0.8.10 → 0.8.11
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.
- package/README.md +1 -1
- package/assets/icon.svg +3 -0
- package/lib/client.js +190 -58
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +6 -2
- package/test/client.test.js +245 -45
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
|
8
|
-
[](https://github.com/slow-stack/mneme)
|
|
9
9
|
[](https://github.com/slow-stack/mneme/actions)
|
|
10
10
|
[](https://nodejs.org)
|
|
11
11
|
[](https://www.npmjs.com/package/@modusensus/dsh-mneme)
|
package/assets/icon.svg
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 16 16" fill="none" stroke="#4176e6" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<path d="M4.43 12.31C2.19 10.17 2.27 6.81 4.18 4.84C6.10 2.88 9.06 2.99 10.74 4.68C12.43 6.37 12.28 8.92 10.82 10.33C9.35 11.74 7.22 11.55 6.08 10.32C4.95 9.08 5.18 7.35 6.18 6.50C6.57 6.17 7.03 5.99 7.48 5.97"/>
|
|
3
|
+
</svg>
|
package/lib/client.js
CHANGED
|
@@ -68,19 +68,37 @@ window.__ModuleLoader__.load({
|
|
|
68
68
|
if (ops.filter((o) => o.kind !== "same").length > 200) return null;
|
|
69
69
|
return ops;
|
|
70
70
|
}
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
71
|
+
// --- mneme mark:插件自己的标识图标 -------------------------------------
|
|
72
|
+
// 不再借宿主的 IconArchiveOutline*,两个原因:
|
|
73
|
+
// · 语义撞车——「归档盒」和记忆库里的归档动作是同一个符号,用户反馈看不出
|
|
74
|
+
// 这是记忆插件(issue 走维护者自开通道);
|
|
75
|
+
// · 宿主耦合——宿主图标名会换代(#287:Outline20 → OutlineRegular/Medium),
|
|
76
|
+
// 只认某一代就取到 undefined,h(undefined) 即 React #130 把整个 slot entry
|
|
77
|
+
// 崩掉。自绘后这一整类问题消失,也不再需要「探测多代名字 + 缺名降级」。
|
|
78
|
+
// 形状:单线螺旋(记忆痕迹 / 召回回路)。规格逐条对齐宿主图标,才不像外来户:
|
|
79
|
+
// 16 栅格、fill:none、stroke:currentColor、描边 1(宿主 Regular 档)、圆头圆角。
|
|
80
|
+
// 路径是 6 段三次贝塞尔拟合同一螺旋(拟合版与逐点采样版逐像素一致,已核验),
|
|
81
|
+
// 所以源码里只有 200 字符,不是一百多个采样点。
|
|
82
|
+
// 改形状只需换 MNEME_MARK_D,并同步 assets/icon.svg(插件列表那份,同一个 d)。
|
|
83
|
+
const MNEME_MARK_D = "M4.43 12.31C2.19 10.17 2.27 6.81 4.18 4.84C6.10 2.88 9.06 2.99 10.74 4.68C12.43 6.37 12.28 8.92 10.82 10.33C9.35 11.74 7.22 11.55 6.08 10.32C4.95 9.08 5.18 7.35 6.18 6.50C6.57 6.17 7.03 5.99 7.48 5.97";
|
|
84
|
+
|
|
85
|
+
/** mneme mark 的内联 SVG;currentColor + 描边 1,随主题与字号走。 */
|
|
86
|
+
const MnemeMark = ({ size = 16, className }) => h("svg", {
|
|
87
|
+
width: size,
|
|
88
|
+
height: size,
|
|
89
|
+
className,
|
|
90
|
+
viewBox: "0 0 16 16",
|
|
91
|
+
fill: "none",
|
|
92
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
93
|
+
"aria-hidden": "true",
|
|
94
|
+
stroke: "currentColor",
|
|
95
|
+
strokeWidth: 1,
|
|
96
|
+
strokeLinecap: "round",
|
|
97
|
+
strokeLinejoin: "round"
|
|
98
|
+
}, h("path", { d: MNEME_MARK_D }));
|
|
99
|
+
|
|
100
|
+
/** 渲染 mneme mark:四个入口(浮层标题栏 / footer 回退 / 侧边栏 portal / better-sidebar tab)共用同一颗。 */
|
|
101
|
+
const renderMnemeMark = (props) => h(MnemeMark, props);
|
|
84
102
|
|
|
85
103
|
// Portal target for the hero fallback surface. The host whitelists
|
|
86
104
|
// react-dom for its own bundles (dsh-client-ui-trajectory requires it);
|
|
@@ -1328,11 +1346,26 @@ window.__ModuleLoader__.load({
|
|
|
1328
1346
|
".mneme-heat--warm{color:var(--dsw-alias-label-secondary)}",
|
|
1329
1347
|
".mneme-heat--cold{color:var(--dsw-alias-label-tertiary)}",
|
|
1330
1348
|
".mneme-heat--cold .mneme-heatpct{display:none}",
|
|
1331
|
-
//
|
|
1349
|
+
// 侧边栏入口两种形态的样式(挂载与搬移逻辑见 SidebarTopEntry 的头注释)。
|
|
1350
|
+
// 展开态兜底:逐条镜像宿主 panelRow(36px 行高 / 透明底 / 2px 外边距 /
|
|
1351
|
+
// 7px 8px 内边距)——宿主面板行类名在或不在,观感都是「插件 / 自动化任务」
|
|
1352
|
+
// 那一档,不会再退回 v0.8.10 那种高亮大按钮。
|
|
1353
|
+
// 收起态:按钮带宿主 newSession 类名,标题栏固定几何(position:fixed、
|
|
1354
|
+
// 顶部居中、28px 圆钮、次级文字色、hover 填充)整套继承宿主规则,我们只用
|
|
1355
|
+
// 一条更具体的规则把 left 从宿主的 48px 推到 84px——即「新会话」右侧,
|
|
1356
|
+
// 不再像 v0.8.10 那样压在它身上(用户实测确认的遮挡 bug)。
|
|
1357
|
+
// 84 = 48(宿主给「新会话」的 left)+ 28(钮宽)+ 8(间距);120 = 84 + 28 + 8,
|
|
1358
|
+
// 让「应用 / 编辑」菜单顺延到我们右侧。宿主侧出处:SidebarRoot.module.css 的
|
|
1359
|
+
// `.collapsed .newSession{left:48px}` 与
|
|
1360
|
+
// `html[data-windows-titlebar]:has([data-sidebar-collapsed=true]){--dsh-windows-menu-start:84px}`。
|
|
1332
1361
|
".mneme-topentry{display:flex;flex-direction:column;position:relative}",
|
|
1333
|
-
".mneme-topentry-native{
|
|
1362
|
+
".mneme-topentry-native{box-sizing:border-box;min-height:36px;border:none;border-radius:var(--dsw-radius-md);background:0 0;color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer;align-items:center;gap:8px;margin:0 2px;padding:7px 8px;line-height:22px;display:flex}",
|
|
1334
1363
|
".mneme-topentry-native:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
|
|
1364
|
+
".mneme-topentry-native:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}",
|
|
1335
1365
|
".mneme-topentry-native .mneme-topentry-label{white-space:nowrap}",
|
|
1366
|
+
"html[data-windows-titlebar] .mneme-topentry .mneme-topentry-rail{left:84px}",
|
|
1367
|
+
// 菜单让位只在「收起 + 入口确实挂在标题栏里」时生效;展开态完全不碰宿主变量。
|
|
1368
|
+
"html[data-windows-titlebar]:has([data-sidebar-collapsed=true]):has([data-plugin-entry=\"@modusensus/dsh-mneme\"]){--dsh-windows-menu-start:120px}",
|
|
1336
1369
|
// --- 功能开关:一行一开关,Claude 式安静排版 ---
|
|
1337
1370
|
".mneme-featgroup{flex:none;font-size:12px;font-weight:600;letter-spacing:.02em;color:var(--dsw-alias-label-tertiary);margin:16px 0 2px}",
|
|
1338
1371
|
".mneme-featrow{display:flex;align-items:center;gap:14px;padding:11px 2px;border-bottom:1px solid var(--dsw-alias-border-l1)}",
|
|
@@ -3031,6 +3064,10 @@ window.__ModuleLoader__.load({
|
|
|
3031
3064
|
// MemoryExplorer 内部状态(选中行、过滤)由各自的跳转回调处理。
|
|
3032
3065
|
function openLibrary() {
|
|
3033
3066
|
setOverlayOpen(true);
|
|
3067
|
+
// 打开面板 = 用户正准备处理冻结冲突:顺手把入口红点重取一次,别让他
|
|
3068
|
+
// 对着一个已经过期的计数(裁决走别的路径、或后台 autoDream 刚写过库时,
|
|
3069
|
+
// 计数不该等 60s 兜底轮询才对齐)。
|
|
3070
|
+
conflictBadgeCount.refresh();
|
|
3034
3071
|
}
|
|
3035
3072
|
|
|
3036
3073
|
// --- Hero fallback overlay state (module-level pub/sub) ---
|
|
@@ -3098,7 +3135,7 @@ window.__ModuleLoader__.load({
|
|
|
3098
3135
|
h("div", { className: "mneme-overlay", role: "region", "aria-label": t("memory.view.label"), ref: panelRef },
|
|
3099
3136
|
h("div", { className: "mneme-overlaybar" },
|
|
3100
3137
|
h("span", { className: "mneme-overlaytitle" },
|
|
3101
|
-
|
|
3138
|
+
renderMnemeMark({ size: 15 }),
|
|
3102
3139
|
t("memory.view.label")
|
|
3103
3140
|
),
|
|
3104
3141
|
h("button", {
|
|
@@ -4924,32 +4961,80 @@ window.__ModuleLoader__.load({
|
|
|
4924
4961
|
);
|
|
4925
4962
|
}
|
|
4926
4963
|
|
|
4927
|
-
// --- Sidebar entry:
|
|
4964
|
+
// --- Sidebar entry: 侧边栏里的记忆入口 ---
|
|
4928
4965
|
// 注册仍在 sidebar.footer.action(list 插槽)——它既是 React 树的挂载
|
|
4929
|
-
// 锚点,也是 portal
|
|
4930
|
-
//
|
|
4931
|
-
//
|
|
4932
|
-
//
|
|
4933
|
-
//
|
|
4934
|
-
//
|
|
4935
|
-
//
|
|
4966
|
+
// 锚点,也是 portal 失效时的原位回退;常驻 sheet 也从这个插槽挂载。
|
|
4967
|
+
// 入口形状跟着宿主 v0.2 的侧边栏走,分两态:
|
|
4968
|
+
// · 展开态:宿主把「插件 / 自动化任务」这类全局面板渲染成 nav.panelList
|
|
4969
|
+
// 里的 panelRow(36px 行高、透明底、图标 + 标题)。我们把按钮 portal 进
|
|
4970
|
+
// 同一组并原样套用宿主 panelRow 的实时类名,于是它是该组的第三行——低调,
|
|
4971
|
+
// 而不是 v0.8.10 那样长得像第二个高亮「新会话」按钮。
|
|
4972
|
+
// · 收起态(Windows 标题栏):panelList / regionArea / footArea 被宿主
|
|
4973
|
+
// display:none,只有开关与「新会话」被改成固定在标题栏上。我们把按钮搬回
|
|
4974
|
+
// 标题栏、并排在「新会话」右侧(84px / 120px 的算法见上面 CSS 注释),
|
|
4975
|
+
// 三颗图标各就各位——v0.8.10 把入口留在宿主的 48px 上把它盖住了。
|
|
4976
|
+
// 承载类名一律从活元素读取(宿主/皮肤会异步改写哈希类名),不硬编码;
|
|
4977
|
+
// 面板列表不存在(没装任何面板插件)时退回「新会话」行之后。
|
|
4936
4978
|
// 宿主结构异常时约 2 秒后放弃 portal,footer 回退按钮保持可用。
|
|
4937
|
-
// #177:未处理冲突数 badge——数据走 dream-status 的 pendingConflicts
|
|
4938
|
-
//
|
|
4939
|
-
//
|
|
4940
|
-
|
|
4941
|
-
|
|
4979
|
+
// #177:未处理冲突数 badge——数据走 dream-status 的 pendingConflicts(与状态卡
|
|
4980
|
+
// 同一端点)。挂侧边栏入口按钮右上角(portal 与回退两处都挂),让用户不进面板
|
|
4981
|
+
// 也知道有冻结要裁决。
|
|
4982
|
+
// 刷新时机(用户反馈「裁决完红点消失得很慢」后重做):
|
|
4983
|
+
// · 裁决成功时冲突队列广播 mneme:conflicts-changed → 立刻重取,不必等下一轮;
|
|
4984
|
+
// 此前这条广播只有状态卡订阅,红点漏订,才慢到要等 60s 兜底轮询;
|
|
4985
|
+
// · openLibrary 打开面板时重取(见上);
|
|
4986
|
+
// · 60s 兜底轮询——后台 autoDream 自己产生/消解冲突时没人广播,靠它浮现。
|
|
4987
|
+
// 计数是模块级单例:入口按钮的 portal 与 footer 回退是两个组件,原先各起一个
|
|
4988
|
+
// 60s 轮询(两份请求、两个互不同步的计数)。现在共享一个 store:一次取回广播给
|
|
4989
|
+
// 全部订阅者,in-flight 请求也去重,最后一个订阅者离开才停表。
|
|
4990
|
+
const conflictBadgeCount = (() => {
|
|
4991
|
+
const listeners = new Set();
|
|
4992
|
+
let value = 0;
|
|
4993
|
+
let timer = null;
|
|
4994
|
+
let inflight = null;
|
|
4995
|
+
const publish = (next) => {
|
|
4996
|
+
if (next === value) return;
|
|
4997
|
+
value = next;
|
|
4998
|
+
listeners.forEach((fn) => {
|
|
4999
|
+
try { fn(value); } catch { /* 单个订阅者出错不拖垮其余 */ }
|
|
5000
|
+
});
|
|
5001
|
+
};
|
|
5002
|
+
const refresh = () => {
|
|
5003
|
+
if (inflight) return inflight;
|
|
5004
|
+
inflight = apiFetch("/api/dsh-mneme/dream-status")
|
|
5005
|
+
.then((res) => (res.ok ? res.json() : { pendingConflicts: 0 }))
|
|
5006
|
+
.then((d) => { publish(Number((d && d.pendingConflicts) ?? 0) || 0); })
|
|
5007
|
+
.catch(() => { publish(0); })
|
|
5008
|
+
.finally(() => { inflight = null; });
|
|
5009
|
+
return inflight;
|
|
5010
|
+
};
|
|
5011
|
+
return {
|
|
5012
|
+
get: () => value,
|
|
5013
|
+
refresh,
|
|
5014
|
+
subscribe: (fn) => {
|
|
5015
|
+
listeners.add(fn);
|
|
5016
|
+
fn(value);
|
|
5017
|
+
if (listeners.size === 1) {
|
|
5018
|
+
refresh();
|
|
5019
|
+
timer = setInterval(refresh, 60_000);
|
|
5020
|
+
}
|
|
5021
|
+
return () => {
|
|
5022
|
+
listeners.delete(fn);
|
|
5023
|
+
if (listeners.size === 0 && timer !== null) { clearInterval(timer); timer = null; }
|
|
5024
|
+
};
|
|
5025
|
+
}
|
|
5026
|
+
};
|
|
5027
|
+
})();
|
|
5028
|
+
function useConflictBadgeCount() {
|
|
5029
|
+
const [pending, setPending] = useState(() => conflictBadgeCount.get());
|
|
4942
5030
|
useEffect(() => {
|
|
4943
|
-
|
|
4944
|
-
const
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
5031
|
+
const unsubscribe = conflictBadgeCount.subscribe(setPending);
|
|
5032
|
+
const onChanged = () => { conflictBadgeCount.refresh(); };
|
|
5033
|
+
window.addEventListener("mneme:conflicts-changed", onChanged);
|
|
5034
|
+
return () => {
|
|
5035
|
+
unsubscribe();
|
|
5036
|
+
window.removeEventListener("mneme:conflicts-changed", onChanged);
|
|
4949
5037
|
};
|
|
4950
|
-
tick();
|
|
4951
|
-
const timer = setInterval(tick, 60_000);
|
|
4952
|
-
return () => { cancelled = true; clearInterval(timer); };
|
|
4953
5038
|
}, []);
|
|
4954
5039
|
return pending;
|
|
4955
5040
|
}
|
|
@@ -4959,7 +5044,7 @@ window.__ModuleLoader__.load({
|
|
|
4959
5044
|
pending > 99 ? "99+" : String(pending));
|
|
4960
5045
|
}
|
|
4961
5046
|
function SidebarFallbackTrigger({ wide, t }) {
|
|
4962
|
-
const pending = useConflictBadgeCount(
|
|
5047
|
+
const pending = useConflictBadgeCount();
|
|
4963
5048
|
// #295 评审:.mneme-trigger 自身 overflow:hidden 且无定位上下文,badge
|
|
4964
5049
|
// 直接放里面会被裁剪——包一层定位容器,badge 挂在容器上。
|
|
4965
5050
|
return h("div", { className: "mneme-triggerwrap" },
|
|
@@ -4973,7 +5058,7 @@ window.__ModuleLoader__.load({
|
|
|
4973
5058
|
onClick: openLibrary,
|
|
4974
5059
|
"data-mneme-overlay-opener": "true"
|
|
4975
5060
|
},
|
|
4976
|
-
|
|
5061
|
+
renderMnemeMark({ size: wide ? 16 : 18 }),
|
|
4977
5062
|
wide && h("span", { className: "mneme-trigger-label" }, t("memory.panel.open"))
|
|
4978
5063
|
),
|
|
4979
5064
|
h(ConflictBadge, { pending })
|
|
@@ -4982,14 +5067,25 @@ window.__ModuleLoader__.load({
|
|
|
4982
5067
|
|
|
4983
5068
|
function SidebarTopEntry({ wide, t, fallback }) {
|
|
4984
5069
|
const [host, setHost] = useState(null);
|
|
4985
|
-
const [
|
|
4986
|
-
const pending = useConflictBadgeCount(
|
|
5070
|
+
const [cls, setCls] = useState({ row: "", glyph: "", title: "" });
|
|
5071
|
+
const pending = useConflictBadgeCount();
|
|
4987
5072
|
useEffect(() => {
|
|
4988
5073
|
if (!reactDom || typeof document === "undefined") return undefined;
|
|
4989
5074
|
let tries = 0, timer = null, created = null, mo = null;
|
|
5075
|
+
const findRegion = () => {
|
|
5076
|
+
const region = document.querySelector('[class*="regionArea"]');
|
|
5077
|
+
return region && region.parentElement ? region : null;
|
|
5078
|
+
};
|
|
4990
5079
|
const findNative = (region) =>
|
|
4991
5080
|
region.parentElement.querySelector('[class*="newSession"]')
|
|
4992
5081
|
|| region.previousElementSibling;
|
|
5082
|
+
const findPanelList = (region) =>
|
|
5083
|
+
region.parentElement.querySelector('[class*="panelList"]');
|
|
5084
|
+
// 宿主自己的面板行:排除我们自己 portal 进去的那颗按钮(它也带 panelRow)。
|
|
5085
|
+
const findPanelRow = (region) => {
|
|
5086
|
+
const list = findPanelList(region);
|
|
5087
|
+
return list ? list.querySelector('[class*="panelRow"]:not(.mneme-topentry-native)') : null;
|
|
5088
|
+
};
|
|
4993
5089
|
// 占位锚点:新会话行(现役外壳里 newSession 按钮嵌在 logoRow 内,
|
|
4994
5090
|
// 旧外壳是根的直接子按钮)。判定与生态插件的 sidebar-entry-core
|
|
4995
5091
|
// 一致;锚点不可靠时回退到 regionArea 之前的旧位置。
|
|
@@ -5008,32 +5104,61 @@ window.__ModuleLoader__.load({
|
|
|
5008
5104
|
// insertBefore(x, x) 在 Chromium 里不是 no-op,会触发 mutation 造成
|
|
5009
5105
|
// 观察器自激风暴、冻死整个 SPA。
|
|
5010
5106
|
const place = (region) => {
|
|
5107
|
+
if (!created) return;
|
|
5011
5108
|
const parent = region.parentElement;
|
|
5012
5109
|
if (!parent) return;
|
|
5110
|
+
// 展开态落进宿主的面板列表(紧跟「插件 / 自动化任务」);宿主没有面板
|
|
5111
|
+
// 列表时退回「新会话」行之后,由 .mneme-topentry-native 兜住低调行样式。
|
|
5112
|
+
const list = wide ? findPanelList(region) : null;
|
|
5113
|
+
if (list) {
|
|
5114
|
+
if (created.parentElement === list && created === list.lastElementChild) return;
|
|
5115
|
+
list.appendChild(created);
|
|
5116
|
+
return;
|
|
5117
|
+
}
|
|
5013
5118
|
const anchor = findAnchor(region);
|
|
5014
5119
|
const target = anchor ? anchor.nextSibling : region;
|
|
5015
5120
|
if (target === created) return;
|
|
5016
5121
|
if (created.parentElement === parent && created.nextSibling === target) return;
|
|
5017
5122
|
parent.insertBefore(created, target);
|
|
5018
5123
|
};
|
|
5124
|
+
// 类名一律从活元素读。展开态借宿主面板行的盒模型,并剔掉激活态修饰类
|
|
5125
|
+
// (否则我们的行会跟着宿主当前选中的面板一起常亮);收起态借「新会话」
|
|
5126
|
+
// 按钮的类名拿整套标题栏固定几何。两处都取不到时留空,由我们的类兜底。
|
|
5127
|
+
const readCls = (region) => {
|
|
5128
|
+
if (!wide) return { row: findNative(region)?.className || "", glyph: "", title: "" };
|
|
5129
|
+
const row = findPanelRow(region);
|
|
5130
|
+
const btn = row || findNative(region);
|
|
5131
|
+
const glyph = row && row.querySelector('[class*="panelGlyph"]');
|
|
5132
|
+
const title = row && row.querySelector('[class*="panelTitle"]');
|
|
5133
|
+
return {
|
|
5134
|
+
row: (btn?.className || "")
|
|
5135
|
+
.split(/\s+/)
|
|
5136
|
+
.filter((name) => name && !name.includes("panelActive"))
|
|
5137
|
+
.join(" "),
|
|
5138
|
+
glyph: (glyph && glyph.className) || "",
|
|
5139
|
+
title: (title && title.className) || ""
|
|
5140
|
+
};
|
|
5141
|
+
};
|
|
5142
|
+
const sync = (region) => {
|
|
5143
|
+
const next = readCls(region);
|
|
5144
|
+
setCls((prev) => (prev.row === next.row && prev.glyph === next.glyph && prev.title === next.title ? prev : next));
|
|
5145
|
+
};
|
|
5019
5146
|
const attempt = () => {
|
|
5020
|
-
const region =
|
|
5021
|
-
if (region
|
|
5022
|
-
//
|
|
5023
|
-
//
|
|
5024
|
-
//
|
|
5025
|
-
// 否则 portal 按钮会停留在捕获时刻的旧类上(宽度/对齐失配)。
|
|
5147
|
+
const region = findRegion();
|
|
5148
|
+
if (region) {
|
|
5149
|
+
// 宿主切换展开/收起时会重排 DOM(面板列表 → 标题栏固定位),所以每次
|
|
5150
|
+
// 变动都重新落位并重读类名;宿主/皮肤异步改写类名也靠这条同步,否则
|
|
5151
|
+
// portal 按钮会停留在捕获时刻的旧类上(宽度/对齐失配)。
|
|
5026
5152
|
created = document.createElement("div");
|
|
5027
5153
|
created.dataset.pluginEntry = "@modusensus/dsh-mneme";
|
|
5028
5154
|
place(region);
|
|
5029
5155
|
setHost(created);
|
|
5030
|
-
|
|
5156
|
+
sync(region);
|
|
5031
5157
|
mo = new MutationObserver(() => {
|
|
5032
|
-
const cur =
|
|
5033
|
-
if (!cur
|
|
5158
|
+
const cur = findRegion();
|
|
5159
|
+
if (!cur) return;
|
|
5034
5160
|
place(cur);
|
|
5035
|
-
|
|
5036
|
-
setNativeCls((prev) => (prev === cls ? prev : cls));
|
|
5161
|
+
sync(cur);
|
|
5037
5162
|
});
|
|
5038
5163
|
mo.observe(region.parentElement, {
|
|
5039
5164
|
attributes: true,
|
|
@@ -5052,13 +5177,19 @@ window.__ModuleLoader__.load({
|
|
|
5052
5177
|
if (mo) mo.disconnect();
|
|
5053
5178
|
if (created && created.parentElement) created.parentElement.removeChild(created);
|
|
5054
5179
|
};
|
|
5055
|
-
}, []);
|
|
5180
|
+
}, [wide]);
|
|
5056
5181
|
if (!host) return fallback;
|
|
5182
|
+
// 图标与「插件 / 自动化任务」同档(16px);收起态沿用标题栏里原来的 18px。
|
|
5183
|
+
const icon = renderMnemeMark({ size: wide ? 16 : 18 });
|
|
5057
5184
|
return reactDom.createPortal(
|
|
5058
5185
|
h("div", { className: "mneme-topentry" },
|
|
5059
5186
|
h("button", {
|
|
5060
5187
|
type: "button",
|
|
5061
|
-
|
|
5188
|
+
// 展开态:宿主面板行类名 + 我们的同款兜底;收起态:宿主「新会话」类名
|
|
5189
|
+
// + 只覆盖 left 的 rail 规则(整套标题栏几何继承宿主)。
|
|
5190
|
+
className: wide
|
|
5191
|
+
? `${cls.row} mneme-topentry-native`.trim()
|
|
5192
|
+
: `${cls.row} mneme-topentry-rail`.trim(),
|
|
5062
5193
|
"aria-label": pending > 0
|
|
5063
5194
|
? `${t("memory.sidebar.aria")} · ${t("memory.status.conflicts")} ${pending}`
|
|
5064
5195
|
: t("memory.sidebar.aria"),
|
|
@@ -5066,8 +5197,9 @@ window.__ModuleLoader__.load({
|
|
|
5066
5197
|
onClick: openLibrary,
|
|
5067
5198
|
"data-mneme-overlay-opener": "true"
|
|
5068
5199
|
},
|
|
5069
|
-
|
|
5070
|
-
|
|
5200
|
+
// 图标套上宿主面板行的槽位类名,视觉与同组入口完全一致;取不到时裸渲染。
|
|
5201
|
+
cls.glyph ? h("span", { className: cls.glyph, "aria-hidden": "true" }, icon) : icon,
|
|
5202
|
+
wide && h("span", { className: `${cls.title} mneme-topentry-label`.trim() }, t("memory.panel.open")),
|
|
5071
5203
|
h(ConflictBadge, { pending })
|
|
5072
5204
|
)
|
|
5073
5205
|
), host);
|
|
@@ -5128,7 +5260,7 @@ window.__ModuleLoader__.load({
|
|
|
5128
5260
|
reg.registerTab({
|
|
5129
5261
|
id: TAB_ID,
|
|
5130
5262
|
title: () => t("memory.view.label"),
|
|
5131
|
-
icon: (size) =>
|
|
5263
|
+
icon: (size) => renderMnemeMark({ size }),
|
|
5132
5264
|
order: 60,
|
|
5133
5265
|
component: () => h(MemoryExplorer, { t })
|
|
5134
5266
|
});
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 9 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.8.
|
|
4
|
+
"version": "0.8.11",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"type": "module",
|
|
15
15
|
"main": "lib/index.js",
|
|
16
|
+
"icon": "assets/icon.svg",
|
|
16
17
|
"bin": {
|
|
17
18
|
"dsh-mneme": "bin/cli.mjs",
|
|
18
19
|
"dsh-mneme-mcp": "bin/dsh-mneme-mcp.mjs"
|
|
@@ -24,11 +25,14 @@
|
|
|
24
25
|
"./client": {
|
|
25
26
|
"default": "./lib/client.js"
|
|
26
27
|
},
|
|
27
|
-
"./package.json": "./package.json"
|
|
28
|
+
"./package.json": "./package.json",
|
|
29
|
+
"./locale/*": "./locale/*"
|
|
28
30
|
},
|
|
29
31
|
"files": [
|
|
32
|
+
"assets",
|
|
30
33
|
"bin",
|
|
31
34
|
"lib",
|
|
35
|
+
"locale",
|
|
32
36
|
"src",
|
|
33
37
|
"scripts",
|
|
34
38
|
"test",
|
package/test/client.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
@@ -63,9 +63,13 @@ test("sidebar entry opens the sheet; conversation-view tab stays retired", () =>
|
|
|
63
63
|
"no dialog surface should remain"
|
|
64
64
|
);
|
|
65
65
|
assert.ok(
|
|
66
|
-
/function openLibrary\(\) \{\s*setOverlayOpen\(true\)
|
|
66
|
+
/function openLibrary\(\) \{\s*setOverlayOpen\(true\);/.test(clientSource),
|
|
67
67
|
"the entry must open the overlay directly (no tab activation)"
|
|
68
68
|
);
|
|
69
|
+
assert.ok(
|
|
70
|
+
clientSource.includes("conflictBadgeCount.refresh();"),
|
|
71
|
+
"opening the library must re-read the conflict count (badge must not show a stale number)"
|
|
72
|
+
);
|
|
69
73
|
assert.equal(
|
|
70
74
|
clientSource.includes('ctx.slots.inject("conversation.view"'),
|
|
71
75
|
false,
|
|
@@ -126,16 +130,20 @@ test("trigger renders a wide row or a rail icon from the wide flag", () => {
|
|
|
126
130
|
"the label span must render only when wide"
|
|
127
131
|
);
|
|
128
132
|
assert.ok(
|
|
129
|
-
/size: wide \?
|
|
130
|
-
"the portalled icon must follow the
|
|
133
|
+
/size: wide \? 16 : 18/.test(clientSource),
|
|
134
|
+
"the portalled icon must follow the host panel-row size (16) and the rail size (18)"
|
|
131
135
|
);
|
|
132
136
|
});
|
|
133
137
|
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
138
|
+
// 入口的两态形状(宿主内核 0.2 侧边栏改版后重写,见 lib/client.js 的
|
|
139
|
+
// SidebarTopEntry 头注释):
|
|
140
|
+
// · 展开:混进宿主 nav.panelList 里「插件 / 自动化任务」那一组,套用宿主
|
|
141
|
+
// panelRow 的实时类名 → 一颗低调面板行,不再是第二个高亮「新会话」按钮;
|
|
142
|
+
// · 收起:宿主把面板列表整组 display:none,只有 fixed 到标题栏的开关与
|
|
143
|
+
// 「新会话」还可见,所以入口必须搬回那个位置(见下一条用例)。
|
|
144
|
+
// 本用例锁「展开态借的是宿主面板行」,回归表现是入口退回成大按钮或落回
|
|
145
|
+
// 新会话行后面变成孤立项。
|
|
146
|
+
test("sidebar entry portals into the host panel list with footer fallback", () => {
|
|
139
147
|
assert.ok(
|
|
140
148
|
clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
|
|
141
149
|
"the footer slot registration must stay as anchor + fallback"
|
|
@@ -149,20 +157,24 @@ test("sidebar entry portals above the workspaces region with footer fallback", (
|
|
|
149
157
|
"the portal must anchor at the host's regionArea container"
|
|
150
158
|
);
|
|
151
159
|
assert.ok(
|
|
152
|
-
clientSource.includes("
|
|
153
|
-
"the
|
|
160
|
+
clientSource.includes(`'[class*="panelList"]'`),
|
|
161
|
+
"the wide shape must target the host panel list (the 插件 / 自动化任务 group)"
|
|
154
162
|
);
|
|
155
163
|
assert.ok(
|
|
156
|
-
clientSource.includes("
|
|
157
|
-
"the
|
|
164
|
+
clientSource.includes(`'[class*="panelRow"]:not(.mneme-topentry-native)'`),
|
|
165
|
+
"the panel-row class must come from a host row, never from our own portalled button"
|
|
158
166
|
);
|
|
159
167
|
assert.ok(
|
|
160
|
-
clientSource.includes(
|
|
161
|
-
"the entry must
|
|
168
|
+
clientSource.includes("list.appendChild(created)"),
|
|
169
|
+
"the wide entry must be appended into that group, below the host's own entries"
|
|
162
170
|
);
|
|
163
171
|
assert.ok(
|
|
164
|
-
|
|
165
|
-
"the
|
|
172
|
+
clientSource.includes("insertBefore(created, target)"),
|
|
173
|
+
"the entry must keep the idempotent place() helper for hosts without a panel list"
|
|
174
|
+
);
|
|
175
|
+
assert.ok(
|
|
176
|
+
clientSource.includes("if (!host) return fallback;"),
|
|
177
|
+
"the portal entry persists across collapse (no footer jump); footer fallback only covers portal failure"
|
|
166
178
|
);
|
|
167
179
|
assert.ok(
|
|
168
180
|
clientSource.includes('"memory.view.label"'),
|
|
@@ -178,7 +190,7 @@ test("sidebar entry portals above the workspaces region with footer fallback", (
|
|
|
178
190
|
// sidebar-entry-core (logoRow row, legacy direct-child button), (b) re-asserts
|
|
179
191
|
// its slot from the existing observer, and (c) stays idempotent — the anchor
|
|
180
192
|
// check short-circuits before any DOM write, so observers never ping-pong.
|
|
181
|
-
test("entry re-asserts
|
|
193
|
+
test("entry re-asserts its slot against ecosystem entries", () => {
|
|
182
194
|
assert.ok(
|
|
183
195
|
clientSource.includes(`btn.closest('[class*="logoRow"]')`),
|
|
184
196
|
"the anchor must resolve the New-Session logo row like the ecosystem core"
|
|
@@ -192,18 +204,55 @@ test("entry re-asserts the New-Session-adjacent slot against ecosystem entries",
|
|
|
192
204
|
"placement must be idempotent so the observer never writes on steady state"
|
|
193
205
|
);
|
|
194
206
|
assert.ok(
|
|
195
|
-
/place\(cur\);\s*\n\s*
|
|
196
|
-
"the observer must re-place the entry
|
|
207
|
+
/place\(cur\);\s*\n\s*sync\(cur\);/.test(clientSource),
|
|
208
|
+
"the observer must re-place the entry and re-read its classes on childList churn"
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// 用户实测确认的遮挡 bug(v0.8.10):收起侧边栏时入口沿用宿主给「新会话」的
|
|
213
|
+
// left:48px,正好压在「新会话」图标上把它盖掉,左上角只剩两颗图标。
|
|
214
|
+
// 修法:不再共用那个 left,用一条更具体的规则推到 84px(「新会话」右侧 =
|
|
215
|
+
// 宿主 48px + 钮宽 28px + 间距 8px),并把宿主的面板菜单变量
|
|
216
|
+
// --dsh-windows-menu-start(宿主收起态自己设 84px)顺延到 120px。
|
|
217
|
+
// 回归表现:左上角少一颗图标,或「应用 / 编辑」与记忆图标叠在一起。
|
|
218
|
+
test("collapsed entry vacates the New-Session slot instead of covering it", () => {
|
|
219
|
+
assert.ok(
|
|
220
|
+
clientSource.includes("html[data-windows-titlebar] .mneme-topentry .mneme-topentry-rail{left:84px}"),
|
|
221
|
+
"the collapsed entry must sit right of the host's 48px New-Session slot"
|
|
222
|
+
);
|
|
223
|
+
assert.ok(
|
|
224
|
+
// 源码里这段 CSS 含转义引号(注入 <style> 后才还原成普通引号),断言按源码字面量写。
|
|
225
|
+
clientSource.includes(':has([data-plugin-entry=\\"@modusensus/dsh-mneme\\"]){--dsh-windows-menu-start:120px}'),
|
|
226
|
+
"the caption menu must shift right so the memory icon never overlaps 应用 / 编辑"
|
|
227
|
+
);
|
|
228
|
+
assert.ok(
|
|
229
|
+
clientSource.includes(":has([data-sidebar-collapsed=true]):has("),
|
|
230
|
+
"the menu shift must apply only while the sidebar is collapsed"
|
|
231
|
+
);
|
|
232
|
+
assert.ok(
|
|
233
|
+
clientSource.includes("`${cls.row} mneme-topentry-rail`.trim()"),
|
|
234
|
+
"the collapsed button carries the host New-Session class plus our rail offset rule"
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// 宿主面板行的类名带着激活态修饰类(.xxx_panelActive),而我们的入口不是
|
|
239
|
+
// panel 插槽的成员、永远不该常亮;照抄整串类名会让记忆入口跟着宿主当前选中
|
|
240
|
+
// 的面板一起高亮。这里锁「读类名时必须剔掉激活态」。
|
|
241
|
+
test("panel-row highlight never leaks onto the memory entry", () => {
|
|
242
|
+
assert.ok(
|
|
243
|
+
clientSource.includes('!name.includes("panelActive")'),
|
|
244
|
+
"panelActive must be filtered out of the copied host row classes"
|
|
197
245
|
);
|
|
198
246
|
});
|
|
199
247
|
|
|
200
248
|
// Three alignment/softness guarantees born from field feedback: (a) the
|
|
201
|
-
// portalled entry
|
|
202
|
-
// rewrite
|
|
203
|
-
//
|
|
204
|
-
// (
|
|
249
|
+
// portalled entry re-reads the live host classes on every mutation — the host
|
|
250
|
+
// and skins rewrite hashed class names asynchronously, so a mount-time snapshot
|
|
251
|
+
// goes stale (收起态尤其依赖这条:搬迁后类名必须重新对齐宿主「新会话」按钮),
|
|
252
|
+
// (b) it never hardcodes a width, so whichever host class it carries decides the
|
|
253
|
+
// box model, (c) the toolbar dropdown escapes the transform stacking trap — the
|
|
205
254
|
// container needs the z-index because transform creates the context.
|
|
206
|
-
test("entry
|
|
255
|
+
test("entry re-reads the live host classes and lifts the toolbar dropdown", () => {
|
|
207
256
|
assert.ok(
|
|
208
257
|
clientSource.includes("new MutationObserver"),
|
|
209
258
|
"the entry must re-sync the copied class via MutationObserver"
|
|
@@ -334,33 +383,99 @@ test("graph toggle uses a node-graph glyph, not the share icon", () => {
|
|
|
334
383
|
);
|
|
335
384
|
});
|
|
336
385
|
|
|
337
|
-
// #287
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
/primitives\.IconArchiveOutline20\s*\?\?\s*primitives\.IconArchiveOutlineRegular\s*\?\?\s*primitives\.IconArchiveOutlineMedium/.test(clientSource),
|
|
345
|
-
"the glyph must probe the 0.1.6 pixel name before the 0.1.7 weight names"
|
|
346
|
-
);
|
|
386
|
+
// #287 的成因已经不存在了,但那条教训要留着:当年的崩法是「宿主图标名换代 →
|
|
387
|
+
// 取到 undefined → h(undefined) 即 React #130,整个 slot entry 崩掉」。现在的解法
|
|
388
|
+
// 不是继续探测多代名字,而是**不再依赖宿主图标库**——插件自己画 mneme mark。
|
|
389
|
+
// 这条用例锁三件事:(a) 客户端里不再有对宿主 IconArchive* 名字的探测(回到探测
|
|
390
|
+
// 就等于把 #287 那类耦合请回来);(b) mark 走一个内联 SVG 组件、自带路径常量;
|
|
391
|
+
// (c) 四个入口共用同一个 helper,避免某处漏改又出现"归档盒"。
|
|
392
|
+
test("mneme mark is self-drawn: no host icon-name probing left (#287 root cause)", () => {
|
|
347
393
|
assert.equal(
|
|
348
|
-
/
|
|
394
|
+
/primitives\.IconArchiveOutline(20|Regular|Medium)/.test(clientSource),
|
|
349
395
|
false,
|
|
350
|
-
"the
|
|
396
|
+
"the client must not probe host archive glyph names any more"
|
|
397
|
+
);
|
|
398
|
+
assert.ok(
|
|
399
|
+
/const MNEME_MARK_D = "M[^"]+"/.test(clientSource),
|
|
400
|
+
"the mark must carry its own path constant"
|
|
401
|
+
);
|
|
402
|
+
assert.ok(
|
|
403
|
+
clientSource.includes('viewBox: "0 0 16 16"') && clientSource.includes('stroke: "currentColor"'),
|
|
404
|
+
"the mark must follow the host icon spec (16 grid, currentColor) so it reads as first-party"
|
|
351
405
|
);
|
|
352
406
|
assert.ok(
|
|
353
|
-
/const
|
|
354
|
-
"the
|
|
407
|
+
/const renderMnemeMark = \(props\) => h\(MnemeMark, props\)/.test(clientSource),
|
|
408
|
+
"the mark must render through one shared helper"
|
|
355
409
|
);
|
|
356
410
|
for (const site of [
|
|
357
|
-
"
|
|
358
|
-
"
|
|
359
|
-
"
|
|
360
|
-
"renderArchiveIcon({ size })" // better-sidebar tab 图标
|
|
411
|
+
"renderMnemeMark({ size: 15 })", // 浮层标题栏
|
|
412
|
+
"renderMnemeMark({ size: wide ? 16 : 18 })", // 侧栏 trigger + portal 入口(面板行档 16 / 标题栏档 18)
|
|
413
|
+
"renderMnemeMark({ size })" // better-sidebar tab 图标
|
|
361
414
|
]) {
|
|
362
415
|
assert.ok(clientSource.includes(site), `every render site must go through the helper: ${site}`);
|
|
363
416
|
}
|
|
417
|
+
assert.equal(
|
|
418
|
+
/renderArchiveIcon/.test(clientSource),
|
|
419
|
+
false,
|
|
420
|
+
"the retired archive-glyph helper must be gone, not left as a second path"
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// 插件列表那颗图标由宿主从 package.json 的 `icon` 读:必须是**包内相对路径**、
|
|
425
|
+
// 落在 files 白名单里、且宿主只接受 SVG/PNG/JPEG/WebP ≤256KiB。
|
|
426
|
+
// 宿主 `iconOf()` 的硬规则:绝对路径或带 scheme 直接报错、realpath 后不许跑出
|
|
427
|
+
// manifest 目录、超 256KiB 报错;缺文件/坏文件时静默回落成通用插画(等于用户
|
|
428
|
+
// 看到的不是自家图标)。这里把可静态检的部分全锁住。
|
|
429
|
+
test("plugin-list icon: declared, in-package, and shipped in files", () => {
|
|
430
|
+
const icon = pkg.icon;
|
|
431
|
+
assert.equal(typeof icon, "string", "package.json must declare an icon");
|
|
432
|
+
assert.equal(icon, "assets/icon.svg", "the icon must be the self-drawn mark");
|
|
433
|
+
assert.ok(!/^([A-Za-z]:|[A-Za-z][A-Za-z\d+.-]*:)/.test(icon), "the icon must be a relative path (host rejects absolute/URL)");
|
|
434
|
+
assert.ok(pkg.files.includes("assets"), "the assets dir must ship, or the published package has no icon");
|
|
435
|
+
const file = join(root, icon);
|
|
436
|
+
assert.ok(existsSync(file), `the declared icon must exist: ${icon}`);
|
|
437
|
+
const bytes = readFileSync(file);
|
|
438
|
+
assert.ok(bytes.length > 0 && bytes.length <= 256 * 1024, "the icon must be a non-empty file under the host's 256 KiB cap");
|
|
439
|
+
const svg = bytes.toString("utf8");
|
|
440
|
+
assert.ok(svg.includes("<svg") && svg.includes("viewBox"), "the icon must be an SVG with a viewBox");
|
|
441
|
+
// <img> 不能继承 currentColor:文件必须自带颜色,否则会渲染成黑块/不可见。
|
|
442
|
+
assert.ok(/stroke="#[0-9a-fA-F]{6}"/.test(svg), "the standalone file must bake its own colour (<img> cannot inherit currentColor)");
|
|
443
|
+
// 文件与客户端内联路径必须是同一颗:两边形状漂移是最容易悄悄发生的事。
|
|
444
|
+
const inline = clientSource.match(/const MNEME_MARK_D = "([^"]+)"/)[1];
|
|
445
|
+
assert.ok(svg.includes(inline), "assets/icon.svg must use the same path as the inline mark (no shape drift)");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// 插件列表的标题/描述读包内的 `locale/<语言码>.json`:宿主 readPluginMeta 先解析
|
|
449
|
+
// `<specifier>/locale/en.json`,再遍历同目录的每个 *.json(文件名必须过语言码正则),
|
|
450
|
+
// 取 meta.title / meta.description;一个都读不到就回落到 package.json 的 name——
|
|
451
|
+
// 也就是裸包名 "@modusensus/dsh-mneme"(用户看到的现状)。
|
|
452
|
+
// ⚠️ 真正的门槛是**能否被 Node 解析**:宿主用 ESM resolver 取这个文件,而 exports
|
|
453
|
+
// 字段会把没列出的子路径**全部挡掉**,且 optionalResourcePath 对"读不到"是静默的
|
|
454
|
+
// (返回 undefined 继续走回落),不报错。所以少了 "./locale/*" 这一行,文件乖乖躺在
|
|
455
|
+
// 包里也永远不生效——这条回归极难靠肉眼发现,必须锁死。
|
|
456
|
+
test("plugin-list title/description are localized through locale/*.json", () => {
|
|
457
|
+
assert.ok(
|
|
458
|
+
Object.keys(pkg.exports).includes("./locale/*"),
|
|
459
|
+
"exports must expose ./locale/* or the host resolver silently never reads the dictionaries"
|
|
460
|
+
);
|
|
461
|
+
assert.ok(pkg.files.includes("locale"), "the locale dir must ship in files, or the published package loses it");
|
|
462
|
+
const dir = join(root, "locale");
|
|
463
|
+
assert.ok(existsSync(dir), "locale/ must exist");
|
|
464
|
+
const files = readdirSync(dir).filter((name) => name.endsWith(".json"));
|
|
465
|
+
assert.ok(files.length >= 2, "at least an English and a Chinese dictionary must ship");
|
|
466
|
+
for (const name of files) {
|
|
467
|
+
const language = name.slice(0, -5);
|
|
468
|
+
assert.match(language, /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/, `locale/${name} must be named after a language id`);
|
|
469
|
+
const parsed = JSON.parse(readFileSync(join(dir, name), "utf8"));
|
|
470
|
+
assert.equal(typeof parsed.meta, "object", `locale/${name} must carry a meta block`);
|
|
471
|
+
assert.ok(typeof parsed.meta.title === "string" && parsed.meta.title.trim() !== "", `locale/${name}: meta.title must be a non-empty string`);
|
|
472
|
+
assert.ok(
|
|
473
|
+
typeof parsed.meta.description === "string" && parsed.meta.description.trim() !== "",
|
|
474
|
+
`locale/${name}: meta.description must be a non-empty string`
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
const en = JSON.parse(readFileSync(join(dir, "en.json"), "utf8"));
|
|
478
|
+
assert.ok(en.meta.title !== pkg.name, "the English title must not be the bare package name (that is the bug this fixes)");
|
|
364
479
|
});
|
|
365
480
|
|
|
366
481
|
// 方案 A:查询收敛。状态页只做仪表盘(小页预览 + 服务端 total + 查看全部),
|
|
@@ -937,11 +1052,96 @@ test("conflict queue: actionable status card and sidebar entry badge", () => {
|
|
|
937
1052
|
assert.ok(clientSource.includes("mneme-statuscard--actionable"), "highlight CSS must exist");
|
|
938
1053
|
assert.ok((clientSource.match(/h\(ConflictBadge, \{ pending \}\)/g) || []).length === 2,
|
|
939
1054
|
"badge must be wired into both entry buttons");
|
|
940
|
-
assert.ok((clientSource.match(/= useConflictBadgeCount\(
|
|
941
|
-
"both entry components must subscribe to the badge count");
|
|
1055
|
+
assert.ok((clientSource.match(/= useConflictBadgeCount\(\);/g) || []).length === 2,
|
|
1056
|
+
"both entry components must subscribe to the shared badge count");
|
|
942
1057
|
assert.ok(clientSource.includes('"/api/dsh-mneme/dream-status"'), "badge must reuse the dream-status endpoint");
|
|
943
1058
|
});
|
|
944
1059
|
|
|
1060
|
+
// 用户反馈「裁决完红点消失得很慢」的根因:裁决成功时冲突队列广播
|
|
1061
|
+
// mneme:conflicts-changed,但只有状态卡订阅了它,侧边栏红点漏订——红点于是只能
|
|
1062
|
+
// 等 60s 兜底轮询。本用例锁三类回归:(a) 红点订阅该广播并立即重取;(b) 计数走
|
|
1063
|
+
// 模块级单例(原先 portal 与 footer 回退各起一个 60s 轮询 → 两份请求且互不同步);
|
|
1064
|
+
// (c) 兜底轮询保留(后台 autoDream 自己产生/消解冲突时没人广播,靠它浮现)。
|
|
1065
|
+
test("sidebar badge clears on the conflicts-changed broadcast, not on the next poll", () => {
|
|
1066
|
+
assert.ok(
|
|
1067
|
+
/window\.addEventListener\("mneme:conflicts-changed", onChanged\)/.test(clientSource),
|
|
1068
|
+
"the badge must subscribe to the broadcast the conflict queue already emits"
|
|
1069
|
+
);
|
|
1070
|
+
assert.ok(
|
|
1071
|
+
clientSource.includes("const conflictBadgeCount = (() => {"),
|
|
1072
|
+
"the count must live in one module-level store shared by both entry components"
|
|
1073
|
+
);
|
|
1074
|
+
assert.ok(
|
|
1075
|
+
clientSource.includes("if (listeners.size === 1) {"),
|
|
1076
|
+
"exactly one poll timer per store, refcounted by its subscribers"
|
|
1077
|
+
);
|
|
1078
|
+
assert.ok(
|
|
1079
|
+
/setInterval\(refresh, 60_000\)/.test(clientSource),
|
|
1080
|
+
"the 60s fallback poll must stay: background autoDream resolves conflicts without broadcasting"
|
|
1081
|
+
);
|
|
1082
|
+
assert.ok(
|
|
1083
|
+
clientSource.includes("if (inflight) return inflight;"),
|
|
1084
|
+
"concurrent refreshes (broadcast + open + first subscriber) must collapse into one request"
|
|
1085
|
+
);
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
// 上一条锁的是"接线在不在",这条锁"接线真的按预期跑"。把源码里那段 store 抠出来
|
|
1089
|
+
// 用受控的 apiFetch / setInterval 真跑一遍——用户抱怨的正是时序(慢),而
|
|
1090
|
+
// "存在某个字符串" 证明不了任何时序。回归表现:第二个订阅者又开一条轮询、
|
|
1091
|
+
// 广播后不立刻反映、并发 refresh 打两次请求、订阅者走光后定时器还在跑。
|
|
1092
|
+
test("badge store: one poll for both entries, immediate publish after a refresh", async () => {
|
|
1093
|
+
const src = clientSource.match(/const conflictBadgeCount = \(\(\) => \{[\s\S]*?\}\)\(\);/);
|
|
1094
|
+
assert.ok(src, "the badge store source block must be extractable verbatim");
|
|
1095
|
+
const calls = [];
|
|
1096
|
+
const resolvers = [];
|
|
1097
|
+
const timers = [];
|
|
1098
|
+
let cleared = 0;
|
|
1099
|
+
const makeStore = new Function("apiFetch", "setInterval", "clearInterval",
|
|
1100
|
+
`${src[0]}\nreturn conflictBadgeCount;`);
|
|
1101
|
+
const store = makeStore(
|
|
1102
|
+
(path) => {
|
|
1103
|
+
calls.push(path);
|
|
1104
|
+
return new Promise((resolve) => { resolvers.push(resolve); });
|
|
1105
|
+
},
|
|
1106
|
+
(fn, ms) => { timers.push({ fn, ms }); return timers.length; },
|
|
1107
|
+
() => { cleared += 1; }
|
|
1108
|
+
);
|
|
1109
|
+
const settle = async (payload) => {
|
|
1110
|
+
const batch = resolvers.splice(0, resolvers.length);
|
|
1111
|
+
batch.forEach((r) => r({ ok: true, json: () => Promise.resolve(payload) }));
|
|
1112
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
const seen = [];
|
|
1116
|
+
const un1 = store.subscribe((v) => seen.push(v));
|
|
1117
|
+
assert.equal(calls.length, 1, "the first subscriber must trigger exactly one fetch");
|
|
1118
|
+
assert.equal(timers.length, 1, "and exactly one timer");
|
|
1119
|
+
assert.equal(timers[0].ms, 60_000, "the fallback cadence stays 60s");
|
|
1120
|
+
const un2 = store.subscribe((v) => seen.push(v));
|
|
1121
|
+
assert.equal(calls.length, 1, "a second subscriber must reuse the poll, not start another");
|
|
1122
|
+
assert.equal(timers.length, 1, "still one timer for both entry buttons");
|
|
1123
|
+
|
|
1124
|
+
await settle({ pendingConflicts: 3 });
|
|
1125
|
+
assert.deepEqual(seen, [0, 0, 3, 3], "both subscribers must see the same published value");
|
|
1126
|
+
|
|
1127
|
+
// 广播路径:冲突队列裁决成功后 dispatch mneme:conflicts-changed → hook 调 refresh()
|
|
1128
|
+
store.refresh();
|
|
1129
|
+
store.refresh();
|
|
1130
|
+
assert.equal(calls.length, 2, "concurrent refreshes collapse into a single in-flight request");
|
|
1131
|
+
await settle({ pendingConflicts: 0 });
|
|
1132
|
+
assert.equal(store.get(), 0, "the count must drop as soon as the refreshed value lands");
|
|
1133
|
+
assert.equal(seen[seen.length - 1], 0, "and both subscribers must be notified");
|
|
1134
|
+
|
|
1135
|
+
store.refresh();
|
|
1136
|
+
await settle({ pendingConflicts: 0 });
|
|
1137
|
+
assert.deepEqual(seen, [0, 0, 3, 3, 0, 0], "an unchanged value must not re-render subscribers");
|
|
1138
|
+
|
|
1139
|
+
un1();
|
|
1140
|
+
assert.equal(cleared, 0, "the timer must survive while one subscriber remains");
|
|
1141
|
+
un2();
|
|
1142
|
+
assert.equal(cleared, 1, "the timer must stop when the last subscriber leaves");
|
|
1143
|
+
});
|
|
1144
|
+
|
|
945
1145
|
// --- issue #179:注入预览(状态页卡片 + /inject-preview 端点透传的旁路快照) ---
|
|
946
1146
|
test("a11y+preview: inject preview card is wired on the status tab", () => {
|
|
947
1147
|
assert.ok(clientSource.includes('"/api/dsh-mneme/inject-preview"'), "card must fetch the preview endpoint");
|