Repository navigation
Add Model Manager mini app by ocoomber - #4
Conversation
A MiniApp for browsing, searching, and enabling/disabling models in ~/.minimax/config.yaml directly from MiniMax Code. - Works with any provider (OpenRouter, custom, local endpoints) - Instant save per toggle; bulk enable/disable with one-level undo - Automatic timestamped backups before bulk changes - Atomic, line-based YAML editing preserving indentation and line endings - Restart reminder banner; OpenRouter model page links - English and Simplified Chinese READMEs; MIT licensed
The UI navigates by model family (ID prefix) only. All manageable model blocks in config.yaml are merged into a single family-grouped list; blocks without enabled flags (e.g. the built-in always-on provider) are skipped as before.
|
Thanks for the contribution — this is the first Mini App in the repo that writes to the host config, and the line-based approach that preserves indentation and unknown keys is a sensible choice. Package-level checks all pass: Because this plugin modifies 1. Required before merge (CONTRIBUTING / disclosure)a. README is missing the tested environment. CONTRIBUTING #3 asks each README to state tested client versions and operating systems. The PR description has "Windows 10.0.26200 x64, MiniMax Code Desktop, plugin v1.2.2" — please add that (with the MiniMax Code version) to both READMEs, and note that macOS/Linux are untested if that's the case. b. File access / process disclosure is incomplete. The Privacy section says the app "reads and writes only your
Both are fine, but the repo's model is "users read the README to decide what to install", so please list them. c. Two statements in the PR description have no counterpart in the diff.
d. Hardcoded 2. Please fix or disclose (data safety on
|
…1.2.3) server.mjs: - resolve config.yaml from the runtime dataDir first, ~/.minimax fallback - preserve each line's own terminator (CRLF/LF/CR mixed files stay intact) - atomic write keeps the original file mode; temp file removed on failure - serialize mutations behind a promise queue so overlapping requests cannot clobber each other or the undo snapshot - one-level undo now refuses (stale) if the file changed outside the app since the snapshot - backups moved next to config.yaml (backups/), pruned to newest 20 - 500 responses no longer leak raw error text; configPath removed from /api/providers client (index.html): - Undo enabled only when something actually changed; stale-undo shows a reload hint - group headers are real buttons with aria-controls (no nested interactive elements) - context menu closes on Escape and returns focus to the link - inline SVG icons (no emoji), no infinite pulse animation, warning colors from the visual-baseline tokens repo: - add tests/parser.test.mjs (node --test) covering EOL round-trip, provider discovery, colon ids, enabled flags - README/README.zh-CN: tested environment (Windows 11, MiniMax Code 3.0.73), macOS/Linux untested, process-spawning + temp-file + backups disclosures, corrected secrets wording, plugin-ID note - plugin.json: v1.2.3 + exampleQueries
|
Thanks for the careful review, @MyPrototypeWhat — everything is addressed in db10720 (v1.2.3). Point by point: Required fixes
Data-safety fixes
Suggestions taken
Happy to adjust anything further — thanks again. |
|
Thanks for the fast and thorough turnaround — I went through Confirmed resolved: tested-environment section in both READMEs; process-spawning and temp-file disclosure; PR body no longer claims Two things remain — one required, one your call. A. Required (pick either option): README vs. actual config resolutionThe README now says: "the runtime resolves Why: Reproduction (real Note the path: neither candidate existed, so L31 Either of these is fine with us:
B. Suggestion (your call): backups locationv1.2.3 moved backups from The README discloses this accurately, so it's not blocking. But from the Host side we'd prefer the v1.2.2 location ( Minor (non-blocking)
Once A is settled, this looks good to merge from our side. Thanks again! |
…e whole action (v1.2.4)
server.mjs:
- resolveConfigPath now walks ancestors of context.dataDir looking for config.yaml (matches the current Host layout where context.dataDir is several levels below the data root) and falls back to ~/.minimax/config.yaml; if neither is found it returns the default rather than a missing path
- DATA_DIR captured at startup so backups land under the plugin-owned namespace (context.dataDir/backups), pruned to the newest 20 — matches the 'store durable state under dataDir' runtime guidance and avoids touching anything in the data-root namespace
- /api/bulk now accepts {enabled, providers: [{provider, models}]} so a single bulk request takes one snapshot and one write; Undo reverts the whole action regardless of how many providers it touched
client (index.html):
- bulk() sends one multi-provider request to /api/bulk (instead of one Promise.all per provider); Undo enabled iff any model changed
tests/:
- parser.test.mjs: +1 test covering the multi-provider toggle invariant (ids remain valid across providers in a single write)
- resolveConfigPath.test.mjs (new): 3 tests covering the four-ancestor walk, the default fallback, and a sibling-ancestor config
READMEs (EN + zh):
- config-location: rewrote to describe the parent walk accurately, called out that the walk matches an implementation detail of the current Host layout, and noted the default fallback is what protects non-default installs
- backups: relocated disclosure to dataDir/backups (plugin-owned)
- Undo: noted that multi-provider bulk is reverted as one operation
- Tests/-folder wording: clarified it lives outside the Host's runtime payload roots (miniapp/client, miniapp/node) rather than being excluded from a directory copy
- Tested env: bumped to v1.2.4
plugin.json: 1.2.4
|
Thanks again for the close read — both items addressed in ff05789 (v1.2.4), plus the two minor nits. A. Config resolution (required) — went with option 2 (walk up) so non-default data directories still work.
The walk finds Added B. Backups location (your call) — moved back to Minor items
Ready for re-review whenever you are. Thanks! |
|
Thanks — re-reviewed Confirmed:
Two places where the README doesn't match the build — please fix before merge:
Non-blocking suggestions:
Once (1) and (2) are in, this is good to merge from our side. Thanks for the quick turnarounds. |
…ecks ## What changed Contributors — most of them working with AI coding agents — had no way to learn the Mini App package contract without access to the desktop app. This branch puts the contract in the repository and gates pull requests on it. - `AGENTS.md`: the entry point coding agents load automatically — steps, hard rules, review policy, navigation. - `docs/package-contract.md`, `docs/runtime.md`, `docs/security.md`: layout, the three manifests, portable paths and limits, the Node runtime contract, and the security rules. Each states the MiniMax Code version it was verified against. - `examples/hello-miniapp/`: the smallest valid package, copyable as a starting point. Its README doubles as the template for the two headings the check looks for. - `npm run check`: a zero-dependency validator (`scripts/validate.mjs`) plus its tests (`node --test`, 55 cases). It checks manifest field sets, patterns, enums and limits; portable paths and package size; `node_modules`; the Node entry's `start` export and stdout use; README headings (warning only); and, repository-wide, plugin-ID uniqueness and root README links. Plugin capabilities (Skills, MCP servers, hooks, host bindings, MCP endpoints, Connector access) are validated for shape only; MiniMax Code validates their contents at install time. - `.github/workflows/ci.yml`: runs `npm run check` on every pull request and push to `main`. - `CONTRIBUTING*.md` / `README*.md`: point at the example, the docs, and the check. The MiniApps tables and the two existing community packages are untouched. ## Validation - `npm ci && npm run check` on the branch head: `examples/hello-miniapp` OK; `plugins/amszuidas/mcode-token-usage-board` and `plugins/yanhy2000/mcode-usage-monitor` 0 errors (two README-heading warnings each, by design); `repository` OK; 55/55 tests; CI green. - The package from PR #4 (`openrouter-model-manager`, a package that runs on a real host) validated separately: 0 errors, 1 warning. - Example runtime: `start(context)` → `GET /dashboard` 200 → `dispose()` frees the port; nothing written to stdout. - Example installed into a real MiniMax Code (3.0.73, macOS) and opened through the Agent: page rendered. ## Not covered - No runtime smoke test in CI (a follow-up). - README headings are a warning, not an error, until the existing packages adopt them. - The validator's constants mirror the host's reader by hand; the `Verified against` markers and header comments in the host repository are the sync mechanism.
…l-manager # Conflicts: # README.md # README.zh-CN.md
|
Thanks — both blockers are fixed in 98279e4. The single-model helper is named setModelEnabled again, so POST /api/set now reaches the real implementation. I verified the installed v1.2.4 plugin in MiniMax Code with a per-row toggle, and added tests/api-set.test.mjs to exercise the real HTTP endpoint and confirm config.yaml changes. Both READMEs now document node --test. Result: 11/11 plugin tests pass. I also merged the latest upstream main, kept both new app-table entries while resolving the README overlap, and updated the PR description to match the current test suite. |
R6 允许控件所在层与它写入的层不同,条件是这对关系被写下来。此前它只存在于 docs/LAYERS.md 的一句话里,没有任何断言,于是改位置或改归属时没有一处会先红。 verify-cross-layer.mjs(12 条)+ mutate-cross-layer.py(6 条变异全捕)守的是配对, 不是整洁:DOM 父节点说这个控件被呈现成哪一层,赋值说它写哪一层,改任何一半而不 声明,配对就不成立。 声明 1 #window-bar 在 L5 面板内、写 L3(只写模块级 ledgerWindow / ledgerProgress) 声明 2 #overview 持有写 L2 字段 state.overviewSelection 的控件 六条变异分别对应六条具名断言,守卫必须按名字报出:把 window-bar 挪出面板、给它加 一个 state. 赋值、给进度写者加一个、把 state.overviewSelection 塞进窗口条、让刷选 落点不再写该字段、让 L2 入口不再读它。 第 6 条变异重锚过一次:var selection = state.overviewSelection; 在 renderOverviewSelection() 里也有一处,锚在 visibleEntries() 的函数头上,否则命中 的是 L3 的那个读者,而 L2 入口会保持完好、变异存活。变异不删,重锚。 本条刻意不覆盖 R5,而 R5 没有闭合:state.overviewSelection 今天有 5 个写点 (clearOverviewSelection / setActualDuration / onOverviewPointerMove / onOverviewPointerUp / resetBaseFilters),没有一个是 R5 要求的那个单一 setter。 断言「已经收敛」是一条过不了的守卫,断言「当前是 5 个」是一条恒真的守卫,两条都 是错的写法,所以这个缺口留在文档里而不是被断言盖住。收它属于 D4。 同时把两项设计写进 docs/LAYERS.md,都标为「设计已完成,等指示」,未写实现代码: 按 AGENTS.md 的硬约束,四个问题的答案落文档之前不写实现。 Stage 2(异常轴建模,MiniMax-AI#4)读点已数清:anomalyRules 4 写 13 读、multiAnomaly 3 写 2 读、anomalyLast 7 写 1 读。R2 单一入口成立——flattenTurns() 产出 {turn, record},所以两个入口拿到的本来就是同一个 turn 对象。对外口径不变, 但 state 形状变,verify-anomaly.mjs(53 KB)与 mutate-anomaly.py(29 KB)里 守形状的断言要逐条改写、守行为的原样保留,一条都不删。建议分三次独立提交。 Stage 4(增量协议 ?since=,MiniMax-AI#2)调研结论改变了收益估计:payload 只有 session / stats / turns 三个键,41 MB 全在 turns,而 stats 是累加器必须全量重算——所以 ?since= 省的是传输与客户端 JSON.parse,不是服务端 CPU。游标选 record.index。 这是本轮唯一一处真口径变更(applyPayload 从整体替换变追加合并),读点已列全, 写侧只有 1 处。 产品代码零改动:index.html 仍为 310,348 bytes,套件 36 → 38 条全绿。
…e machine (#16) * test(mmc-trajectory): ship the guards that were only on one machine The PR #15 review asked whether the rule-name guard could be reproduced, and noted that the script behind it is not in the repository. It was not: the mutation controls and verifiers lived in a scratch directory on the machine that wrote the client, with absolute paths in them. Fifteen of them now live in tests/, and they run on a clean checkout with nothing but Node and Python: node tests/run.mjs Every path resolves from the directory's own location, and client-check.mjs writes its extracted copy of the inline script to a temporary directory rather than into the checkout. Why half of them are mutate-*.py: a guard that cannot fail is not a guard, because it reads as coverage. Each one breaks exactly one rule in the shipped source, requires the guard that owns that rule to fail *and* to name the right check, then restores the file and requires green again. Two of the six report "all N mutations caught"; the rest print each mutation and the check that caught it. Four were left out rather than carried: mutate-filter-guards.mjs, mutate-qa-lens.py Guard the layer buttons and the turn lens. Commit 91464cd removed the layer presets in favour of the type chips; turns-btn, setLayer, LAYER_KINDS and applyLayerToKinds now occur zero times in index.html. The rule they guard is gone. mutate-steer.py, mutate-window-default.py The rules are still live -- verify-window-widen.mjs still covers the second one -- but the mutation anchors no longer match the source. These need re-anchoring, which is a separate change; shipping them red would only teach reviewers to ignore the suite. tests/README.md records the same reasoning, and states plainly that the one-shot patch scripts that write the package are not here and must not be run: they have no dry-run and no backup, they write straight back to index.html / server.mjs / the READMEs, and re-running one edits text that has since moved on. 15/15 pass. The four payload files are byte-identical to main -- this commit adds only tests/. * test(mmc-trajectory): make the session-store guards machine-independent verify-parsers.mjs and verify-prompt-fix.mjs read this machine's ~/.minimax. On a runner without one the suite drops to 13/15: verify-parsers crashes and verify-prompt-fix fails two checks, so the guards would have gone red the moment CI was allowed to run. They now read a committed synthetic store under tests/fixtures/minimax-home. The fixture is distilled from real transcripts rather than hand-written: make-fixtures.mjs keeps the structure the assertions depend on -- roles, content-part types, stopReason (the reply/narration split turns on it), toolCallId pairing, usage numbers, and the injection markers splitUserMessage() branches on -- and replaces every string payload. Three things keep that honest: - a leak assertion on the rendered bytes refuses to write if a path, user name or store reference survived synthesis (--selftest proves it trips); - a coverage gate refuses to write unless all eight required shapes are present, so a sparse sample cannot pass by accident; - toolCall id / toolResult toolCallId are memoised per original value, so corpus pairing survives -- regenerating each occurrence independently silently broke it and the pairing assertion would have failed. `node tests/run.mjs --real` still runs the two guards against the local ~/.minimax, for checking a change against transcripts nobody curated. Also corrects verify-prompt-fix.mjs, which passed resolveSessionsRoot().home to createRedactor -- that property does not exist on the returned object, so the store root was never one of the redacted secrets. 15/15 on this machine, 15/15 on a runner with no ~/.minimax, 15/15 with --real. The four payload files are byte-identical to main; this adds only tests/. npm run validate reports plugins/avatasia/mmc-trajectory: OK. * test(mmc-trajectory): guard the rest, with no service and no machine of its own The suite went 15 -> 22. The six guards that were only runnable against a booted service on a developer machine now read a committed payload instead, and the two mutation controls whose anchors had stopped matching work again. Seven guards wanted one /api/trajectory body and got it by fetching it from a port read out of .probe/port.txt. The route is buildTrajectoryPayload(...) plus a few session.* fields, so the same body falls out of the fixture store offline -- make-payload.mjs writes it, with --check to catch drift between the two fixtures. Distilling that store properly needed the whole lineage. A compacted session keeps its old messages in snapshots/g<gen>--ctx_*.jsonl, and the shapes the guards need -- an empty reply above all -- live in those older generations. Reading only the active file found none of them and verify-anomaly failed for want of something to find. The distiller now walks the lineage and the coverage gate requires all ten shapes, including one that produced no segment at all: server.mjs only pushes a text:null reply when segmentOrder is empty, so an assistant message that called a tool but said nothing is not a silent reply. Two of these were not what they looked like: - mutate-steer.py was not stale. server.mjs is CRLF and the anchor joined its lines with \n, so a multi-line anchor matched zero times while every single-line anchor in the same file matched fine. Anchors are now built in the file's own line ending. - mutate-window-default.py was stale, but not where it looked: the rule is intact and rePinWindow had gained a time-range early-return *between* the two lines the anchor spanned. verify-block-order.mjs was wrong about the rule it names. It broke a run only on a non-assistant record, so two assistant messages in one turn were ranked against each other and three toolCall -> thinking pairs were reported that never occurred inside any message. The rule is per-message; splitting runs at the message boundary finds more runs, not fewer (16 -> 19, 0 violations). mutate-anomaly.py does not carry a case that nulls the page's liveTurn, even though that looks like the obvious thing to break. verify-anomaly.mjs re-derives the live turn itself instead of calling the page's function, so the mutation is invisible to every assertion in the file -- tried, and the probe passed. Shipping it would ship a mutation known to be unobservable. The gap is named in tests/README.md instead. verify-time-range.mjs and mutate-time-range.py are present but skipped by run.mjs: their assertions are calibrated against a full multi-generation session (stats.messages > 1000, a checkpoint nested inside a turn) and reproducing that means committing a multi-megabyte payload. Relaxing the numbers would stop exercising the timezone arithmetic the file exists for. 22/22 here, 22/22 on a runner with no ~/.minimax, 22/22 with --real. The four payload files are byte-identical to main; this adds and changes only tests/. npm run validate reports plugins/avatasia/mmc-trajectory: OK. * Remove the commit-message scratch file that git add swept in * Give 异常 its own axis, and make its counts mean what they say The 异常 row was the last field of the search form, which put it above the KPI cards and the type axis — four cards away from the control it reads as part of. It now sits directly under 全部/用户/追加, because both axes answer the same question: which records the ledger shows. It is a second filter system rather than another field of the first. Ticking a rule switches the type axis, the search box, the time range and the overview brush off; a new 全部数据 button — the state the page opens in — switches them back on with their values intact. The inactive controls are `inert`, not merely dimmed: a control that takes a click and then changes nothing on screen is the defect this change is about, and reintroducing it one line away would be worse than the original. The session and generation pickers are deliberately left working — they choose which session is on screen, not which records of it. The counts followed. anomalyCounts() walked passesBaseFilter while the ledger filtered the same way, so a button promising 283 rows sat above a list of 1,494 — reproducible on a 15k-record session, and the arithmetic was right in both places, which is why nothing caught it. It now walks the records anomaly mode actually shows, so the number on a button is what ticking that button puts on screen. A rule scoped to a turn is counted once per turn and published in 轮: 无答复 was advertising 1,244 "unanswered" rows in a session that was broken 13 times, none of which carried a sign of being unanswered. The unit comes from the rule table, so a rename or a rescope cannot leave it lying. verify-anomaly.mjs 116 → 158 assertions. The two that required the count to follow the other axis are inverted rather than deleted — they were correct while the ledger filtered the same way, and they are the reason the mismatch had a written spec. Added: the unit follows the rule table, a turn rule is counted per turn, the axes are alternatives rather than a stack in both the ledger and the overview strip, the way back exists and is not a toggle, the inactive axis is inert, and the position of the axis on the page. mutate-anomaly.py 36 → 45 mutations, one per new invariant. Two cases were repointed and one deleted: they encoded the stacked-axis behaviour that has just been removed, and the anchor of a third had moved with the markup. Also fixes a dead class — `.anomaly-btn.is-empty` was toggled on every render and had no rule anywhere in the stylesheet. 22/22 locally, 22/22 on a clean runner, 22/22 with --real. * Make 重置 a reset, and put the row above back when a rule is ticked The button added in the previous commit as 全部数据 was neither of those things. It is 重置: not a reverse selection — nothing is being inverted — but the row returning to the state it was in on arrival, where the table is filtered by the row above. That distinction has a consequence worth spelling out. In 异常 mode the row above filters nothing, so a 重置 that restored what that row used to hold would be restoring a condition the page is not applying. It resets it instead — and so does ticking a rule, on the way in. Otherwise the axis keeps drawing 「用户」 while the table is showing tool failures: the same defect as a button whose number does not match its list, one control over. Ticking a rule therefore puts the row above back to its default — every type, no search, no range, no brush. `resetBaseFilters()` sets the state *and* calls `syncKindButtons()`, because a write without the sync is what leaves the axis drawing what was there before. That combination has its own mutation control. 重置 also drops its count. 「怎么多会留下多少」 is a question about a rule, and a control that keeps whatever the row above says has no answer to it; printing a number there only invites adding it up against the numbers beside it. verify-anomaly.mjs 158 → 172 assertions. mutate-anomaly.py 45 → 50 mutations, five new controls for the reset. One of them survived the first run, because the assertion that catches it was in client-check.mjs and this runner does not execute that file; the assertion moved to the file whose runner can, rather than being dropped. client-check's "state.kinds is written from exactly three controls" becomes four, for the reason its own comment demands — change the check in the same commit that adds the control. 22/22 locally, 22/22 on a runner with no ~/.minimax, 22/22 with --real. * 异常轴加「多选」:默认单选,勾选后可叠加 多选是一个模式开关,不是第五条规则:它在规则按钮组之外,所以不会被 syncAnomalyMode 的 data-filter-scope 打成 inert——那会把它唯一一条退路掐掉。 单选语义下点第二条是替换而非叠加,重复点同一条才关掉;多选下点第二条才叠加, 点已选中的那条只摘掉它。关掉多选时保留最后勾选的那条(读者刚才在看的那条), 而不是表格第一条。重置把规则和多选一起清回默认,并复位 checkbox。 守卫:verify-anomaly 172 -> 196,mutate-anomaly 50 -> 62。 - 两条旧断言(ticking a second keeps table order / unticking removes only that one)编码的是 toggle 无条件叠加的旧语义,本轮按需求推翻重写:单选写到单选 块,原来的并集语义挪到多选块重断言,不是删掉。 - 新增覆盖:关多选保留最后勾选那条、多选开关不改变已选集合、重置同时关掉多选 并复位 checkbox、每次 render 从 state 重绘 checkbox、checkbox 不得带 data-filter-scope、change 而非 click 绑定。 - 修了 FAILED_NAMES 用 split(':')[0] 取名会把带冒号的断言名截断的 bug——它 让变异跑器把「已抓住」读成「没抓住」。名字改为单独收集,可读输出不变。 浏览器实测(大会话 126 轮):单选替换、并集叠加(343 = 200 已显示 + 143 未 加载,与 324+19 有 1 条重叠吻合)、关多选保留最后勾选那条、重置回到默认且 checkbox 视觉复位。 * 异常轴复审:修正三处控件状态与合计口径,并沉淀验证方法论 黑盒走了一遍按钮矩阵,发现三处缺陷——都是「控件说了什么」而不是「控件算了什么」, 读代码读不出来,因为算的那部分是对的。 1. 部分并集时,提示把「合计」指向「全部异常」。实测:多选下勾 工具失败+上下文压缩, 提示写「合计见「全部异常」」,而「全部异常」是四条规则的全集 1,707 条,当前所选的合计 约 354 行。按提示去找合计,拿到的是另一个选择的数。 改为在 anomalyCounts() 同一次遍历里数出所选规则的并集,提示直接给这个数。 2. 多选已勾但未选规则时,「重置」自称「已是默认状态」且 aria-pressed=true。实测证据: 多选可见、规则全未选,重置仍读「已是默认状态」。但默认是单选,而且多选会改变之后每次 点击的语义(替换 vs 叠加),重置是唯一能把模式拨回去的控件——告诉读者已经在默认态, 他就没有理由去按它。 3. 单选下按「全部异常」会用多选词汇描述状态:提示写「已叠加 4 条规则」,而多选是关的, 读者并没有叠加。改为「全部 4 条规则同时生效」。 顺带把 counts 改成单次快照:render() 原来为提示和按钮各走一遍,而会话在轮询间隙还在长, 两遍可能给出不同数字。现在一次 anomalyCounts() 同时喂两者——提示和按钮不可能各答各的。 守卫:verify-anomaly 214 -> 234,mutate-anomaly 62 -> 70。 - 补上了此前根本没测的部分并集:�ll.any 是四条规则的全集,逐条规则的检查又只选一条, 所以多选唯一能产出的那种选择此前一条断言都没有。现在对 6 个规则对逐一断言 counts.selected == visibleEntries().length,并用与页面无关的代码独立算出期望值。 - 两条新变异第一次跑就存活:一条是我把变异写成了空操作(sel.length === 0 || 在有选中时 等价于原式),换成了「去掉 break」——只在两条规则重叠于一条记录时才出错的那种;另一条 指向了不存在的断言。两条都是补守卫,不是删变异。 - 每条规则对的断言名带规则对,人读着好用,但变异跑器按名字精确匹配,哪一对命中取决于夹具。 因此另加族级断言(名字稳定)作为变异的归属,逐对的保留作诊断。 新增 tests/METHODOLOGY.md:验证方法论。每条规则都对应一次真实代价,包括本轮踩的—— 读父容器而不是过滤子元素、期望值只能来自冻结夹具、空操作变异、断言名不能带冒号、 散文要跑出来断言而不是看源码形状、以及工具驱不动的事要写明驱不动。 浏览器实测:多选两条 → 提示「合计 354 条」,账本 200 已显示 + 154 未加载 = 354; 单选按全部异常 → 「全部 4 条规则同时生效 · 合计 1,716 条」,账本 200 + 1,516 = 1,716; 多选未选规则 → 重置改为「点击回到默认状态」。 未验证:多选 checkbox 的键盘操作。Shift+Tab 在该 harness 里移不动焦点(焦点环仍在重置上, Space 无效),所以该结论只由静态守卫(绑定 change 而非 click,配变异对照)加原生行为支撑, 不做端到端声称。 * 把两条筛选轴钉成一块,并把下方每层的偏移改成实测 滚动时看不到异常筛选:类型轴自己 claim 过 position: sticky,异常轴没有,于是 筛选过的会话滚下去之后,页面上没有任何一行还在说明「现在被筛过什么」。 - `.filter-sticky` 把类型轴和异常轴包成一个钉在 top: 0 的不透明块。它们回答 同一个问题(账本显示哪些记录),分开钉就会出现「类型轴还在、异常轴滚走了」 的半截状态,比完全不钉更糟。 - 下方三层的偏移不再写死。原来的 0–32 / 32–83 只在「总览条上方只有一行筛选轴」 的布局里成立,而异常轴会随窄屏换行、提示行的出现和消失改变高度,任何常量都会 悄悄变成重叠。`syncStickyOffsets()` 在 `render()` 末尾量三个块,写进 `--sticky-band-h` / `--sticky-overview-h` / `--sticky-window-h`,样式表只读变量。 - 轮头从 `top: 0; z-index: 2` 移到实测偏移下方。它原先压在总览刻度和类型轴按钮 上,两处都读不出来;z-index 6 的筛选带盖住它只是把重叠藏起来,不是修好。 守卫:`verify-sticky.mjs`(20 条断言)把 syncStickyOffsets() 拿 137 / 65 / 57 的 stub 高度跑一遍——文件里没有任何常量等于这三个数,所以「写数字而不是量」的版本 过不去,把 band 量成 strip 的版本也过不去。`mutate-sticky.py` 18 条变异逐条要求 它以正确的名字失败,其中三条是补了守卫才有的:样式表读到的变量集合必须等于测量 写出的集合(单向都不够)、被测量的块必须真的绑定到 markup 里存在的 id(stub 永远 喂得起函数,抓不到这种静默回退)、以及任何钉在账本之上的层都必须有不透明背景 (账本行是 `background: transparent`)。 测试 22 → 24 个守卫,全绿。 * 补一份分层设计:功能类型 → 层 → 归属 这个页面的筛选逻辑是一次加一个功能长出来的,代价能逐条指到代码: - 「看哪些记录」这一个职责住在文档位置 3 和 5 两处(工具条 / 筛选带), 中间隔着整块 KPI 区。上一轮的 sticky 修复是这笔债的利息不是本金。 - state 里两句注释互相矛盾:`kinds` 写「类型选项是唯一筛选」,`rangeFrom` 写「区间是 KPI 跟随的唯一筛选」。两句各自描述一个更早的页面。 - kpiStats() 只跟区间,不跟类型轴、不跟搜索、不跟异常轴——汇总层和筛选层 共用了同一套视觉语言。 - 异常轴被实现成「第二套系统」而不是筛选层的一个字段,于是互斥只能散落在 两个渲染函数里手写。上一轮修的三处缺陷没有一处是算错,全是「两个筛选器 各自推理自己」的症状。 - window-bar 在列表面板里,写的却是「渲染多少」这一层的事。 docs/LAYERS.md 给出 L0–L6 的归属表、六条不可违反的规则,以及实现之前必须先 答的四个问题:归层、关联、布局、口径。最后一问是「联动异常」的主要来源—— 加了一个能力改了一个数字,别的地方还在按旧口径说话。 本 PR 不改页面代码,Stage 1–3 在文档里分阶段列出。 * Stage 1 冻结 spec:先写清四问与不变量,再动结构 按 AGENTS.md 的强约束,Stage 1 在实现之前先把设计冻结成可判定的断言。 docs/STAGE1-SPEC.md: - 四问在本阶段的答案。L2 容器装四组控件(搜索 / 区间 / 类型轴 / 异常轴), L1 的会话与上下文代留在外面,不动 Stage 2 的模型,一个数字都不改。 - 口径读点清单:KPI↔区间这条口径共有 4 个页面读点、4 个守卫读点。其中 verify-time-range.mjs 在 run.mjs 的 SKIP 集合里默认不跑,所以这条口径在 默认套件里目前没有保护——而 Stage 1 恰好要动 KPI 的位置,所以必须补一条 能默认跑的断言,不能依赖那个跳过文件。 - 10 条断言,每条都写了「必须拒绝的错误实现」和配套变异。其中 V7(把区间塞进 类型轴)是这一阶段最容易犯的错。 - 4.1 节记了搬家会引出、原 scope 没写的后果,逐条对过代码: F1 .toolbar-row 承载区间那行的 flex,移出表单后静默失配; F2 form#toolbar 的 submit handler 只为拦搜索框的隐式提交,搬走后表单里只剩 两个 select,role="search" 成了假话; F3 异常模式的 inert 靠后代选择器 [data-filter-scope],搬家后仍生效,异常轴 本身不带该属性(否则会把自己 inert 掉); F4 applyFilterScope() 名字说 L2、只写 L4 的口径行——记进 LAYERS.md 的 D7, 本阶段不改名,留给 Stage 2。 跨家族只读查漏试了 codex / claude / codebuddy,三个都被认证挡住(模型不被 ChatGPT 账号支持 / OAuth 过期 / 需要 /login),三次都设了只读机制且比对过文件 哈希,未被改动。所以这份 spec 是「还没查过漏的」,已在 7.1 节写明解锁方式。 24/24 守卫全绿,validate OK。 * Stage 1 spec 修订:跨家族查漏的 15 条结论,改了 6 条断言、加了 3 条后果 用 codex / gpt-5.6-sol(OpenAI,与本会话的模型不同家族)做只读查漏。 Windows 沙箱在 exec 下初始化失败,第一次交白卷——它明确拒绝捏造文件:行, 且哈希确认未改动文件。改为把材料内联进 stdin 后跑通。 只有 gpt-6.1-sol 被 ChatGPT 账号拒绝,gpt-5.6-sol 等可用。 它找对的(已接受并改进 spec): - V1 的变异是空操作:把 KPI 接在四组之后时四组仍连续,V1 照样通过。 这是我给守卫定的规矩(变异存活就补守卫)在自己身上失效了。改成把 KPI 插在搜索与区间之间,并让 V2 用「接在四组之后」的互不等价变异。 - V1/V2 共用同一变异,等于其中一条没被测到。已拆开。 - V10 只覆盖一种丢标记方式。拆成 V10a/b/c + V11。 - 第 4 问的口径表漏了一个读点:#kpi-scope 不只是三句措辞,还发布 stats.messages 与 stats.turnsWithPrompt 两个数字。 - 同一份表里我把写 #kpi-scope 的函数写成了 updateKpiScope()——那个函数 不存在,真正的写入者是 applyFilterScope(),也就是 D7 说「名字与职责不符」 的那个。我在自己的文档里把它的名字写错了一次。 - 「保留 form 因为 select 需要 label 关联」是错的:for/id 关联与是否在 form 内无关。改为直接删掉失去对象的 submit handler。 - verify-sticky.mjs「只改指向」不足:现有子树断言只要求类型轴与异常轴同在 band 内,扩到四组必须重写成员与排他性,并显式排除 L1 与 L4。 - I1 用下标比大小发现不了中间的插入;改为解析容器子节点的标签序列。 - I4 的不透明判断太粗;限定为已定义的 --mcode-* token。 - 焦点顺序变化(会话→代→搜索→区间→KPI 变成 KPI 插进筛选流程中间)不能列为 「不验证」——DOM 顺序正是本阶段直接改的东西。新增 F5 并撤销原排除。 - aria-controls 只指向 overview、而同一筛选也控制账本;L2 容器也没有可访问 名称。新增 F6。 - 窄屏下四组全 sticky 可能占满视口。新增 F7 与 B1–B4 四条浏览器补测。 它指错的(已驳回): - 「目前没有保护」过度表述。spec 第 94 行原文是「在默认套件里目前没有保护」, 已经限定范围;它引的第 88 行是表格行,那行里没有这句话。 - verify-sticky.mjs:79 的行号落在 value() 这个辅助函数上,不是它要谈的 子树成员断言。 断言 10 条 -> 15 条(V10 拆三条 + 新增 V11/V12),后果 4 条 -> 7 条, 浏览器补测 4 条。spec 第 8 节留了完整对照表,含驳回项与理由。 24/24 守卫全绿,validate OK。spec 仍未冻结,需再查漏一轮。 * Make the inspector follow the table, and own the re-measure at one exit Selecting a record and then changing any filter left the inspector describing a row that was no longer on screen. Two functions decided what 'selected' meant and they read different lists: the rows were built from visibleEntries(), while revealSelection() and selectedRecord() scanned state.flat — the whole session, unfiltered. Worse, the only caller of revealSelection() was applyPayload(), and only when the payload signature changed, so no local filter change ever reached it. The ledger read 已显示 2 条, the inspector still named the old record, and no row anywhere carried the selection mark; 复制 JSON and 交给 AI 分析这条 read the same stale record and stayed enabled. revealSelection() is now handed the very array the rows are built from and is called from renderLedger(), the one exit every path that changes what the table shows goes through — nine of them call it directly instead of calling render(). It reports whether it dropped the selection, and the exit redraws the panel when it did: clearing the id alone is not enough, because those nine callers never redraw it, so the search box left the panel naming a record that had just been dropped. selectedRecord() stays a plain lookup and documents why that is safe. The sticky offsets had the same shape of problem. syncStickyOffsets() had exactly one call site — the end of render() — while nine writers change what the table shows without going through render(), so the invariant lived in a convention rather than in the structure. There was also no resize hook at all, and on a session whose payload has stopped growing every poll is a 304 and render() never runs, so a window resize that wrapped the type or 异常 row left the pinned strips at the old width indefinitely. The measurement now runs at the same renderLedger() exit, and resize re-measures through a requestAnimationFrame so a window drag does not measure on every event. docs/LAYERS.md D1 is withdrawn. Treating the filter controls being split across two regions as structural debt was wrong: query conditions are an aggregation, and the frontend UI being separate from the request construction is the design. docs/STAGE1-SPEC.md is marked not-adopted with the reason. D8 and D9 record what was actually wrong, and both are closed. Guards 24 -> 26. verify-selection.mjs runs revealSelection() against a state carrying both the session and the filtered list, so a version that reads the unfiltered one again answers wrongly instead of throwing; mutate-selection.py breaks nine of its rules. verify-sticky.mjs gains five assertions for the new exit and the resize hook; mutate-sticky.py gains seven mutations. One of the nine mutations survived the first run — it moved the call to the line above the slice, which still raised state.limit in time, so it changed the text and not the page; the mutation was rewritten to actually move it past the slice. * Stop rebuilding the whole lineage on every poll A finished session costs nothing: the ETag is derived from stat facts, so an unchanged session is a 304 and nothing is rebuilt. The cost is the session that is still growing, which is the one a reader is watching on purpose — and there every poll rebuilt everything from scratch. Measured on a 23-generation session (payload.session.sizeBytes = 99,986,501): * 22 of the 23 generation files are snapshots, written once when the runtime rotates generations. Between compactions they do not move at all. Only messages.jsonl is appended to — 97,526 bytes, 0.10% of the lineage. * trajectoryCacheKey() carries each generation's byteLength, so a growing session misses the whole-session cache every poll and re-reads and re-parses all 95 MB, 99.90% of it byte-identical to the previous poll. * On the wire: 1.31 GB delivered over five minutes to carry 118,875 bytes of real growth. 11,006x amplification, 99.99% re-sent. Two fixes, both on the read path. Per-generation parsed rows. loadParsedRows() keys a cache on path+size+mtime+limit and hands back the rows of any generation file that has not moved. Cold 306 ms, warm 2 ms over the 22 frozen files, measured directly against the real files. Bounded by bytes rather than entries, because entries say nothing about volume — one generation file is 40 KB and the next is 10 MB. * The key names the path, not the basename: every session has a messages.jsonl, so a basename key lets two of them collide. The fixture builds exactly that collision — same size, same mtime, different conversations — and asserts neither sees the other's rows. * The key carries the size and the mtime rather than the catalog's recorded byteLength, which this file already documents as a snapshot that goes stale and that the active file grows past between polls. * The key carries the limit, because readHead truncates at it and the truncation verdict is part of what the entry holds. * A file that changes between the stat and the read lands on a key that describes a size it does not match; the next poll stats again and misses. The race can only cost a redundant read. The untouched message, on demand. buildRawPayload() ran JSON.stringify on every record to measure it against a 64 KB cap — about 100 MB per rebuild — to produce a string that was thrown away, because splitRawRecords() strips the message off the bulk response before it is ever serialized and /raw serves the message object rather than the string. A row now carries the position of its message instead, and /raw builds it for the one record that asked. That moves 「原文过大,已省略」 out of the bulk payload and into the /raw response. It is a contract change, so every reader is listed in LAYERS.md D11 and asserted by name: none of the five is in the ledger — all of them are on the path a reader takes after opening a record. The only visible difference is that the raw tab now spends one request to learn a message was too large instead of knowing it in advance. Guards 26 -> 30, mutations 34 -> 58, all caught. verify-parsed-rows.mjs 53 assertions. A hit is compared against a cold read by serializing both bodies and requiring them equal, not merely similar. A version that read the wrong source, or keyed on the wrong fields, produces the wrong answer here rather than looking plausible. verify-raw-lazy.mjs 39 assertions. The 64 KB cap is asserted through the path that now owns it, rather than dropped: the locator is followed to its row and buildRawPayload() is run there. Three things the mutations caught in this change and the guards did not have: * A locator written as Number(record.rawRow) accepted null as row zero — Number(null) is 0 — so /raw would have answered with a different record's message. Now rejected rather than coerced. * Removing the delete that takes the locator off the row let rawRow ship on every record. Nothing asserted its absence. * mutate-pr15-fixes.py had a mutation whose anchor this change erased. It was reporting SKIP, not a pass, but a dead mutation reads like a green one; it is re-anchored to the line that now carries the same decision. Correction to LAYERS.md D10, which this change's measurement disproved: the read-and-parse was ~306 ms, not the ~1,000 ms written there. That number was obtained by subtracting the serializer from the rebuild total, which folded buildTrajectoryPayload and buildRawPayload into "read and parse". D10's payoff is therefore ~300 ms of a ~1,400 ms rebuild, and the resident heap is a measured 116 MB rather than the 130-290 MB estimated there. * Build the ledger in batches, and stop the inspector widening the window Two defects, both reported from the same session, both with a cause that was structural rather than a missing line. **A — 「显示全部」 froze the page.** Building 18,924 rows inside one synchronous loop took 3,956 ms of main thread with nothing on screen saying so. Measured on this machine (150 turns, 18,924 records, 735x900): batched build (25 turns/frame) 3,956 ms total row construction median 141 ms / max 151 ms per batch between frames ~617 ms <- layout + paint of the whole document The blocking work was not the row construction. It was each frame's layout pass over a document that was already two million pixels tall, so making the batches smaller does not help — it multiplies the passes. The fix keeps the promise and changes the pacing: one rAF builds at most 25 turn groups, then hands the thread back, and the window bar reports 「正在显示 N / M 条」 while it goes. Every pass through renderLedger() bumps ledgerBuildToken first, so a build still in flight retires itself at its next step instead of appending into a list that has been cleared underneath it. The poll tick checks ledgerBuilding() first and remembers the tick — at 3,956 ms against a 2,500 ms interval, letting every tick through would restart the build before it ever finished and the reader would watch the count reset for ever. flushDeferredPoll() runs the held tick once the build ends. Nothing about what the window shows has changed. 「显示全部」 still means every record. **Rejected, and recorded in LAYERS.md D12 because it is the obvious next question:** `content-visibility: auto` on `.turn-group` is a real 4.0x — 3,956 ms to 979 ms, same row count, and the per-frame layout cost disappears with it. It needs one `contain-intrinsic-size` for elements it skips, and the 150 turn groups span 1,011 px (p25) to 29,754 px (max), median 2,667 px. Any single value makes the scroll height wrong by a large factor, and `auto` only remembers sizes of groups that have already rendered. That is trading a control that describes the page accurately for speed, so it is not shipped. **B — the inspector survived the record it was describing.** Select an early record in 「显示全部」, press 「显示最新 200 条」, and the row leaves the table — but the panel went on naming it. revealSelection() was judged before the window was sliced and raised state.limit to reach the selection, so the reader's own click changed the number on the bar a second later: press 「显示最新 200 条」 已显示 200 条 one second later 已显示 15,224 条 Two things wrong with that: the window became something the inspector could write, which gave state.limit a second writer, and the test was the wrong one — 「what the reader can see」 is the window, not the filter result. revealSelection() is now a predicate over the rows that will actually be drawn. It writes state.selectedId and nothing else, and it runs after the slice. A selection below the window is dropped rather than revealed: the reader's explicit window choice outranks an earlier click, and it matches what the filter axes already do. **Guards** - tests/verify-selection.mjs — rewritten, 14 assertions. This is a 口径 change, not a refactor: the file previously pinned 「the window is pulled down to a selection below it」 (asserting limit === 8) and that the judgement ran *before* the slice. Both are now the opposite, and the new promise is asserted instead, including that the function's source may not mention `limit` at all. - tests/verify-ledger-batch.mjs — new, 20 assertions over the four things that have to hold together: the frame is really handed back; a new exit retires the build in flight; progress goes through updateWindowBar, the one writer of that count; a poll defers without swallowing the tick. - tests/mutate-ledger-batch.py — new, 19 mutations, all caught by name. - tests/mutate-selection.py — two mutations rewritten rather than deleted. The old 「the window is never pulled down to the selection」 was guarding the very state.limit write this change removes; it now breaks the rule that replaced it. The old 「the selection is judged after the window is sliced」 guarded the opposite ordering, so it now breaks the new one. - mutate-sticky.py, mutate-turn-breakdown.py, client-check.mjs — re-anchored and tightened where the refactor moved text. The window-bar total is now pinned on both of its branches, since it is written twice. **Verified** - `node tests/run.mjs` — 32/32 locally and in the fork; `--real` also 32/32 - mutate-selection 9/9, mutate-ledger-batch 19/19, mutate-sticky 14/14, mutate-turn-breakdown 12/12 - `npm run validate` — mmc-trajectory OK, repository OK - Browser, served bytes == disk bytes (302,384): the progress readout observed live mid-build at 「正在显示 15,813 / 19,150 条」, then settling to 「已显示 19,157 条」; with row #1 selected and the window narrowed, the inspector returned to 「尚未选择记录」, no row carried aria-current, and the count stayed at exactly 「已显示 200 条 · 更早的 18,964 条未加载」 instead of silently widening. Console clean. The remaining lever on the A side is making the two-million-pixel number smaller — capping 「显示全部」, or the incremental protocol. Both change what the page promises, so neither was done inside a performance fix. * Record the eight undone items, and one measured option that was rolled back Documentation only. No code in this commit: index.html and the tests are byte-identical to 5b3f29b. **Added section 7 — the eight undone items**, each with what it is, whether it is code / verification / process, what blocks it, and the next step. Item 1 is the one I left myself: D12 only fixed half of the freeze — the batching changed the shape of the wait, not its length. **Revisited the `content-visibility` rejection in D12, because the reason I gave was half wrong.** I rejected it for an 11x spread in turn-group height (median 2,631 px against a maximum of 29,754 px), which is true but is the spread *between* groups. Inside a group the height is not a guess at all. Measured over 152 groups and 19,315 rows: plain rows n=19,292 min 36 median 36 max 36 turn heads n=152 min 39 median 39 max 39 bands n=23 min 38 median 38 max 38 group chrome n=152 min 0 median 0 max 12 Plain rows are min == max == 36 px across all 19,292 of them. The summary column is nowrap + ellipsis, so it never wraps — "row height varies with content" was never true, and I had assumed it was. So I implemented the version that computes `contain-intrinsic-size` per group from (rows, compaction bands), with the three heights read off the real DOM after the first batch rather than written down as constants. It measures: total build 782 ms (3,956 ms batched -> 5.1x; 979 ms for the CSS constant) self-calibrated head 39.40 · row 36 · band 38.40 groups sized 152 / 152 predicted height 705,230 actual 705,529 error -0.04% document height 705,627 at the bottom, `auto` remembering rendered groups Verified in the browser: scrolled to the bottom of the 705,627 px document, rows render with real geometry, and a deep record (turn 27, #2129) is clickable with the inspector filling correctly. **Rolled back, because of something I saw and could not attribute.** With a record selected in 「显示全部」, after roughly three poll-driven rebuilds the viewport went from turn 27 back to turn 1. I did not find the cause and did not rule out that it predates the change. D12's poll deferral was designed for a ~4 s build; at 782 ms it almost never engages, so the table now rebuilds on nearly every poll — every 2.5 s instead of every ~6.5 s — which makes a jump more likely to be seen. That is inference, not a conclusion. The reason for not shipping is not that the gain is too small. The gain is measured. It is that the cost is not measured, and scroll anchoring is exactly what the D9 work exists to protect. Being 5x slower is better than shipping an unlocated regression on that path. D12 now records the numbers, what was verified, what was not, and the first step to finish it: instrument `restoreRowAnchor()` and confirm whether `anchorOffset()` returns a real position or 0 for a row inside a skipped subtree — a skipped subtree's rect reads as 0, not null, and 0 is a position like any other. Section 7 item 1 is updated to say the option is measured and rolled back rather than untried. * docs(mmc-trajectory): 归因滚动跳转为既有缺陷 D14,解除 content-visibility 路的阻塞 上一轮回退逐组推导 contain-intrinsic-size 版(782 ms / 5.1×)的唯一理由,是一次 「视口从第 27 轮跳回第 1 轮」的滚动回归未能归因。本轮把它查清了。 控制实验先做:在**已发布代码**上(无 content-visibility、无探针)跑同一条路径, 视口同样跳回第 1 轮,而检视面板仍正确显示 #2780 · 第 29 轮。所以它与 content-visibility 无关。 给 renderLedger() 的捕获点与 restoreRowAnchor() 打点后的实测(19,626 行 / 155 轮 / 文档高 716,000 px / 视口 841×900): cap ySave=715080 yReal=616 doc=1515 <- clear(ledger) 把文档压到 1515px,scrollY 被夹到 616 rst after=707051 delta=707056 yOut=708185 <- 从被夹住的位置出发,仍正确还原 cap ySave=715080 yReal=616 doc=1515 (无 rst:这次建行被点击取代,restoreRowAnchor 从未执行) cap ySave=5530 yReal=1065 doc=1965 <- 点击触发的 renderLedger,锚点已经错了 rst docYWas=5515 docYNow=5515 <- 忠实地还原到了一个错误的位置 restoreRowAnchor() 本身是对的,即使起点被夹到 616 / 2,051 也能算出正确 delta 落回 原位。真正的丢失点是 buildStep() 的提前 return: if (build.token !== ledgerBuildToken) return; D12 给在途分批定的规则「就地退出、不碰 DOM」是对的,但退出时没人把滚动位置交还给 下一个建行,而下一个建行捕获锚点时读到的是已被 clear(ledger) 夹断的位置。一旦落到 顶,之后每轮都拿「第 1 轮」当锚点,自我维持。 触发面:任何在轮询建行途中直接调用 renderLedger() 的读者动作,最大的一处是 selectRecord()——点任意一行都会整表重建。「显示全部」下建行 3,956 ms、轮询 2.5 s, 所以点一行大概率落在建行途中。 对照组证明变量唯一:同一份代码、同一处点击,点行时没有建行在途,则 delta 0、 yIn = yOut = 105,494,位置纹丝不动。 记为 D14,并给出未实施的修法:把「读者想去哪」从「建行结束时算」提前成 「第一次捕获时记下,此后每次建行都转交」,一个写手一个字段。 同时更新 D12 与第 7 节未做清单:#1 的阻塞标记为已解除,新增 #1b 记录 D14。 代码与测试逐字节未动(302,384 bytes),32/32 守卫全绿。 * fix(mmc-trajectory): 滚动意图跨建行转交,点行不再把读者留在第 1 轮(D14) D14 的机制:位置不是丢失,是被摧毁再重建,而摧毁与重建之间没有任何东西。 clear(ledger) 之前 doc = 716,016 px scrollY = 715,080 clear(ledger) 之后 doc = 1,515 px scrollY = 616 ← 文档塌了,浏览器夹断 scrollY 建行 6 帧约 3,956 ms,期间 scrollY 停在被夹住的位置 buildStep() 收尾 restoreRowAnchor() 把位置算回来 restoreRowAnchor() 本身是对的,即使起点被夹到 616 / 2,051 也能算出正确 delta 落回 原位。丢失发生在 buildStep() 的提前 return: if (build.token !== ledgerBuildToken) return; D12 给在途分批定的规则「就地退出、不碰 DOM」是对的,坏的是退出时没人把滚动位置交还 给下一个建行,而下一个建行捕获锚点时读到的是已被 clear(ledger) 夹断的位置。一旦落到 顶,之后每轮都拿「第 1 轮」当锚点,自我维持。 触发面:任何在轮询建行途中直接调用 renderLedger() 的读者动作,最大的一处是 selectRecord()——点任意一行都会整表重建。「显示全部」下建行 3,956 ms、轮询 2.5 s, 所以点一行大概率落在建行途中。 修法:把「读者想去哪」从「建行结束时算」提前成「第一次捕获时记下,此后每次建行转交」。 一个模块级字段 pendingAnchor,一个写手 forgetScrollAnchor()。 - renderLedger() 只在 pendingAnchor 为空时捕获 {row, y},之后每一趟沿用。 - restoreRowAnchor() 第一句就是 forgetScrollAnchor(),它是唯一以还原结束一链的地方。 - 五条提前返回各调 forgetScrollAnchor():它们把账本换成状态块,没有可锚的行, 丢弃而不是还原,否则会把针对旧会话或旧筛选测出来的位置套到另一个上面。 顺带修掉的一处(是上面那条的前提,不是顺手):ledgerBuildToken += 1 从 applyFilterScope() 那一侧移到 clear(ledger) 之上。四条提前返回都在自增之前返回,它们同样清空账本却什么都 不退休,于是在途建行会继续往骨架屏里追加轮组,随后还会拿一张已不存在的表的锚点去移动 页面。现在自增在清空之前,五条路径走同一句。 口径不变:显示全部仍是全部,窗口条读数不变,选中语义不变。 守卫: - 新增 tests/verify-anchor-carry.mjs(10 条断言)。捕获那三句是跑出来的—— firstVisibleRow() 与 window.scrollY 换成返回另一个位置的桩,一个每次重读的版本会给出 桩的答案而不是预设的答案;消费者按条数点,clear(ledger) 到 var groups 之间的 return; 必须与 forgetScrollAnchor() 一一对应。 - 新增 tests/mutate-anchor-carry.py,6 条变异全捕。 - mutate-ledger-batch.py 原有两条变异锚在 ledgerBuildToken += 1 后面那一行,自增移动后 失效——已重新锚定到 clear(ledger);,一条未删,并新增一条「把自增放回提前返回之下」 忠实复现本缺陷。 - verify-ledger-batch.mjs 从「数一次自增」加强为「数自增与 clear() 之间的 return;」, 原来那条对四条提前返回完全失明。 浏览器实测(本机,19,991 条 / 156 轮 / 视口 841×900):显示全部 → 滚到第 133 轮 #17188 → 点一行 → 等重建。建行途中视口短暂塌到第 51 轮(预期内),建行收尾后回到第 134 轮 #17207,正是点行前那一行;再点一行仍在第 134 轮 #17215。修复前同一操作把读者永久留在 第 1 轮。 34/34 守卫全绿,npm run validate 报 plugins/avatasia/mmc-trajectory: OK。 index.html 纯 LF,305,391 bytes。 * perf(mmc-trajectory): 逐轮组推导 contain-intrinsic-size,「显示全部」建行 3,437 → 817 ms(D15) D12 把等待的形状改了(进度可见、分批交还主线程、在途可打断),等待的长度没变。 本条改长度。 做法:.turn-group 的 content-visibility: auto 由账本容器上的一个 class 打开,不是 无条件打开;class 只在第一批建完、并且从真实 DOM 量出行高/带高/头高之后才加上—— 写死的常量就是猜别人屏幕的字体与缩放,所以不写死,用中位数不用均值。之后每个轮组 在建行那一刻按 (行数, 压缩带数) 算自己的 contain-intrinsic-size,格式带 auto 前缀, 让浏览器优先采用它已经渲染过一次的真实尺寸,来回滚动不累积漂移。 口径不变:「显示全部」仍然是全部,窗口条读数不变,选中语义不变,粘性偏移不变。 syncStickyOffsets() 仍是 buildStep() 的最后一句。 实测(同一会话内的 A/B,两臂都在同一浏览器、同一份数据上量): 基线(class 不加) 3,437 ms 159 组 20,246 行 逐组推导(class 加) 817 ms 159 组 20,182 行 文档高误差 +0.07% D12 当初挡下这条路的那个疑问已有实测答案:content-visibility: auto 跳过的子树里, 行仍然有真实布局盒——离视口 733,916 px 的行报出 top: -733915.94, w: 776, h: 36, 不是 0 也不是空矩形。于是 anchorOffset() 在 cv 之下成立,D14 的锚点没有被破坏: 顶部、深处(640,165)、近底部(740,165)三个位置跨十几趟重建,preY 与 postY 逐 像素相同,期间会话从 20,552 长到 20,573 行;深处点一行之后屏上仍是同一批行。 已知未解释项(已在 docs/LAYERS.md 记为待复现,不写成缺陷):某一趟近底部的重建 postY 比 preY 高了 77,226 px 而屏上行未变,之后同一深度未能复现。 守卫:verify-intrinsic-size.mjs(14 条)+ mutate-intrinsic-size.py(9 条变异全捕)。 另重新锚定 mutate-ledger-batch.py 里一条因 build 字面量新增字段而失效的变异。 * test(mmc-trajectory): 声明 R6 的两处跨层配对,Stage 3 完成(未做清单 #5) R6 允许控件所在层与它写入的层不同,条件是这对关系被写下来。此前它只存在于 docs/LAYERS.md 的一句话里,没有任何断言,于是改位置或改归属时没有一处会先红。 verify-cross-layer.mjs(12 条)+ mutate-cross-layer.py(6 条变异全捕)守的是配对, 不是整洁:DOM 父节点说这个控件被呈现成哪一层,赋值说它写哪一层,改任何一半而不 声明,配对就不成立。 声明 1 #window-bar 在 L5 面板内、写 L3(只写模块级 ledgerWindow / ledgerProgress) 声明 2 #overview 持有写 L2 字段 state.overviewSelection 的控件 六条变异分别对应六条具名断言,守卫必须按名字报出:把 window-bar 挪出面板、给它加 一个 state. 赋值、给进度写者加一个、把 state.overviewSelection 塞进窗口条、让刷选 落点不再写该字段、让 L2 入口不再读它。 第 6 条变异重锚过一次:var selection = state.overviewSelection; 在 renderOverviewSelection() 里也有一处,锚在 visibleEntries() 的函数头上,否则命中 的是 L3 的那个读者,而 L2 入口会保持完好、变异存活。变异不删,重锚。 本条刻意不覆盖 R5,而 R5 没有闭合:state.overviewSelection 今天有 5 个写点 (clearOverviewSelection / setActualDuration / onOverviewPointerMove / onOverviewPointerUp / resetBaseFilters),没有一个是 R5 要求的那个单一 setter。 断言「已经收敛」是一条过不了的守卫,断言「当前是 5 个」是一条恒真的守卫,两条都 是错的写法,所以这个缺口留在文档里而不是被断言盖住。收它属于 D4。 同时把两项设计写进 docs/LAYERS.md,都标为「设计已完成,等指示」,未写实现代码: 按 AGENTS.md 的硬约束,四个问题的答案落文档之前不写实现。 Stage 2(异常轴建模,#4)读点已数清:anomalyRules 4 写 13 读、multiAnomaly 3 写 2 读、anomalyLast 7 写 1 读。R2 单一入口成立——flattenTurns() 产出 {turn, record},所以两个入口拿到的本来就是同一个 turn 对象。对外口径不变, 但 state 形状变,verify-anomaly.mjs(53 KB)与 mutate-anomaly.py(29 KB)里 守形状的断言要逐条改写、守行为的原样保留,一条都不删。建议分三次独立提交。 Stage 4(增量协议 ?since=,#2)调研结论改变了收益估计:payload 只有 session / stats / turns 三个键,41 MB 全在 turns,而 stats 是累加器必须全量重算——所以 ?since= 省的是传输与客户端 JSON.parse,不是服务端 CPU。游标选 record.index。 这是本轮唯一一处真口径变更(applyPayload 从整体替换变追加合并),读点已列全, 写侧只有 1 处。 产品代码零改动:index.html 仍为 310,348 bytes,套件 36 → 38 条全绿。 * refactor(mmc-trajectory): 异常轴收成一个字段 state.anomaly {mode, rules, last}(Stage 2-a) Stage 2 三步里的第一步:先让状态是一个模型,再谈入口(D5 / LAYERS.md)。 原来 axis 上有三个顶层字段 —— anomalyRules、multiAnomaly、anomalyLast。 「有没有规则开着」这一个判断要从三个地方重新推导,于是「关掉其中一个而 没关另外两个」读起来不像 bug、像另一个状态,它们之间的协调只能活在调用 顺序里,因为没有共同的模型可以住。现在是一个字段,rules / mode / last 三 个读点都在同一个对象上。 mode 用 'single' | 'multi' 字符串而不是布尔:每个读都是一次显式比较, 而不是 `multiAnomaly || !multiAnomaly`。`!state.multiAnomaly` 那种写法读 起来像「不是多选」,而它在 单选 与 未知 之间是同义的。 清零走工厂而不是共享常量:defaultAnomaly() 每次给一个新对象。一个被两处 共享的常量就是同一个双写者缺陷换了个位置。 零产品行为变化(这是 2-a 的口径): - 账本读点 2 处、strip 读点 1 处、按钮读点 2 处 —— 全部原样改写 - 合计口径不动:anomalyCounts() 仍走自己的计数通道 - isFiltering() 仍只看 rules.length —— mode 不是 filter,勾上 多选 不该让 「清除筛选」变成可点 新增 tests/verify-anomaly-model.mjs(15 条)守这个模型本身,刻意不守 「已收敛」—— 那个断言现在过不了,写成「当前 N 个」又恒真。守的是可以被 证伪的四条:一个字段、两条重建路径、一个工厂、mode 不是布尔。 mutate-anomaly-model.py:7 条变异,覆盖新建点被绕过、工厂被共享对象顶替、 mode 被写成布尔、mode 泄漏进 isFiltering。 重锚(不是新增断言,是把旧断言的锚点从旧字段名换到新字段名): - verify-anomaly.mjs:fixture 声明合并成 anomaly 对象;19 处 multiAnomaly 的读写换成 mode 的字符串比较;10 处形状断言改锚 - mutate-anomaly.py:20 条变异的锚点重锚。两条变异(strip 分支前置于另一 个轴、单击关掉已开的规则)在重锚过程中 mutant 侧一度被写成和 old 侧等价 —— 那是空变异,跑出来是「NOT CAUGHT」而不是失败。已改回。 39/39 绿(原 37/39)。index.html 310,348 → 311,217 bytes,纯 LF。 * refactor(mmc-trajectory): 可见性收成一个入口 recordVisible(record, turn, selection)(Stage 2-b) R2 闭合。原来「这条记录在不在屏上」有两个函数回答:visibleEntries() 和 spanPassesFilter(),各自持有完整判断。这不是整洁问题——本页已经因为两份 拷贝漂移过一次:总览条画出账本拒绝渲染的跨度,读起来是「筛选了但什么都没 变」而不是 bug(D 类缺陷的结构来源)。 三处判断,逐条对着代码: 1. 笔刷是参数,不是 state 读。两个入口对笔刷的处理本来就不一样:账本用, 总览条不用。总览条必须不用——笔刷画在它上面,一个把自己正在被拖选的记录 藏起来的条没法用来拖选。写进签名是为了让下一个人不会「顺手修正」它。 断言守的是 recordVisible() 体内不出现 state.overviewSelection。 2. turn 是参数。flattenTurns() 产出 {turn, record},账本拿得到;总览条只拿 到裸 record 所以要从 turnById 查回。两者本来就是同一个对象。断言守的是 账本不自己查——它手上已经有那个对象,再查一次是同一个对象换个来源, 将来两边会长歪。 3. 互斥由 return 表达,不由 else 表达。「二选一不是叠加」这条断言原来只读 `} else if (!passesBaseFilter`,守的是 else 的存在。新的断言要求异常分支 return,且基础判断写在它之后——一个只判不回落的分支就是叠加,只是拼法 相反。 anomalyContext() 随之删除:它只是为了把 entry 适配成 (record, turn),而 recordVisible() 直接收这两个。passesBaseFilter() 删除:唯一调用方就是 visibleEntries()。 新增 3 条断言 + 3 条变异(70 → 73)。其中最强的一条是「总览条体内一个谓词 都没有」——断言两个调用方都不可持有判断,而不是都应用了异常。适配参数形状 不是第二意见,再回答一次才是。 变异重锚(不是删除): - 「总览条不应用 异常」→ 现在只有一个谓词持有者,改锚到 recordVisible(), 名字跟着改(仍破同一件事:轴不生效) - 「账本不应用 异常」→ 改锚到账本的调用点,整条替换为不过滤 - 「两个轴叠加而非二选一」→ 原来是去掉 else,现在去掉 return - 「模式读反」→ visibleEntries 里的 anomalyMode 没了,改锚到 recordVisible 的签名 + 第一行(`> 0` 在 recordVisible 与 applyFilterScope 各出现一次, 锚必须带签名才能唯一定位) verify-time-range.mjs 的三条断言同步改写。它不在 run.mjs 的默认集合里(需要 起服务 + 多代会话),已对着 55344 手跑:149 断言全过。 index.html 311,217 → 311,482 bytes,纯 LF。39/39 绿(默认与 --real), 73 变异全捕,validate OK。 过程事故:移除 anomalyContext() 时探针从签名向前回溯找 /**,跨过空行把刚插入 的 recordVisible() 一起删了,脚本仍报 ok 并写盘。由 fnSource 抽不出 recordVisible 暴露。已从 fork 复位,回溯改为「注释紧贴签名才吸收」,并让探针 打印删了多少行。空变异同理:mutation control 只报 probe still passed,不会 告诉你它什么也没测。 * refactor(mmc-trajectory): 删掉 D2 那三句互相矛盾的设计声明,并补上它自己的变异控制(Stage 2-c 注释半) D2:state 里曾有两句话各自宣称自己在描述「筛选是什么」—— 「The type chips are the only filter」与「This is the one filter the KPI follows」——两个模型不兼容,而没有一层裁决哪句作废。两句各自都对,都在描述 一个更早的页面。 三处处置: - KIND_ORDER 上方:删掉独占声明,保留「一个数组、一个写者」那半,并**列出另外 三个也在收窄表格的条件**(异常轴替换它、区间按墙钟裁、笔刷按总览裁)。 删掉独占声明却不说清剩下的是什么,等于把一句假话换成一句空话。 - kinds: 字段上方:整句删掉,保留后面关于折叠的段落——那段仍然成立。 - rangeFrom 字段上方:加限定词 →「Of everything on this layer, the KPI follows the range and nothing else」,并写明异常轴开着时 recordVisible() 一个都不查。 **这句话本身被守了**(verify-anomaly-model.mjs 15 → 17 条):index.html 里 "the only filter" 与 "the one filter" 各必须出现 0 次,而带限定词的那句仍在 README.md。守一段散文看起来奇怪,但这是本模型唯一一处改错了也不会有任何运行 时报错的地方——而一句与架构相矛盾的注释,不因为它是注释就不算假话。两条变异 分别把它插回 KIND_ORDER 上方与 kinds: 字段上方。 写入替换注释时踩了一个自指的坑:第一版把被删的原话原样引述在新注释里,于是 「零次出现」这条断言被自己的解释文本触发。改写为「claim exclusivity … with no qualifier」。 **`resetBaseFilters()` 变一次赋值那一半,本轮显式不做。** 要「一次赋值」就得把 基础轴收成 state.base + 工厂,与 2-a 对异常轴的做法对称。不做的理由是它越界: Stage 2 自己的「归层」答案是「不改 L1/L3/L4/L5/L6 任何一层」,而 overviewSelection 是 L3→L2 的回写(R5 有 5 个写点未闭合),收它就改了 L3 声明的形状;代价约 40 个站点横跨 L3/L4/L5,还要改写 verify-cross-layer.mjs 的断言。已作为未做清单第 9 行记录,并注明「要做的话四问要重答」。 **顺带修掉 2-a 的一个漏**:verify-anomaly-model.mjs 的文件头写着 「mutate-anomaly-model.py breaks each rule below」,而那个文件当时并不存在。 树里一句没有任何东西在检查的声明,正是这条守卫本身要抓的缺陷。现补上,16 条 变异全捕——其中 5 条初版写错,值得一提:三条只加了不带 `state.` 前缀的键,那 不是「第二个答案」只是一个死键;一条改了条件却没删赋值;一条把写者改成自赋值, 写者计数根本没变。变异必须真的改变被计数的东西,锚点命中不等于变异有效。 index.html 311,482 → 311,945 bytes,纯 LF。40/40 绿(默认与 --real), 73 + 16 变异全捕,validate OK。 * feat(mmc-trajectory): 增量协议 ?since=,会话增长时只传尾部(Stage 4) 本轮唯一一处真口径变更:applyPayload 从整体替换改为追加合并。 请求多一个可选游标 ?since=<整数 ≥ 0>;响应多一个顶层键 delta。**有 delta 就 合并,没有就整体替换**——这个标记是判定的唯一依据,客户端因此不需要猜服务端 这次发的是哪种。游标缺失 / 空 / 非整数 / 负数都不是错误,一律当没有游标返回 整包:什么都还没有的页面需要的就是整包,把首轮轮询变成 400 是比发 41 MB 更 差的答案。 三个判断: 1. stats 整包来,因为它是个累加器——里面每个计数都是遍历全部行累加出来的, 尾部无法被「加」到它上面,要那样就得由客户端重新推导服务端的算术。session 同理:几百字节,而用上一份回答它会让每个 payload 依赖一份服务端并不保留 的历史。 2. 轮整轮携带,只切 records。这重复了页面已有字段,是刻意的:对话进行中 closed 会翻转,而 无答复 由它判定——发残缺的轮,页面就会对唯一那个还在被 写入的轮持有过期的 closed。 3. 游标超过末尾返回 null(即整包)。那是页面跑到服务端前面、也就是从服务端看 「切了一代」的样子;从那儿切出的尾部是空的,而空尾部与「没有新东西」不 可区分。 服务端只在冷构建路径切尾部。缓存命中那条路径不处理 ?since= 且不是遗漏:带 游标的页面一定也带着它对应的校验器,所以带着游标走到这一行意味着会话变了。 而从缓存 payload 里切尾部还要把那个对象图留在内存里,这个缓存刻意不留。 客户端游标与校验器同一个条件、同样绑定 cache key(state.since / sinceFor), 手动刷新同时丢掉两者——它是在向服务端要真相,尾部不是真相。合并以 body.delta && state.payload 为前提,缺一不可。mergeTurnRecords 按 record.index 取并集而不是追加,所以同一个尾部重放两次什么也不会变:重试和重叠轮询都会发 生,只在顺利路径上正确的协议会在第一次重放时丢记录。 **省的是传输与客户端 JSON.parse,不是服务端 CPU。** 实测(冷缓存,同一会话): 整包 43,159,724 bytes 服务端墙钟 1,124 ms ?since=21584 19,663 bytes 服务端墙钟 1,124 ms (0.046%) 服务端墙钟两次相同:它仍全量读、解析、构造、序列化(缓存条目与 /raw 都需要整 包 body)。这不是推算,是量出来的。 浏览器端:窗口条「共 21,680 条」与同一时刻 API 的 21,679 条同步,差 1 条正是 两次读取之间我自己产生的记录——合并既没丢也没重。console 零 error / 零 warn。 网络面板:无变化轮 304 约 30 ms,会话增长轮 200 约 640–710 ms(服务端那部分没 变,正如预期)。 **证据边界**:网络面板不返回响应体大小、也不返回 URL query,所以「那些 200 是 尾部」不是从面板读出来的,是冷缓存直连测出来的;面板只能证明轮询节奏与状态 码。304 在面板里显示 outcome: failed + ERR_ABORTED(304 无正文,页面在读正文 前就 return),这条路径本轮一行未改,但**我没有做改动前的 A/B 对照**——按推理 它早于本改动存在,没有实测证据。 守卫 verify-incremental.mjs 63 条 + mutate-incremental.py 16 条变异全捕(同时改 server.mjs 与 index.html 两个文件)。核心是往返等式 mergeDelta(整包, delta) == 整包——不是「差不多」,是轮、closed、记录一次不多一次不少地回到原样,在 6 个 游标位置各跑一遍;其余断言都在回答这条等式为什么会不成立。其中一条是手算的 小合成用例,因为 fixture 证明不了:里面没有正在增长的轮。 两处过程事故记在 docs/LAYERS.md: - 守卫自己崩过一次——变异让 find() 返回 undefined,守卫直接读属性。…
New MiniApp: Model Manager (
openrouter-model-manager)A MiniApp for browsing, searching, and enabling/disabling the models in your MiniMax Code
config.yaml— no more find-and-replace in a text editor.What it does
enabled:flags is picked up automatically and merged into one list grouped by model family — there is no provider-switching UI. (The plugin IDopenrouter-model-manageris kept for stability, but the app is not OpenRouter-specific.)backups/under the MiniApp's own data directory (per the runtime's "store durable state under dataDir" guidance), pruned to the newest 20 — nothing in the data-root namespace is touched.enabled:line is rewritten. Each line's own terminator is preserved (mixed CRLF/LF/CR files stay intact), the original file mode is kept, concurrent mutations are serialized, and colon-tolerant keys (llama3.1:latest,:free) are supported.Checklist
plugins/ocoomber/openrouter-model-manager/, including the hidden.minimax-plugin/directory~/.minimax/plugins/openrouter-model-manager/File access, process spawning & network
config.yaml. Resolution walks ancestors of the injected data directory looking for aconfig.yaml(the Host injects a plugin-owned subdirectory several levels below the data root, and the MiniMax Code config lives at the data root or one of its ancestors in the current Host layout), then falls back to the default~/.minimax/config.yaml. The parent walk is an implementation detail of the current Host layout, not a guaranteed API — the default fallback is what protects non-default installs..config.yaml.mm-tmpcreated next to the config, then renamed; the temp file is removed if anything fails.https://openrouter.ai/<vendor>/<model>pages in the user's own browser via the OS opener (rundll32/cmd/exploreron Windows,openon macOS,xdg-openon Linux); anything outside the allowlist is rejected server-side.id/name/enabled/contextLimitto the UI. API key material is never read into the UI, returned by the API, or displayed.Test environment & results
tests/api-set.test.mjs,tests/parser.test.mjs, andtests/resolveConfigPath.test.mjs, 11 tests vianode --test— including a realPOST /api/setrequest that verifies a single-model toggle changesconfig.yaml, plus EOL round-trip, provider discovery, colon-in-id keys, commented lines, enabled-state extraction, multi-provider toggle invariants, and config-path resolution.