@modusensus/dsh-mneme 0.9.5 → 0.10.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.
- package/README.md +9 -5
- package/docs/CONFIGURATION.md +21 -0
- package/docs/DAEMON.md +6 -2
- package/docs/DISTILL.md +90 -6
- package/docs/SLEEP.md +20 -6
- package/docs/STORAGE.md +1 -0
- package/lib/api-standalone.js +11 -3
- package/lib/api.js +25 -5
- package/lib/client.js +137 -10
- package/lib/config.js +33 -1
- package/lib/continuity.js +11 -3
- package/lib/dream/decisions.js +88 -27
- package/lib/dream/sleep.js +467 -81
- package/lib/dream.js +110 -13
- package/lib/fence.js +24 -0
- package/lib/index.js +15 -6
- package/lib/inject.js +9 -4
- package/lib/lang.js +17 -12
- package/lib/pipeline-nudge-auto.js +3 -0
- package/lib/pipeline-nudge.js +41 -8
- package/lib/pipeline-runner.js +15 -0
- package/lib/pre-inject-gate.js +14 -0
- package/lib/profile-extract.js +4 -1
- package/lib/provenance-http.js +162 -37
- package/lib/service.js +30 -0
- package/lib/session-gate.js +91 -0
- package/lib/settings.js +13 -1
- package/lib/store.js +526 -18
- package/lib/summarize.js +152 -15
- package/lib/tools.js +26 -6
- package/package.json +4 -2
- package/src/api-standalone.js +11 -3
- package/src/api.js +25 -5
- package/src/config.js +33 -1
- package/src/continuity.js +11 -3
- package/src/dream/decisions.js +88 -27
- package/src/dream/sleep.js +467 -81
- package/src/dream.js +110 -13
- package/src/fence.js +24 -0
- package/src/index.js +15 -6
- package/src/inject.js +9 -4
- package/src/lang.js +17 -12
- package/src/pipeline-nudge-auto.js +3 -0
- package/src/pipeline-nudge.js +41 -8
- package/src/pipeline-runner.js +15 -0
- package/src/pre-inject-gate.js +14 -0
- package/src/profile-extract.js +4 -1
- package/src/provenance-http.js +162 -37
- package/src/service.js +30 -0
- package/src/session-gate.js +91 -0
- package/src/settings.js +13 -1
- package/src/store.js +526 -18
- package/src/summarize.js +152 -15
- package/src/tools.js +26 -6
- package/test/api.test.js +153 -4
- package/test/client.test.js +215 -0
- package/test/config.test.js +8 -0
- package/test/conflict-reviewed.test.js +79 -0
- package/test/continuity.test.js +29 -1
- package/test/dependency-overrides.test.js +78 -0
- package/test/differentiate-feedback.test.js +248 -0
- package/test/dream-decision-evidence.test.js +314 -0
- package/test/dream-fence.test.js +43 -0
- package/test/inject-pools.test.js +2 -1
- package/test/mint-source.test.js +83 -0
- package/test/pipeline-nudge-auto.test.js +26 -2
- package/test/pipeline-nudge.test.js +87 -0
- package/test/pipeline-runner.test.js +26 -0
- package/test/prompt-stance.test.js +128 -0
- package/test/provenance-http.test.js +200 -17
- package/test/release-docs.test.js +7 -1
- package/test/security-doc.test.js +43 -4
- package/test/session-gate.test.js +264 -0
- package/test/settings.test.js +4 -0
- package/test/sleep-action-set.test.js +16 -11
- package/test/sleep-catchup.test.js +315 -0
- package/test/sleep-failure-backoff.test.js +156 -0
- package/test/sleep.test.js +293 -0
- package/test/standalone-api.test.js +50 -0
- package/test/store.test.js +220 -0
- package/test/summarize.test.js +172 -0
package/test/client.test.js
CHANGED
|
@@ -2312,6 +2312,39 @@ test("skipped reasons are visible without expanding the collapsed detail section
|
|
|
2312
2312
|
assert.ok(!dreamCards.includes("skippedReasons"),
|
|
2313
2313
|
"the collapsed card no longer needs its own copy of the skip buckets");
|
|
2314
2314
|
});
|
|
2315
|
+
|
|
2316
|
+
// #495:organize 与巩固共用 dream_runs,一次 agent 主动整理写两行。这张卡的标题是
|
|
2317
|
+
// 「最近巩固」,若面板继续拿 lastRun 取数,一次整理就把卡面变成整理回执——正是
|
|
2318
|
+
// issue 报的那个现象。锁三件事:① 卡面读 lastConsolidation;② 退回 lastRun 时必须
|
|
2319
|
+
// 先排除 organize(旧服务端没有新键,不能拿 organize 行冒充巩固);③ KPI 带那块
|
|
2320
|
+
// 「上一轮跳过什么」同样改读巩固轮,organize 的 skipped 不是巩固决策。
|
|
2321
|
+
test("dream status card reads the consolidation run, not whatever ran last (issue #495)", () => {
|
|
2322
|
+
const dreamCards = clientSource.slice(
|
|
2323
|
+
clientSource.indexOf("function DreamStatusCards("),
|
|
2324
|
+
clientSource.indexOf("function InjectStatusCard(")
|
|
2325
|
+
);
|
|
2326
|
+
assert.ok(dreamCards.includes("state.lastConsolidation"),
|
|
2327
|
+
"the card must consume lastConsolidation from /dream-status");
|
|
2328
|
+
assert.ok(dreamCards.includes('state.lastRun.run_type === "organize"'),
|
|
2329
|
+
"falling back to lastRun must still refuse an organize row — an old server may omit the new key");
|
|
2330
|
+
assert.ok(/const run = state\.lastConsolidation\s*\n?\s*\|\| \(state\.lastRun && !organizedOnly/.test(dreamCards)
|
|
2331
|
+
|| dreamCards.includes("|| (state.lastRun && !organizedOnly"),
|
|
2332
|
+
"the fallback branch must skip the row it just identified as organize");
|
|
2333
|
+
// 脚注:最近一次后台运行确实是 organize 时点名它,而不是静默把它当巩固。
|
|
2334
|
+
assert.ok(dreamCards.includes("memory.status.dreamOrganizeOnly"),
|
|
2335
|
+
"an organize-only history must be named in a footnote, not shown as a consolidation");
|
|
2336
|
+
// ① 的取数还必须晚于本卡自己那次 fetch,不能只是把键名写进注释里。
|
|
2337
|
+
const fetchIdx = dreamCards.indexOf('apiFetch("/api/dsh-mneme/dream-status")');
|
|
2338
|
+
const useIdx = dreamCards.indexOf("state.lastConsolidation");
|
|
2339
|
+
assert.ok(fetchIdx >= 0 && useIdx > fetchIdx, "the key must be read from the /dream-status payload");
|
|
2340
|
+
|
|
2341
|
+
const tokenFlow = clientSource.slice(
|
|
2342
|
+
clientSource.indexOf("function TokenFlowSection("),
|
|
2343
|
+
clientSource.indexOf("function DreamStatusCards(")
|
|
2344
|
+
);
|
|
2345
|
+
assert.ok(tokenFlow.includes("d.lastConsolidation || d.lastRun"),
|
|
2346
|
+
"the skip-reason KPI belongs to consolidation; organize skipped buckets would read as blocked consolidation");
|
|
2347
|
+
});
|
|
2315
2348
|
// 设置页目录(#518):用户要的是「左侧一个分类,直接跳到某一节」。这里锁五件事:
|
|
2316
2349
|
// ① 目录由 tocGroups 一张表生成,五项分组标题全部走 groupHead(key) 渲染——两处各写
|
|
2317
2350
|
// 一遍清单是这类索引最典型的腐烂方式,而腐烂后的目录会把人领到不存在的锚点;
|
|
@@ -2395,4 +2428,186 @@ test("minimal preset notice reaches the settings page, not just the status tab (
|
|
|
2395
2428
|
assert.ok(!/minimalWarn\s*=\s*true/.test(clientSource), "the notice must never be hard-wired on");
|
|
2396
2429
|
});
|
|
2397
2430
|
|
|
2431
|
+
// --- 移动端适配(mobile-adapt 车道)---
|
|
2432
|
+
// 面板在 320–430px 容器里原本是「骨架级失衡」而不是「略挤」:记忆页左栏固定
|
|
2433
|
+
// 216px、卡片最小轨道 252+48=300px,要 ≥517px 才排得下;实体页 rail 224px +
|
|
2434
|
+
// 属性网格 200+48=248px,要 ≥473px。目标区间上沿只有 430,靠收窄数值永远追
|
|
2435
|
+
// 不上,所以适配必须换骨架(row → column)而不是继续动宽度。
|
|
2436
|
+
// 下面这组锁防三类回归:
|
|
2437
|
+
// ① 有人把新规则写成 @media——面板有两种形态(overlay 弹层与 better-sidebar
|
|
2438
|
+
// 工作台 tab),容器宽度与视口宽度并不相等,@media 只在 overlay 形态对,
|
|
2439
|
+
// tab 形态会整块失效;
|
|
2440
|
+
// ② 有人在双花括号块里写 color —— contrast.test.js 的规则提取器按单层花括号
|
|
2441
|
+
// 匹配,\`@container{...{...}}\` 整串不匹配会被跳过,等于绕过 #178 的 AA 主锁
|
|
2442
|
+
// (静默失效,比写错颜色更糟);\`background\` 同理,它是合成对比度的基底;
|
|
2443
|
+
// ③ 有人删掉 \`min(...,100%)\` 的最小轨道下限——只要容器比轨道窄,网格就会横向
|
|
2444
|
+
// 溢出,而外层 .mneme-xcontent 是 overflow:hidden,溢出部分直接被切掉。
|
|
2445
|
+
test("mobile: the narrow-container breakpoints reflow the two-column skeletons instead of shrinking numbers", () => {
|
|
2446
|
+
const cssStart = clientSource.indexOf("const css = [");
|
|
2447
|
+
const cssEnd = clientSource.indexOf('].join("\\n");', cssStart);
|
|
2448
|
+
assert.ok(cssStart > 0 && cssEnd > cssStart, "lib/client.js 里找不到 CSS 数组(切片锚点变了?)");
|
|
2449
|
+
const cssBlock = clientSource.slice(cssStart, cssEnd);
|
|
2450
|
+
|
|
2451
|
+
// 断点表只许三档:640(原收窄档,数值不动)、480(本轮主断点)、360(图标/内边距)。
|
|
2452
|
+
for (const max of ["480px", "360px"]) {
|
|
2453
|
+
assert.ok(
|
|
2454
|
+
cssBlock.includes("@container (max-width: " + max + "){"),
|
|
2455
|
+
"@container (max-width: " + max + ") 必须存在——" + max + " 是移动端骨架换向档,删了就等于整套适配没生效"
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
// 480 是主断点:记忆页与实体页都要换向,且 rail/xside 满宽、限高、可滚。
|
|
2459
|
+
const c480 = /@container \(max-width: 480px\)\{([\s\S]*?)\}\*?\*?\s*",/.exec(cssBlock);
|
|
2460
|
+
assert.ok(c480, "480 档规则必须是一条完整的 CSS 字符串(单行,便于本测试切片)");
|
|
2461
|
+
const body = c480[1];
|
|
2462
|
+
for (const sel of [".mneme-xmain{flex-direction:column}", ".mneme-ent{flex-direction:column}"]) {
|
|
2463
|
+
assert.ok(body.includes(sel), "480 档必须让 " + sel + " —— 左右分栏在窄容器里只能改上下");
|
|
2464
|
+
}
|
|
2465
|
+
assert.ok(/\.mneme-xside\{[^}]*width:100%/.test(body), "记忆页左栏在窄容器里必须满宽");
|
|
2466
|
+
assert.ok(/\.mneme-xside\{[^}]*max-height:min\(/.test(body),
|
|
2467
|
+
"记忆页左栏必须限高可滚:6 段筛选集群在 320px 下自然高 260–320px,不限高会把主区挤成 0");
|
|
2468
|
+
assert.ok(/\.mneme-entrail\{[^}]*width:100%/.test(body), "实体页 rail 在窄容器里必须满宽");
|
|
2469
|
+
assert.ok(/\.mneme-entrail\{[^}]*max-height:min\(/.test(body), "实体页 rail 必须限高可滚");
|
|
2470
|
+
// 换向之后这两条 rail 是「限高」的,限高必须能真收缩:flex:none 在矮工作区里
|
|
2471
|
+
// 不肯让位——42vh 只是上限、不是需求,可 flex:none 让它按内容撑满,主区被
|
|
2472
|
+
// .mneme-xmain 的 overflow:hidden 直接裁成 0 高(CodeRabbit #542)。改成可收缩
|
|
2473
|
+
// 的 flex:0 1 auto 后,min-height:0 才允许它压到上限以下。
|
|
2474
|
+
for (const sel of [".mneme-xside", ".mneme-entrail"]) {
|
|
2475
|
+
assert.ok(new RegExp("\\" + sel + "\\{[^}]*flex:0 1 auto").test(body),
|
|
2476
|
+
sel + " 在窄容器里必须可收缩(flex:0 1 auto),flex:none 会在矮视口里把主区挤没");
|
|
2477
|
+
assert.ok(new RegExp("\\" + sel + "\\{[^}]*min-height:0").test(body),
|
|
2478
|
+
sel + " 必须带 min-height:0,否则 flex 项的内容高度下限会让收缩失效");
|
|
2479
|
+
}
|
|
2480
|
+
// overscroll-behavior 不继承,只能写在真正的滚动容器上:.mneme-entrail 的 base 规则
|
|
2481
|
+
// 是 overflow:hidden(lib/client.js:2042),滚动的是内层 .mneme-entrailbody
|
|
2482
|
+
// (lib/client.js:2046 的 overflow-y:auto)。写在 rail 上等于没写,触屏滚到底会
|
|
2483
|
+
// 串到外层(Copilot #542)。.mneme-xside 的 base 本身是滚动容器,写在它身上对。
|
|
2484
|
+
assert.ok(/\.mneme-entrailbody\{[^}]*overscroll-behavior:contain/.test(body),
|
|
2485
|
+
"滚动链的 overscroll-behavior 必须写在 .mneme-entrailbody(真滚动容器)上,写在 .mneme-entrail 上无效");
|
|
2486
|
+
assert.ok(!/\.mneme-entrail\{[^}]*overscroll-behavior/.test(body),
|
|
2487
|
+
".mneme-entrail 是 overflow:hidden 的非滚动容器,overscroll-behavior 写在它身上是死代码");
|
|
2488
|
+
// 抽屉是覆盖层,窄容器里 52% 只剩 166px;满宽不丢关闭入口(✕ 在 .mneme-drawerbar)。
|
|
2489
|
+
assert.ok(body.includes(".mneme-drawer{width:100%}"), "详情抽屉在窄容器里必须满宽");
|
|
2490
|
+
// 网格轨道的 100% 下限不再锚定在本档:它的作用域是「所有宽度」,而 481–519px
|
|
2491
|
+
// (480 主断点之上、两栏又排不下的中间带)恰恰落回基础规则。锁在基础规则上,
|
|
2492
|
+
// 见本用例末尾的 topLevel 那组。本档只保留窄屏特有的 padding 与滚动链。
|
|
2493
|
+
// 固定宽弹层在 320px 会顶边:340px 的分享弹层比视口还宽。
|
|
2494
|
+
assert.ok(/\.mneme-sharedialog\{[^}]*calc\(100cqw - 32px\)/.test(body), "分享弹层宽度必须跟着容器收");
|
|
2495
|
+
assert.ok(/\.mneme-impdialog\{[^}]*calc\(100cqw - 32px\)/.test(body), "导入弹层宽度必须跟着容器收");
|
|
2496
|
+
// 页签的横向滚动必须住在 480 主断点,而不是 360 兜底档:4 个带图标的 tab 约 436px,
|
|
2497
|
+
// 而 480 档给 .mneme-xbar 的可用宽度是容器宽减 62px(左 10 + 右 52 让位给绝对定位
|
|
2498
|
+
// 的 ⋯),480px 时只剩 418px——整段 361–480px 都会把页签顶到 ⋯ 底下。写错档位的
|
|
2499
|
+
// 失败形态最难查:窄屏照样溢出,而只锁「360 档有这条规则」的测试全绿。
|
|
2500
|
+
assert.ok(/\.mneme-vtabs\{[^}]*min-width:0[^}]*overflow-x:auto/.test(body),
|
|
2501
|
+
"页签横向滚动属于 480 档:只在 360 档兜底等于把溢出留在主断点的整个区间");
|
|
2502
|
+
// 三处窄屏宽度都得带 box-sizing:border-box:全文件没有 *{box-sizing} 兜底,声明的
|
|
2503
|
+
// 那个数是内容宽,外宽还要再加 padding+border——impdialog +30px、menu +12px,320px
|
|
2504
|
+
// 视口下比屏幕还宽,会被 overlay 裁掉。CodeRabbit 只点了 sharedialog,另两处同病。
|
|
2505
|
+
for (const sel of ["menu", "impdialog", "sharedialog"]) {
|
|
2506
|
+
assert.ok(new RegExp("\\.mneme-" + sel + "\\{[^}]*box-sizing:border-box").test(body),
|
|
2507
|
+
".mneme-" + sel + " 的窄屏宽度必须按外宽算(box-sizing:border-box),否则声明值加完 padding+border 就溢出容器");
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
// .mneme-overlay 不在 .mneme-x 之内,容器查询够不到它——它只能走 @media,
|
|
2511
|
+
// 且必须保留基线声明(client.test.js 上有 width:min(1240px,calc(100vw - 88px)) 的锁)。
|
|
2512
|
+
assert.match(cssBlock, /@media \(max-width: 640px\)\{\.mneme-overlay\{/,
|
|
2513
|
+
".mneme-overlay 是容器查询够不到的祖先,它的移动端外框必须用 @media 覆盖");
|
|
2514
|
+
|
|
2515
|
+
// ② 双花括号块内不得出现颜色声明(见文件头注释的失效机制)。
|
|
2516
|
+
const nested = [...cssBlock.matchAll(/@(?:container|media)[^{]*\{/g)];
|
|
2517
|
+
assert.ok(nested.length >= 4, "移动端适配应当新增 ≥4 条 @container/@media 规则(当前 " + nested.length + ")");
|
|
2518
|
+
for (const m of nested) {
|
|
2519
|
+
const at = m.index + m[0].length;
|
|
2520
|
+
let depth = 1, i = at;
|
|
2521
|
+
while (i < cssBlock.length && depth > 0) {
|
|
2522
|
+
if (cssBlock[i] === "{") depth++;
|
|
2523
|
+
else if (cssBlock[i] === "}") depth--;
|
|
2524
|
+
i++;
|
|
2525
|
+
}
|
|
2526
|
+
const inner = cssBlock.slice(at, i - 1);
|
|
2527
|
+
const hit = /(?:^|[;{])\s*(color|fill|stroke|background(?:-color)?)\s*:/.exec(inner);
|
|
2528
|
+
assert.equal(hit, null,
|
|
2529
|
+
"@container/@media 块内不得写颜色(" + (hit && hit[1]) + "):contrast.test.js 的单层花括号提取器会整串跳过该块," +
|
|
2530
|
+
"写进去的色值不会被 AA 主锁审计——这是静默绕过 #178,不是优化");
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
// 触屏没有 hover:设置页删除钮必须常显(#457/#459 两次回滚都是因为连带改了 hover 配色,
|
|
2534
|
+
// 这里只许加 opacity:1,不许在同一条里再动 color)。
|
|
2535
|
+
assert.match(cssBlock, /@media \(hover:none\)\{\.mneme-set-del\{opacity:1/,
|
|
2536
|
+
"触屏设备上 .mneme-set-del 必须常显——它的 base 是 opacity:0,只靠 :hover 恢复,手机上等于永久不可见");
|
|
2537
|
+
// 断点之外还有一段中间带:481–519px 容器宽(480 主断点之上、两栏又排不下)。
|
|
2538
|
+
// 这一段落回基础规则,所以 min(…,100%) 的轨道下限必须写在基础规则上,只在断点里
|
|
2539
|
+
// 写等于给中间带留缺口。本组锁都作用在「剥掉嵌套块之后的顶层规则」上——否则
|
|
2540
|
+
// 480 档里的同名声明会让断言假绿。
|
|
2541
|
+
const topLevel = (() => {
|
|
2542
|
+
let out = "";
|
|
2543
|
+
let i = 0;
|
|
2544
|
+
while (i < cssBlock.length) {
|
|
2545
|
+
const at = cssBlock.indexOf("@", i);
|
|
2546
|
+
out += cssBlock.slice(i, at === -1 ? cssBlock.length : at);
|
|
2547
|
+
if (at === -1) break;
|
|
2548
|
+
const open = cssBlock.indexOf("{", at);
|
|
2549
|
+
if (open === -1) { out += cssBlock.slice(at); break; }
|
|
2550
|
+
let depth = 0, j = open;
|
|
2551
|
+
while (j < cssBlock.length) {
|
|
2552
|
+
if (cssBlock[j] === "{") depth++;
|
|
2553
|
+
else if (cssBlock[j] === "}") { depth--; if (depth === 0) { j++; break; } }
|
|
2554
|
+
j++;
|
|
2555
|
+
}
|
|
2556
|
+
i = j;
|
|
2557
|
+
}
|
|
2558
|
+
return out;
|
|
2559
|
+
})();
|
|
2560
|
+
assert.ok(topLevel.length > 10000, "顶层规则切片异常(" + topLevel.length + " 字符)——切片实现坏了,下面的锁会假绿");
|
|
2561
|
+
assert.match(topLevel, /\.mneme-cards\{[^}]*minmax\(min\(252px,100%\),1fr\)/,
|
|
2562
|
+
"卡片网格的 100% 轨道下限必须写在基础规则上:481–519px 是 480 主断点之外的中间带," +
|
|
2563
|
+
"只在断点里兜底等于让这一段横向溢出 2–11px(外层 overflow:hidden 直接切掉)");
|
|
2564
|
+
assert.match(topLevel, /\.mneme-attrgrid\{[^}]*minmax\(min\(200px,100%\),1fr\)/,
|
|
2565
|
+
"属性网格同理:481–519px 时实体页 detail 只剩 257–276px,裸 200px 下限会溢出");
|
|
2566
|
+
// 图谱的 touch-action 保持 none(这里是从 pan-y 回退后补的锁)。pan-y 那一版想解决
|
|
2567
|
+
// 「手指在图上竖划时页面滚不动」,但它同时把纵向手势交给浏览器做页面平移,而图谱的
|
|
2568
|
+
// 拖拽只挂了 mousedown/mousemove(全仓 onPointerDown/onTouchStart 命中 0)——触屏上
|
|
2569
|
+
// 纵向拖拽到底走哪条路,浏览器是否派发兼容鼠标事件,没有真机无法定论。宁可保留现状
|
|
2570
|
+
// 也不做无法复验的行为变更;真要两步都能用,得改成 Pointer Events,另开车道。
|
|
2571
|
+
assert.match(topLevel, /\.mneme-graphsvg\{[^}]*touch-action:none/,
|
|
2572
|
+
"图谱的 touch-action 必须写在基础规则上:481–519px 这段中间带落回基础规则," +
|
|
2573
|
+
"只在断点里声明等于给中间带留缺口");
|
|
2574
|
+
assert.doesNotMatch(topLevel, /\.mneme-graphsvg\{[^}]*touch-action:pan-y/,
|
|
2575
|
+
".mneme-graphsvg 不得改回 touch-action:pan-y —— 纵向手势会被浏览器拿去滚页面," +
|
|
2576
|
+
"而图谱的拖拽只有鼠标事件实现,触屏拖拽行为无真机无法验证(见上方注释)");
|
|
2577
|
+
});
|
|
2578
|
+
|
|
2579
|
+
// Issue #512:面板详情抽屉要能说清「这条记忆是从哪来的」。
|
|
2580
|
+
//
|
|
2581
|
+
// 归因分两层:窗口级(哪一段原文蒸出来的)与行级(哪一轮铸造的)。这两层
|
|
2582
|
+
// 任何一层缺失都必须显示成「没有归因」,而不是让用户看到空白以为自己眼花了。
|
|
2583
|
+
test("memory drawer surfaces distillation provenance, both layers (issue #512)", () => {
|
|
2584
|
+
for (const key of [
|
|
2585
|
+
"memory.explorer.detail.provenance",
|
|
2586
|
+
"memory.explorer.detail.provenanceEmpty",
|
|
2587
|
+
"memory.explorer.detail.provenanceMint",
|
|
2588
|
+
"memory.explorer.detail.provenanceWindow",
|
|
2589
|
+
"memory.explorer.detail.provenanceNoRun",
|
|
2590
|
+
"memory.explorer.detail.provenanceRecords"
|
|
2591
|
+
]) {
|
|
2592
|
+
// 只数字典定义处("key": 形态)——键在渲染里还会被 t("…") 再引一次,
|
|
2593
|
+
// 数全量出现次数会把两种用法混为一谈。
|
|
2594
|
+
const defined = clientSource.split(`"${key}":`).length - 1;
|
|
2595
|
+
assert.equal(defined, 2, `i18n key ${key} must be defined exactly once in zh and once in en (got ${defined})`);
|
|
2596
|
+
}
|
|
2398
2597
|
|
|
2598
|
+
// 必须真的去读那一个端点,且是读不是算——归因的判据只有后端一处。
|
|
2599
|
+
assert.ok(clientSource.includes("/api/dsh-mneme/memory/provenance?id="),
|
|
2600
|
+
"the drawer must fetch the provenance endpoint");
|
|
2601
|
+
// 两层都要渲染:只渲染窗口会让铸造出来的 pattern 永远显示「没有来源」,
|
|
2602
|
+
// 而它们恰恰是归因最该说清的一类(没有会话区间可看)。
|
|
2603
|
+
// 断到 .map( 为止:只写 "provenance.mints" 会被空态判断里的 .length 满足,
|
|
2604
|
+
// 于是「渲染被整段删掉」这种回归照样能过(本用例的变异自证就是从这里翻车的)。
|
|
2605
|
+
assert.ok(clientSource.includes("provenance.mints.map("), "mint-level attribution (which run minted the row) must render");
|
|
2606
|
+
assert.ok(clientSource.includes("provenance.windows.map("), "window-level attribution (which transcript) must render");
|
|
2607
|
+
assert.ok(clientSource.includes("\"memory.explorer.detail.provenanceNoRun\""),
|
|
2608
|
+
"a legacy link without a run id must say so explicitly, not render an empty cell");
|
|
2609
|
+
|
|
2610
|
+
// 读失败要落回「没读到」而不是伪造一份空归因:两者在界面上是不同的话。
|
|
2611
|
+
assert.ok(clientSource.includes("/api/dsh-mneme/memory/provenance?id=") && clientSource.includes("setProvenance(null)"),
|
|
2612
|
+
"a failed read must clear the block instead of pretending there is no attribution");
|
|
2613
|
+
});
|
package/test/config.test.js
CHANGED
|
@@ -4,6 +4,14 @@ import { readFileSync } from "node:fs";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { Config } from "../src/config.js";
|
|
6
6
|
|
|
7
|
+
test("session incognito is opt-in, with a default-on subordinate selector", () => {
|
|
8
|
+
const cfg = Config({});
|
|
9
|
+
assert.equal(cfg.sessionGateEnabled, false);
|
|
10
|
+
assert.equal(cfg.sessionGateSubagents, true);
|
|
11
|
+
assert.deepEqual(cfg.sessionGateSessions, []);
|
|
12
|
+
assert.deepEqual(Config({ sessionGateSessions: ["s1", "s2"] }).sessionGateSessions, ["s1", "s2"]);
|
|
13
|
+
});
|
|
14
|
+
|
|
7
15
|
// ---------------------------------------------------------------- rerank opt-in (item ⑥)
|
|
8
16
|
|
|
9
17
|
test("rerank is opt-in: default config does not enable the local reranker", () => {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
|
|
5
|
+
// Issue #529(B 项):冲突裁决台账(conflict_reviewed)的存储层契约。
|
|
6
|
+
//
|
|
7
|
+
// 为什么单开一张表而不是复用 conflict_pending:后者是**人工**队列——面板徽标数
|
|
8
|
+
// 就是 countConflictPending()(resolved_at IS NULL 的行数),机器每判一对就写一行
|
|
9
|
+
// 会把那个数字淹掉,人再也看不出"有哪些待我裁决"。台账是机器记账,零用户界面。
|
|
10
|
+
//
|
|
11
|
+
// 这些用例锁三件事,防的回归是:
|
|
12
|
+
// ① pair 键不归一化 → 同一对(A,B)与(B,A)会记成两行,下一轮照样重送;
|
|
13
|
+
// ② 重复记账不是幂等 upsert → 同一对判过 N 次就堆 N 行,表无界增长;
|
|
14
|
+
// ③ 记账误落 conflict_pending → 人工队列被机器行淹没(这是最贵的一种)。
|
|
15
|
+
|
|
16
|
+
test("issue#529 conflict_reviewed:写入后可读回,键归一化为 (a,b)", () => {
|
|
17
|
+
const store = createStore(":memory:");
|
|
18
|
+
store.saveConflictReviewed({
|
|
19
|
+
run_id: "run-1",
|
|
20
|
+
memory_a: "b-id",
|
|
21
|
+
memory_b: "a-id",
|
|
22
|
+
outcome: "conflict",
|
|
23
|
+
judged_at: "2026-10-31T00:00:00.000Z"
|
|
24
|
+
});
|
|
25
|
+
const all = store.loadConflictReviewed();
|
|
26
|
+
assert.equal(all.size, 1, "只落一行");
|
|
27
|
+
const key = [...all.keys()][0];
|
|
28
|
+
assert.equal(key, "a-id\u0000b-id", "键按字典序归一化——反着传也是同一个键");
|
|
29
|
+
const row = all.get(key);
|
|
30
|
+
assert.equal(row.outcome, "conflict");
|
|
31
|
+
assert.equal(row.run_id, "run-1");
|
|
32
|
+
assert.equal(row.judged_at, "2026-10-31T00:00:00.000Z");
|
|
33
|
+
store.close();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("issue#529 conflict_reviewed:同一对重复记账是 upsert,不堆行", () => {
|
|
37
|
+
const store = createStore(":memory:");
|
|
38
|
+
for (let i = 0; i < 3; i++) {
|
|
39
|
+
store.saveConflictReviewed({
|
|
40
|
+
run_id: "run-" + i,
|
|
41
|
+
memory_a: "a-id",
|
|
42
|
+
memory_b: "b-id",
|
|
43
|
+
outcome: "keep",
|
|
44
|
+
judged_at: "2026-10-31T00:00:0" + i + ".000Z"
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const all = store.loadConflictReviewed();
|
|
48
|
+
assert.equal(all.size, 1, "同一对只留一行");
|
|
49
|
+
assert.equal(all.get("a-id\u0000b-id").run_id, "run-2", "后写的覆盖先写的");
|
|
50
|
+
assert.equal(all.get("a-id\u0000b-id").judged_at, "2026-10-31T00:00:02.000Z");
|
|
51
|
+
store.close();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("issue#529 conflict_reviewed:记账不进人工待裁决队列", () => {
|
|
55
|
+
const store = createStore(":memory:");
|
|
56
|
+
for (let i = 0; i < 5; i++) {
|
|
57
|
+
store.saveConflictReviewed({
|
|
58
|
+
run_id: "run-" + i,
|
|
59
|
+
memory_a: "a-" + i,
|
|
60
|
+
memory_b: "b-" + i,
|
|
61
|
+
outcome: "keep",
|
|
62
|
+
judged_at: "2026-10-31T00:00:00.000Z"
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
assert.equal(store.countConflictPending(), 0, "关键:机器记账不涨人工队列的徽标数");
|
|
66
|
+
assert.deepEqual(store.listConflictPending(), []);
|
|
67
|
+
assert.equal(store.listConflictReviewed().length, 5, "但台账里确实有 5 行");
|
|
68
|
+
store.close();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("issue#529 conflict_reviewed:listConflictReviewed 按判时倒序", () => {
|
|
72
|
+
const store = createStore(":memory:");
|
|
73
|
+
store.saveConflictReviewed({ memory_a: "a", memory_b: "b", outcome: "keep", judged_at: "2026-10-30T00:00:00.000Z", run_id: "r1" });
|
|
74
|
+
store.saveConflictReviewed({ memory_a: "c", memory_b: "d", outcome: "conflict", judged_at: "2026-10-31T00:00:00.000Z", run_id: "r2" });
|
|
75
|
+
const rows = store.listConflictReviewed();
|
|
76
|
+
assert.equal(rows[0].judged_at, "2026-10-31T00:00:00.000Z", "最新的在前");
|
|
77
|
+
assert.equal(rows[1].judged_at, "2026-10-30T00:00:00.000Z");
|
|
78
|
+
store.close();
|
|
79
|
+
});
|
package/test/continuity.test.js
CHANGED
|
@@ -7,6 +7,7 @@ import { createStore } from "../src/store.js";
|
|
|
7
7
|
import { createService } from "../src/service.js";
|
|
8
8
|
import { createTools } from "../src/tools.js";
|
|
9
9
|
import { Config, applyLightModePreset, injectChildEnabled } from "../src/config.js";
|
|
10
|
+
import { createSessionGate } from "../src/session-gate.js";
|
|
10
11
|
import {
|
|
11
12
|
CONTINUITY_KIND,
|
|
12
13
|
CONTINUITY_NOTICE_PREFIX,
|
|
@@ -272,7 +273,7 @@ test("#249 N3:门控不调模型(静态锁)与挂载点的父开关在前"
|
|
|
272
273
|
// 只剩 Config 里的默认值撑着,接线漏一次没人发现。
|
|
273
274
|
const index = readFileSync(new URL("../src/index.js", import.meta.url), "utf8");
|
|
274
275
|
assert.match(index, /if \(cfg\.autoInject && injectChildEnabled\(cfg, "continuityRescueEnabled"\)\) \{/);
|
|
275
|
-
assert.match(index, /disposers\.push\(createContinuityRescue\(ctx, store\)\)/);
|
|
276
|
+
assert.match(index, /disposers\.push\(createContinuityRescue\(ctx, store, sessionGate\)\)/);
|
|
276
277
|
});
|
|
277
278
|
|
|
278
279
|
test("#249 N3:reject 放行且不消费边缘;缺 session 不抛", async () => {
|
|
@@ -295,6 +296,33 @@ test("#249 N3:reject 放行且不消费边缘;缺 session 不抛", async ()
|
|
|
295
296
|
rescue.dispose();
|
|
296
297
|
});
|
|
297
298
|
|
|
299
|
+
test("#539:等待 pre-step 期间切到无痕会清除先前边缘,不落提案也不注入", async () => {
|
|
300
|
+
const store = createStore(":memory:");
|
|
301
|
+
const ctx = mockCtx();
|
|
302
|
+
const config = { sessionGateEnabled: false, sessionGateSessions: ["s1"] };
|
|
303
|
+
const gate = createSessionGate(config);
|
|
304
|
+
const rescue = createContinuityRescue(ctx, store, gate);
|
|
305
|
+
const session = fakeSession("s1", [userEvent(1, "私有指令")]);
|
|
306
|
+
|
|
307
|
+
ctx.fire("session/event", session, { type: "compaction/end", seq: 7 });
|
|
308
|
+
const decision = await ctx.fire(
|
|
309
|
+
"agent/pre-step",
|
|
310
|
+
{ agent: { session } },
|
|
311
|
+
async () => {
|
|
312
|
+
config.sessionGateEnabled = true;
|
|
313
|
+
await Promise.resolve();
|
|
314
|
+
return { kind: "enter", messages: [] };
|
|
315
|
+
}
|
|
316
|
+
);
|
|
317
|
+
assert.deepEqual(decision.messages, []);
|
|
318
|
+
assert.equal(store.getContinuityProposal("s1", KIND), null);
|
|
319
|
+
|
|
320
|
+
config.sessionGateEnabled = false;
|
|
321
|
+
const next = await ctx.fire("agent/pre-step", { agent: { session } }, async () => ({ kind: "enter", messages: [] }));
|
|
322
|
+
assert.deepEqual(next.messages, [], "a stale edge from before the toggle is not consumed later");
|
|
323
|
+
rescue.dispose();
|
|
324
|
+
});
|
|
325
|
+
|
|
298
326
|
test("#249 N3:契约——默认关、父关不生效、轻量档压掉、降级规则只在开启时进描述", () => {
|
|
299
327
|
assert.equal(Config({}).continuityRescueEnabled, false, "新增注入表面 → opt-in 默认关");
|
|
300
328
|
assert.equal(applyLightModePreset({ lightMode: true }).continuityRescueEnabled, false, "轻量档压掉(同 injectGuidanceEnabled)");
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// 两个 overrides 存在的唯一理由是 osv-scanner 的红灯,而红灯的判据是**已提交的
|
|
2
|
+
// lockfile**——所以这里锁的是 lockfile 的解析结果,不是 node_modules 里装了什么。
|
|
3
|
+
// 2026-10-31 的现场:同一条 lockfile 在 08:36 是绿的,04:42 那次 push 却是红的,
|
|
4
|
+
// 中间没有任何依赖变更——上游在 23:09 发了两条新公告(glob 超时后仍继续遍历、
|
|
5
|
+
// minimatch globparts 放大 DoS)。换句话说,这个文件里的断言会**因为上游多一条公告
|
|
6
|
+
// 而变红**,那是它应该做的事:红在本地,比红在 main 上便宜。
|
|
7
|
+
//
|
|
8
|
+
// 依赖链只有一条,且全部是 devDependency:
|
|
9
|
+
// c8@12 → test-exclude@8 → glob + minimatch
|
|
10
|
+
// 两个上游包都已经是 npm 上的最新版、其 deps 仍写旧范围,所以只能在本仓库抬 pin。
|
|
11
|
+
// 一旦上游跟上、这条链不再解析到旧版本,下面的 overrides 断言就会失败——那时应当
|
|
12
|
+
// 删掉这两条 overrides,而不是把断言删掉。
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
20
|
+
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
|
|
21
|
+
const lock = JSON.parse(readFileSync(join(PKG_ROOT, "package-lock.json"), "utf8"));
|
|
22
|
+
|
|
23
|
+
// 公告给出的修复下限。低于它 = osv-scanner 会在 main 上红,这里先红。
|
|
24
|
+
const FLOORS = {
|
|
25
|
+
glob: { fixed: [14, 0, 2], cve: "CVE-2026-118773 / GHSA-3g3m-2fjr-6pw4" },
|
|
26
|
+
minimatch: { fixed: [11, 0, 1], cve: "CVE-2026-118764 / GHSA-g3hv-75wg-m249" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function parseVersion(v) {
|
|
30
|
+
return String(v).split(/[.+-]/).slice(0, 3).map((n) => Number.parseInt(n, 10) || 0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function gte(a, b) {
|
|
34
|
+
for (let i = 0; i < 3; i++) {
|
|
35
|
+
if (a[i] !== b[i]) return a[i] > b[i];
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test("glob 与 minimatch 在 lockfile 里都解析到公告的修复下限之上", () => {
|
|
41
|
+
for (const [name, { fixed, cve }] of Object.entries(FLOORS)) {
|
|
42
|
+
const entry = lock.packages[`node_modules/${name}`];
|
|
43
|
+
assert.ok(entry, `lockfile 里应当有 node_modules/${name}`);
|
|
44
|
+
const actual = parseVersion(entry.version);
|
|
45
|
+
assert.ok(
|
|
46
|
+
gte(actual, fixed),
|
|
47
|
+
`${name} 解析成 ${entry.version},低于 ${cve} 的修复下限 ${fixed.join(".")}——osv-scanner 会在 main 上红`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// overrides 必须是**精确版本**,不能是 ^ / ~ 范围。
|
|
53
|
+
// 范围等于把选择权交回解析器:下一次上游发版,lockfile 会在某个「只更新 lockfile」
|
|
54
|
+
// 的 PR 里悄悄漂走,而红绿灯本该由人决定。brace-expansion 那次(PR #345)就是精确号。
|
|
55
|
+
test("overrides 是精确版本号,不是范围", () => {
|
|
56
|
+
for (const name of Object.keys(FLOORS)) {
|
|
57
|
+
const pin = pkg.overrides?.[name];
|
|
58
|
+
assert.equal(typeof pin, "string", `package.json overrides 里应当钉住 ${name}`);
|
|
59
|
+
assert.ok(
|
|
60
|
+
/^\d+\.\d+\.\d+$/.test(pin),
|
|
61
|
+
`${name} 的 override 应写精确版本(如 14.0.3),实际是 ${JSON.stringify(pin)}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// 钉版本是权宜之计,不是永久配置:这条链还在(c8 → test-exclude → glob)就说明
|
|
67
|
+
// 上游仍会解析到旧范围,overrides 必须留着。上游哪天收了,这条会红,提示来删 pin。
|
|
68
|
+
test("devDependency 链仍在:c8 → test-exclude → glob", () => {
|
|
69
|
+
const c8 = lock.packages["node_modules/c8"];
|
|
70
|
+
assert.ok(c8, "lockfile 里应当有 c8");
|
|
71
|
+
assert.ok(c8.dependencies?.["test-exclude"], "c8 仍应依赖 test-exclude");
|
|
72
|
+
const testExclude = lock.packages["node_modules/test-exclude"];
|
|
73
|
+
assert.ok(testExclude, "lockfile 里应当有 test-exclude");
|
|
74
|
+
assert.ok(
|
|
75
|
+
testExclude.dependencies?.glob,
|
|
76
|
+
"test-exclude 仍应依赖 glob——它一旦不再依赖,glob/minimatch 两条 overrides 就可以删了",
|
|
77
|
+
);
|
|
78
|
+
});
|