Skip to content

v0.5.3

Choose a tag to compare

@github-actions github-actions released this 03 Oct 04:18
· 220 commits to main since this release

MusePi 0.5.3

Platform 下载
macOS (Apple Silicon) MusePi-0.5.3-arm64.dmg
Windows MusePi-0.5.3-setup.exe · MusePi-0.5.3-arm64-setup.exe
Linux x64 MusePi-0.5.3-x86_64.AppImage · MusePi-0.5.3-amd64.deb
Linux arm64 MusePi-0.5.3-arm64.AppImage · MusePi-0.5.3-arm64.deb
Android MusePi-0.5.3-app-debug.apk (debug build, adb 安装)

[0.5.3] - 2026-10-03

Added

  • 侧栏底部新增快捷键按钮 + 快捷键总览弹窗(openchamber HelpDialog 吸收):侧栏底部状态行多一个 ⌘ 图标按钮,弹窗按分组双栏列出全部快捷键,标题下一行说明,末尾两条提示。刻意不做成手维护清单 —— openchamber 那个弹窗内嵌 167 行字面量(版式决策都在数组里),我们每一行都从 lib/shortcut-registry.ts 推导,因此弹窗不可能与按键处理器的实际行为不一致:重复维护的表会自信地列出一个用户早已改掉的键位。由此白得两个性质:bindingLabel 是平台感知的(⌘→Ctrl、↩→Enter、⎋→Esc)且读的是生效绑定;注册表在改键时派发 SHORTCUTS_CHANGED_EVENT,所以弹窗开着也能实时刷新(7 例契约测试钉住,含"注册表每一条都必须出现"和"改键后立即跟随")。
    • EN: The sidebar footer gained a shortcuts button and a keyboard-shortcuts reference (absorbing openchamber's HelpDialog): a ⌘ icon button in the footer status row opens a two-column, grouped list of every shortcut, with a lede under the title and two tips at the tail. It is deliberately NOT a hand-maintained list. openchamber's dialog inlines a 167-line literal (its layout decisions live in that array); every row here is derived from lib/shortcut-registry.ts, so the dialog cannot disagree with what the keydown handlers actually do — a duplicated table ends up confidently listing a binding the user rebound months ago. Two properties fall out for free: bindingLabel is platform-aware (⌘→Ctrl, ↩→Enter, ⎋→Esc) and reads the EFFECTIVE binding, and the registry fires SHORTCUTS_CHANGED_EVENT on a rebind so a dialog left open updates in place (7 contract cases, including "every registry entry appears" and "follows a rebind immediately").
  • 两处刻意不照抄 openchamber:① 不给每行加图标 —— 我们的注册表没有 icon 字段,为一个装饰性字段污染唯一真相源不值得;② 分组到栏位的分配仍是手工的 —— 贪心填充能保持两栏高度均衡,但会把阅读顺序打乱(读者会先看到"会话"再看到"布局"),而分组的意义就是阅读顺序;代价是新增分组时要在这里选栏位,已写在注释里。
    • EN: Two deliberate divergences from openchamber: ① no per-row icon — our registry has no icon field, and adding a decorative one to the single source of truth is not worth it; ② section-to-column assignment stays hand-written — greedy packing keeps the columns balanced but scrambles reading order (a reader would meet 会话 next to 布局), and reading order is the point of grouping; the cost is that a new group means choosing its column here, which the comment now says.
  • 顺带修掉 shortcut-registry 之外新暴露的一处:.gui-dialog 基类是 600×420 的设置箱,装不下五个分组的双栏(分组标题会被滚掉),所以新增 gui-dialog--shortcuts(880px 宽、内部滚动、窄窗口单栏),沿用 --settings 这个尺寸修饰词的先例;--ui-font-mono 是我一开始编的键名,改用仓库既有的 var(--font-mono, …) 写法。
    • EN: Also fixed what the work exposed: the .gui-dialog base is a 600×420 settings box, which cannot hold five grouped sections in two columns without scrolling the headings away, so gui-dialog--shortcuts was added (880px, internal scroll, single column on narrow windows) following the existing --settings size-modifier precedent; and --ui-font-mono was a name I invented, replaced with the repo's existing var(--font-mono, …) form.
  • 回合导轨新增"加载更多提示"按钮(吸收 openchamber PromptNavigatorRail):此前导轨在指针进入顶边时已经会翻页,但那个手势完全没有任何视觉提示 —— 屏幕上没有任何东西说明还有更早的回合;唯一的翻页信号是转录顶部那条 aria-hidden 的加载条,出现在滚动的另一端。现在刻度带上方多一个 20px 圆形按钮,仅在 daemon 报告还有更早历史时出现,在途时 disabled 并换成 spinner,按钮自己管 pending 状态而不是依赖那条 aria-hidden 的加载条。几何沿用上游(20px 圆、margin-bottom: 6px、inset-inline-end: 2px 与刻度列对齐,左右两侧通用);刻度保持 pointer-events: none,按钮可点,点击不冒泡到导轨的 y→刻度映射。
    • EN: The turn rail gained a "load more prompts" button (absorbing openchamber's PromptNavigatorRail). The rail already paged older history when the pointer entered its top edge, but that gesture had no visual affordance at all — nothing on screen said older turns existed, and the only paging signal was the transcript's aria-hidden loading bar at the far end of the scroll. The track now carries a 20px circular button above it, shown only when the daemon reports older history, disabled with a spinner while a page is in flight — the button owns its own pending state instead of leaning on that aria-hidden bar. The geometry follows upstream (20px circle, margin-bottom: 6px, inset-inline-end: 2px to sit on the tick column, valid for both rail sides); ticks stay pointer-events: none, the button is clickable, and its click does not bubble into the rail's y→tick mapping.
  • 历史翻页卡死不再静默:session.history 有三条失败路径会置 hasMore = false —— daemon 判定游标失效(压缩掉了锚点)、空页却仍声称有剩余、页面没有任何推进。三条原本都只有 console.warn;而 markHistoryStale 的注释写着 "and surface it",却从来没有 reader,所以用户唯一的信号就是加载按钮凭空消失。三条路径现在统一置 historyBlocked,转录顶部显示一行"无法加载更早的对话:会话历史已被压缩或替换。",reloadFromSnapshot 会重新武装翻页(终态属于撞上它的那一次视图,不属于会话)。
    • EN: A stalled history backfill is no longer silent. Three failures set hasMore = false — the daemon refuses the cursor as stale (compaction dropped the anchor), an empty page arrives while the daemon still reports rows remaining, and a page advances nothing. All three only logged to console.warn; markHistoryStale's own comment said "and surface it" while nothing ever read it, so the user's only signal was the load-more control silently going away. All three now set historyBlocked, the transcript shows an "older history is unavailable" row at the top, and reloadFromSnapshot re-arms paging — the terminal state belongs to the view that hit it, not to the session.
  • 顺带拆掉一个命名陷阱:TurnRail 里 hasMoreAbove 原本有两个不同含义 —— 局部变量指"刻度窗口上方还有刻度"(轮播翻页),而 turnsData.hasMoreAbove 指"daemon 还有更早历史"。新按钮正好坐在这两者旁边,因此把局部量改名为 moreTicksAbove / moreTicksBelow,并加测试钉住"刻度窗口变长不会凭空生出加载按钮"。
    • EN: Removed a naming trap along the way: hasMoreAbove meant two different things inside TurnRail — the local flag is "more ticks above the window" (carousel paging), while turnsData.hasMoreAbove is "the daemon still has older history". The new button sits right between the two, so the locals are now moreTicksAbove / moreTicksBelow, with a test pinning that a long tick window never conjures the pager.
  • 空闲会话提示缓存预热(吸收上游 cache-warmer):在缓存条目即将过期前用「1 token 输出预算」重发上一次请求,避免长时间空闲后整段前缀缓存被重写。成本感知调度——在声明的条目寿命 90% 处触发(至少保留 10 秒余量),且仅当「预计省下的缓存未命中费用 − 刷新成本 ≥ $0.05」时才真正发送;streaming / idle 两段策略分别用 100% / 15% 的续写概率,并受 60 分钟 / 30 分钟固定安全窗口约束。只对 catalog 里显式标注了 promptCache 寿命的模型生效(目前仅直连 Anthropic:5m / 1h,与官方文档一致),Bedrock / 网关 / openai-compat 代理一律不标注——它们的重放行为与过期时间未经验证,宁可不预热也不编造。自定义模型可在 models.yml 里用 promptCache 自行开启。新增 providers.cacheWarming 开关(off / streaming / idle,默认 idle),并新增 cache_warming_decision 扩展事件可覆盖单次决策。扩展可读取 session.cacheWarmingStatus 查看当前预热状态。原先 Anthropic 专属的固定 keep-alive 刷新回路(ai 层 stream 调度机制)已随之移除。
    • EN: Idle prompt-cache warming (absorbing upstream's cache-warmer): the last request is re-sent with a one-token output budget shortly before its cache entry expires, so a long idle gap no longer forces a full-prefix cache re-write. Cost-aware scheduling — the refresh fires at 90% of the declared entry lifetime (keeping at least a ten-second margin), and only when the expected avoided cache-miss cost minus the refresh cost clears $0.05; the streaming/idle phases use 100%/15% continuation probability and are bounded by fixed 60-minute / 30-minute safety windows. It only arms for models whose catalog entry explicitly declares a promptCache lifetime (currently direct Anthropic only: 5m / 1h, matching the vendor docs); Bedrock, gateways, and openai-compat proxies stay unannotated because their replay behavior and expiry are unvalidated — better not to warm than to guess. Custom models can opt in via promptCache in models.yml. New providers.cacheWarming setting (off / streaming / idle, default idle) plus a cache_warming_decision extension event to override an individual decision; extensions can read session.cacheWarmingStatus for the current state. The previous Anthropic-only fixed keep-alive refresh loop (and its stream-level scheduling machinery in the ai package) is removed.

Changed

  • 历史翻页按轮次对齐:最老的一轮不再是个碎片(openchamber loadOlderPage 吸收):此前一次翻页是 maxMessages: 500 平铺前置,批次边界是任意的 —— 于是最顶上那一轮往往是残缺的,导轨最顶刻度代表一轮而它的前半截还没加载,点进去会落到某一轮的中间,没有任何报错只是屏幕内容不对。客户端现在与 openchamber 一致:每页 100 条,批次最老那条必须是轮起点,不满足则最多再补 2 页,整批原子提交(补读失败则保留原有窗口和游标)。daemon 游标始终权威 —— 对齐只决定"多读几页",绝不臆造或改写锚点;游标重复出现即停止而不是重放同一页;空页却仍声称有剩余也停止。requestJump 的翻页循环自动继承该行为,⌘F 命中因此也落在完整轮次上。

    • EN: History backfill is now turn-aligned, so the oldest turn is no longer a fragment (absorbing openchamber's loadOlderPage). A backfill used to be a flat maxMessages: 500 prepend, which makes the batch boundary arbitrary — so the oldest loaded turn was often incomplete, the rail's top tick stood for a turn whose earlier half was not loaded, and clicking it scrolled into the middle of a turn with no error, just the wrong content on screen. The client now matches openchamber: 100 messages per page, the batch's oldest entry must be a turn start, up to 2 extra reads when it is not, and the whole batch commits atomically (a failed follow-up read leaves the previous window and cursor intact). The daemon cursor stays authoritative throughout — alignment only decides how many extra pages to read and never invents or rewrites an anchor; a repeated cursor stops the walk instead of replaying a page, and so does an empty page that still claims history remains. requestJump's paging loop inherits this, so a ⌘F hit also lands on a whole turn.
  • 翻页每批从 500 降到 100–300 条:这是采纳 openchamber 页大小的直接后果,为了换取批次边界正确。单次点击/自动加载拿到的消息数变少(原来一次 500,现在 100 起、最多 300),滚到很深的历史需要更多次加载;换来的是导轨最顶刻度永远指向一个完整的轮次。若更看重单次载荷,可把 HISTORY_PAGE_MESSAGES 调大 —— 对齐逻辑与页大小相互独立。

    • EN: Each backfill batch drops from 500 to 100–300 messages. This is the direct consequence of adopting openchamber's page size, traded for a correct batch boundary. One click (or auto-load) now yields fewer messages than before, so reaching deep history takes more loads; in exchange the rail's top tick always points at a whole turn. If single-load payload matters more, raise HISTORY_PAGE_MESSAGES — the alignment walk and the page size are independent.
  • 一处刻意不照抄 openchamber:他们的对齐条件是"是否为 user 消息",我们用 isTurnStartEntry(user 消息或 display: true 的顾问笔记)。这是本仓库其它各处(导轨、轨迹地图、daemon 轮次索引)统一的轮边界,而 agent 主动发起的轮本来就没有 user 消息 —— 照抄他们的判定,会让"以顾问笔记开头"的那批会话仍然错位,正是我们这几轮一直在拆的漂移类。

    • EN: One deliberate divergence from openchamber: their alignment test is "is a user message", ours is isTurnStartEntry (a user message OR a displayed advisor note). That is this repo's turn boundary everywhere else — rail, trajectory map, the daemon's turn index — and an agent-initiated round has no user prompt by construction, so copying their test would still misalign exactly the sessions whose first turn is an advisor note.
  • 会话地图的事件卡从"一行字"升级为信息 + 操作卡:展开轮后的事件卡重构为主行 + 副行结构——主行 = kind 药丸(用户/助手/工具/顾问/系统,与图标同色系)+ 摘要(工具行显示工具名)+ 元信息(usage/耗时/ttft/时刻);工具行副行显示参数摘要(mono、单行截断)。交互上单击卡片直接跳回对话定位该事件(取代原先只高亮无用的选中态),悬停浮现「切换到此分支 / 重答」操作按钮(user 行双操作、其余行仅切换,锚分派与右键菜单同一套 branchAt 语义);事件卡高度按形态区分(单行 28px / 工具双行 44px),布局增量与渲染同源。轨迹检视器时间线模式的轮头同步补上同款悬停「重答 / 切换」双操作(重答锚轮首 user 事件、切换锚轮末事件)。

    • EN: Session map event cards upgraded from "one line of text" to info + action cards: each card is now a main row + detail row — the main row carries a kind pill (User/Assistant/Tool/Advisor/System, same palette as the icons) + summary (tool rows show the tool name) + metadata (usage/duration/TTFT/timestamp); tool rows get a detail row with the argument summary (mono, single-line ellipsis). Interactions: clicking a card jumps straight back to the conversation and locates that event (replacing the useless highlight-only selection state), and hovering reveals "switch to this branch / re-answer" action buttons (user rows get both, other rows only switch, with the same branchAt anchor dispatch as the context menu). Card heights now follow the shape (28px single line / 44px tool detail) and the layout increment is derived from the same source as the rendering. The trajectory viewer's timeline mode turn headers gained the same hover "re-answer / switch" dual action (re-answer anchors the turn's first user event, switch anchors the turn-end event).
  • ⌘F 在当前会话内查找消息:查找栏浮在 transcript 顶边,带命中计数与上下跳转(Enter / Shift+Enter),点预览行直接定位。查找范围限定当前会话——会话是全文可搜的,不必先滚到那一段。跳转复用既有的行定位通道,会自动翻历史分页、展开压缩折叠并高亮命中行。

    • EN: ⌘F finds messages within the open conversation. The find bar floats over the transcript's top edge with a match counter and next/previous navigation (Enter / Shift+Enter); clicking the preview row jumps straight there. The search is scoped to the current session, so it sees the whole conversation rather than only the part you have scrolled to. Jumping reuses the existing row-reveal path, which pages history in, opens the compaction fold and flashes the hit.
  • 归档会话后可撤销:侧栏行菜单与顶栏归档按钮都会弹出一条底部居中的撤销 toast。归档本身只是本地列表改动,所以撤销是同步且零成本的 —— 不需要确认对话框,也不会出现"归档完找不回来"。

    • EN: Archiving a session can now be undone. Both the sidebar row menu and the header archive action raise a bottom-center undo toast. The archive is a local list edit, so the undo is synchronous and free — no confirmation dialog, and no "archived and now it's gone".
  • 命令面板增量:新增「重新加载界面」命令;粘贴完整会话 ID 可精确命中该会话(模糊搜索对长 hex ID 无效,此前只能得到「无结果」);面板会记住上一次的搜索词,⌘K 切换不再清空敲了一半的内容。

    • EN: Command palette additions: a Reload UI command; pasting a full session id now resolves to exactly that session (fuzzy search cannot match a long hex id, which previously always returned "no results"); and the palette remembers its last query, so toggling ⌘K no longer wipes a half-typed search.
  • 会话地图重做分叉形态与轮内颗粒度(按实机手绘示意图):① 分叉改在父轮结束之后的主干线上形成独立交汇圆点(git 树式)——此前分支贝塞尔从父轮卡片右缘中部拉出,视觉上兄弟轮被画成父轮的子节点;现在交汇点落在父轮下缘间距中点的干线上(横向模式为转置几何),同一父轮的兄弟分支共享一个圆点,贝塞尔先从圆点沿主干走一段再进入分支卡。② 每轮展开后框内事件从 24px 裸行升级为独立事件卡(微玻璃面 + kind 色点 + 描边,28px + 4px 间距),轮次徽章改为框上的 accent 药丸角标(分支/叶子/顾问轮各有配色)。布局常量与 CSS 同步(turnExpandedExtra 按事件卡 32px 步进 + 泳道留白 8 封顶 TURN_LANE_MAX_H + 8)。3 例新契约测试(交汇点落在父轮之后干线间距中点 / 兄弟分支共享一个交汇点 / 横向模式转置几何)。

    • EN: The session map's fork shape and per-turn granularity were reworked (per the on-device hand-drawn sketch): ① forks now form an independent junction dot on the trunk right after the parent turn ends (git-tree style) — previously the branch bezier started from the middle of the parent card's right edge, visually drawing sibling turns as children of the parent; the junction now sits on the trunk at the mid-gap below the parent card (transposed geometry in horizontal mode), siblings branching from the same parent share one dot, and the bezier runs along the trunk from the dot before entering the branch card. ② events inside an expanded turn upgrade from 24px bare rows to standalone event cards (micro glass surface + kind color dot + border, 28px + 4px gap), and the turn badge becomes an accent pill corner mark on the frame (branch/leaf/advisor turns each get their own tint). Layout constants stay in sync with CSS (turnExpandedExtra steps 32px per event card + 8px lane padding, capped at TURN_LANE_MAX_H + 8). 3 new contract tests (junction sits at the trunk mid-gap after the parent / siblings share one junction / transposed geometry in horizontal mode).

Fixed

  • 装好的插件终于会热重载:扩展 HMR 的监视根是一份手写清单,只有 <agentDir>/extensions 和 <cwd>/.musepi/extensions 两项 —— 而插件真正被加载的根是另外两个:getPluginsDir()(市场安装 / plugin link 的插件住在这里)和锚点之上的项目插件根。于是用户唯一能安装的那种插件,恰好是唯一完全没有热重载的那种:改插件入口文件不触发任何刷新,只能等会话重开;手写扩展一切正常,所以本地开发永远撞不到这个缺口。项目根那一项还踩了第二个坑 —— 旧代码硬编码 cwd/.musepi/extensions,而 loader / 安装 / 卸载 / 列表 / discovery 共用的判定是 resolveActiveProjectRegistryPath 向上找 .omp/ 或 .git/,两边迟早漂。根清单抽到 daemon/extension-watch-roots.ts,复用那一个解析器而不是再写一遍向上查找(3 例契约测试;回退验证确认删掉插件根即失败,而两个既有扩展根仍然通过)。

    • EN: Installed plugins now hot-reload. The extension HMR watch roots were a hand-written list of two entries — <agentDir>/extensions and <cwd>/.musepi/extensions — while the roots plugins are actually loaded from are two others: getPluginsDir() (where marketplace installs and plugin link land) and the project plugin root above the project anchor. So the only kind of plugin a user can install was the only kind with no hot reload at all: editing a plugin entry file triggered nothing and the change appeared only after reopening the session, while hand-written extensions reloaded fine — which is why local development never hits this gap. The project root carried a second mismatch: the old code hardcoded cwd/.musepi/extensions, but the loader, install, uninstall, list, and discovery all share resolveActiveProjectRegistryPath, which walks up for .omp/ or .git/; the two lists were bound to drift. The root list now lives in daemon/extension-watch-roots.ts and reuses that one resolver instead of writing a second walk-up (3 contract cases; reverting the fix to the old two-root list fails the plugin-root case while both pre-existing extension roots still pass).
  • 第一个真实的第一方插件包 + 内置目录(musepi-git):扩展中心此前在结构上就是空的 —— 商店面板遍历 marketplaces.json,而那个文件只有用户会写,仓库里没有任何东西播种它,于是第三方插件无从发现。现在有了:内置目录 packages/marketplace.json(用真实 catalog 解析器校验)、插件包 packages/musepi-git(注册 /git,报告分支 / HEAD 形态 / 工作区计数)、以及 README 双语对。全部 git 访问走宿主中央 helper(repo.root / head.resolve / status.summary),刻意不报 ahead/behind —— 中央 helper 没有对应读取器,用 rev-list 按两个方向各拼一次就是"第二套实现",补它该改 utils/git.ts 而不是改插件。插件刻意多文件(入口只做装配,命令 / 读取 / 格式化各自独立),好让上一条修的子模块热重载有一个真实消费方,而不只是 fixture。7 例契约测试钉住目录与包不会各自漂移(改名 / 挪文件时静默失效,运行时只有"插件装不上"或"扩展根本不加载")。

    • EN: The first real first-party plugin package, plus a bundled catalog (musepi-git). The extension center was structurally empty: the store panel iterates marketplaces.json, which only the user ever writes — nothing in the repo seeds it, so no third-party plugin was discoverable. There is now a bundled catalog at packages/marketplace.json (validated with the real catalog parser), the plugin package packages/musepi-git (registers /git, reporting branch, HEAD shape and working-tree counts), and the README pair the repo requires. All git access goes through the central host helper (repo.root / head.resolve / status.summary), and ahead/behind is deliberately absent: the helper has no reader for it, and hand-assembling rev-list per direction is the second-implementation problem — that belongs in utils/git.ts, not in a plugin. The package is deliberately multi-file (the entry only wires modules together; command, state reading and formatting are separate) so the submodule hot reload fixed above has a real consumer instead of only fixtures. Seven contract cases pin the catalog and the package against silent drift (a rename or a moved file fails quietly; at runtime the only symptom is "the plugin will not install" or "the extension never loads").
  • 扩展改子模块不用再 touch 入口:多文件扩展改一个非入口文件,以前什么都不会发生 —— 文档里写着"改子模块需 touch 入口",看起来像 Bun 模块图的固有语义,其实是我们自己的两个缺陷叠在一起。其一,装载时会对入口的整张源码图做走查(含静态 import、CJS require、动态 import()),但 daemon 的变更判定只比入口文件的 mtime,所以图内其它文件的改动根本不会被判定为需要重载;其二,图内 specifier 走 toGraphImportSpecifier,而它在 win32 上返回 file:// —— Bun 忽略 file:// 上的 query,于是整图静默命中进程缓存。入口之所以能重载,是因为 loadLegacyPiModule 早在 win32 上就拒绝 file://、改发裸路径;图内模块从没跟上这个待遇,这个不对称就是"入口能刷、子模块不能"的来源。现在:装载时快照逐文件 mtime,判定按整图比对(extensionEntriesNeedingReload,图内任一文件变了就重载其入口,删除也算),图内 specifier 一律发裸路径 + ?mtime=。仓库里原本就有 4 个红着的契约测试钉这件事(扩展本地 bare 依赖的子模块、已安装插件的子模块、被相对 CJS 子模块 require 的提升依赖、惰性 import 的 CJS 重导出)——它们在 HEAD 上就是失败的,现在 4 个全绿;另加 3 例新测试覆盖"只改子模块即被判定"、"重载后不再重复判定"(防止重载成环)、"子模块被删"。

    • EN: Editing an extension submodule no longer requires touching the entry. For a multi-file extension, changing anything other than the entry used to do nothing at all — the docs said "touch the entry when you edit a submodule", which read as an inherent property of the Bun module graph but was two of our own defects stacked. First, loading already walks the entire source graph of the entry (static import, CJS require, and dynamic import()), but the change detection in the daemon compared only the mtime of the entry file, so edits anywhere else in the graph never even registered as needing a reload. Second, graph specifiers go through toGraphImportSpecifier, which returned file:// on win32 — and Bun ignores query strings on file:// URLs, so the whole graph silently hit the process cache. The entry reloaded on Windows only because loadLegacyPiModule already refuses file:// there and emits a bare path; graph modules never got that treatment, and that asymmetry is exactly why the entry refreshed and its children did not. Loading now snapshots per-file mtimes and detection compares the whole graph (extensionEntriesNeedingReload: any file in the graph reloads its entry, and a deletion counts); graph specifiers now always carry a bare path plus ?mtime=. The repo already had four failing contract tests pinning this — children of an extension-local bare dependency, children of the extension-local bare dependency of an installed plugin, a hoisted dependency required by a relative CJS child, and a lazily imported CJS re-export. They fail at HEAD and all four are green now — plus three new cases covering "a submodule-only edit is detected", "no repeat detection after a reload" (so a reload loop cannot form), and "a deleted submodule is detected".
  • Markdown 导出不再丢掉顾问卡片:会话导出原先只挑 type === "message",于是 custom_message(顾问笔记、hook 通知、collab / skill 提示)整族从导出的文件里静默消失 —— 没有占位、没有报错,用户转发出去的 transcript 就不再解释发生过什么。第二个更隐蔽的问题:顾问的 content 是给模型看的 <advisory severity="…" guidance="…"> XML,从错误字段取值会把 XML 而不是笔记写进文件。选择规则连同"绝不导出 XML"这条约束一起抽到 lib/session-markdown.ts(10 例契约测试,含隐藏笔记不导出、多条笔记以 ; 合并不丢、display: false 不外泄),文件名生成也在同一处。XML 解包与 daemon 的轮次摘要共用 @musepi/pi-wire 新增的 advisorNoteText,那份拷贝随之删除。

    • EN: The Markdown export no longer loses advisor cards. It used to select only type === "message", so the whole custom_message family (advisor notes, hook notices, collab/skill prompts) vanished from the exported file — no placeholder, no error — and a transcript the user re-shares no longer explained what happened. The second failure is quieter: an advisor entry's content is the model-facing <advisory severity="…" guidance="…"> XML, so reading the wrong field writes XML into the file instead of the note. The selection rule, together with the never-export-the-XML constraint, moved to lib/session-markdown.ts (10 contract cases, covering hidden notes staying out, multiple notes joining with ; without loss, and display: false never escaping); the filename derivation lives there too. The XML unwrap is now shared with the daemon's turn summary via the new advisorNoteText in @musepi/pi-wire, and that copy is gone.
  • 子代理 RPC 的 messages 字段不再吞掉顾问卡片:rpc-subagents.ts 用 entry.type === "message" 过滤 messages,而同一响应的 entries 字段是完整的(类型上能看出来),于是任何从更顺手的 messages 建 transcript 的嵌入方都会静默丢掉顾问卡片。改用共享的 extractRenderableEntries,messages 里现在带 role: "custom",与实时内存 transcript 一致。这条路径可达:advisor.enabled 的子代理(agent frontmatter 驱动)确实会把顾问卡片写进自己的 JSONL。

    • EN: The subagent RPC's messages field no longer swallows advisor cards. rpc-subagents.ts filtered messages with entry.type === "message" while the sibling entries field in the same response is complete (the types make that visible), so any embedder building a transcript view from the more ergonomic messages lost advisor cards silently. It now uses the shared extractRenderableEntries, so messages carries role: "custom" — matching what the live in-memory transcript already holds. The path is reachable: a subagent with advisor.enabled (driven by agent frontmatter) does write advisor cards into its OWN jsonl.
  • HTML 分享导出与 /tree 共用同一份"默认隐藏条目"清单:export/html/template.js 带着一份自己的字面量副本,已经少了三个类型(service_tier_change、title_change、reset_boundary),于是这三类记账行会漏进导出的 default / no-tools 视图。模板是被 index.ts 以原始文本内联进 HTML 的浏览器 JS,无法 import TS 模块,所以由 index.ts 把 HIDDEN_BY_DEFAULT_ENTRY_TYPES 序列化进一个全局、模板读取它;字面量副本随之删除。全局缺失时降级为"什么都不隐藏" —— 漏出几行记账行只是观感问题,藏掉对话内容才是。全程断言在拼装后的模板上比对注入值,而不是比对某份源文件文本。

    • EN: The HTML share export and /tree now share one hidden-by-default entry list. export/html/template.js carried its own literal copy, which had already lost three types (service_tier_change, title_change, reset_boundary), leaking those bookkeeping rows into the export's default and no-tools views. The template is inlined browser JS and cannot import the TS module, so index.ts serialises HIDDEN_BY_DEFAULT_ENTRY_TYPES into a global that the template reads, and the literal copy is deleted. A missing global degrades to "nothing is hidden" — leaking a few bookkeeping rows is cosmetic, hiding conversation content is not. The assertion compares the injected value on the ASSEMBLED template rather than grepping a source file.
  • 顺带修正一处我自己写错的注释:transcript-records.ts 的模块头写的是 HTML 副本"少了四个类型" —— 那是上游那份列表的差集,不是我们的。实际是三个,并已具名。

    • EN: Corrected a comment I had wrong: transcript-records.ts's module header claimed the HTML copy was "four types" behind. That was the delta for upstream's list, not ours — it is three, and they are now named.
  • 代理中心全屏 transcript 不再吞掉顾问卡片:查看器用 entry.type === "message" 挑可渲染条目,把 custom_message 整族静默丢弃 —— 没有占位、没有报错。而 advisor: true 的子代理会话自己的 jsonl 里就带顾问卡片,于是查看器恰好丢掉了标识"这是在看什么"的那几行;实时转录(chat-transcript-builder 走 role: "custom" → #appendCustomMessage)渲染同样的条目,两个表面因此对同一份文件给出不同内容。同时它是不对称的:同一函数显式处理了 model_change,只漏了 custom_message,说明作者在逐类型枚举时漏了这一族。现新增 session/transcript-records.ts 作为唯一的记录族规则:custom_message 转成渲染器吃的 role: "custom" 消息(payload 与 #persistMessageEnd 落盘时写入的五个字段一一对应),"默认隐藏"的条目类型清单也从 tree-selector 的函数内常量提为共享常量(此前 export/html/template.js 另有一份副本,已少 4 个类型)。可见性仍由渲染器决定(#appendCustomMessage 开头就 if (!message.display) return),选择层不重复这条规则。

    • EN: The agent hub's fullscreen transcript no longer swallows advisor cards. The viewer selected renderable entries with entry.type === "message", which silently dropped the whole custom_message family — no placeholder, no error. A subagent session run with advisor: true keeps its advisor cards in its OWN jsonl, so the viewer dropped exactly the rows identifying what it was showing; the live transcript renders the same entries (chat-transcript-builder routes role: "custom" to #appendCustomMessage), so two surfaces disagreed about one file. The gate was also asymmetric: the same function handled model_change explicitly and simply missed custom_message, i.e. the author was enumerating types and missed this family. session/transcript-records.ts is now the single record-family rule: custom_message is lifted into the role: "custom" message the renderer consumes (payload matching the five fields #persistMessageEnd writes), and the hidden-by-default entry types moved out of tree-selector's function-local const into a shared one — the HTML export kept a second copy that was already missing four types. Visibility stays the renderer's call (#appendCustomMessage opens with if (!message.display) return), so the selection layer does not duplicate that rule.
  • 落在废弃分支上的顾问笔记不再被画进主干线:地图用 turn.events[0].branch 判定整轮是否分支,而只有 message 事件带这个标记(branch: isBranch)—— 顾问事件的 push 里从没有这个字段,于是 branchAt 之后旧链上的顾问笔记会被当作主线轮渲染,与它实际所在的分支不符。现把分支判定提成 isBranchEntry 闭包,message 与顾问事件共用同一口径(活跃路径优先,其次 first-child 启发式),顾问事件补 branch。反向也钉住了:顾问在活跃路径上时不得被标记,否则每条顾问笔记都会被塞进独立车道。

    • EN: Advisor notes on a discarded branch are no longer drawn on the main line. The map decides whether a whole turn is a branch from turn.events[0].branch, but only message events ever carried that flag — the advisor event's push had no such field, so after branchAt an advisor note on the old chain rendered as a main-line turn, contradicting the branch it actually sits in. The branch test is now a single isBranchEntry closure shared by message and advisor events (active path first, then the first-child heuristic), and advisor events carry branch. The negative direction is pinned too: a note that IS on the active path must not be flagged, or every advisor note would get its own lane.
  • 顾问轮摘要不再向界面泄漏 <advisory> XML;daemon 轮次判定改用权威实现:session.turns 的 summary 被导航条悬停面板(TurnRail)原样渲染,而顾问条目的 content 是给模型看的 <advisory severity=… guidance=…> XML,人读文本在 details.notes[].note —— 旧的文本提取对顾问条目回退到 content,于是悬停预览里直接显示 XML 标签。地图那条链路早就有同样的解包和注释("永不外露"),daemon 这条一直漏修;旧测试的夹具把 content 写成纯文本,所以从没暴露。现按 details.notes[].note 解包(多条以 "; " 连接、空条丢弃),没有 notes 时留空而不是回退到 XML,并把测试夹具换成真实条目形状。另:daemonIsTurnStart 原是 isTurnStartStart 判定的手抄副本,仅靠一条注释约束(注释本身还写错了源头),而轮次边界同时决定导航条、地图和条目 id 跳转,漂移等于全局少算多算轮次 —— 现改为直接委托 @musepi/pi-wire 的 isTurnStartEntry,并把该判定本身改成全函数(journal 行是未校验的 JSON,缺 message 的 type:"message" 行必须跳过而不是抛异常,否则一行坏数据会带倒 daemon)。

    • EN: Advisor turn summaries no longer leak <advisory> XML into the UI, and the daemon's turn-start test now uses the canonical implementation. session.turns ships a summary that the turn rail's hover panel renders verbatim, but an advisor entry's content is the MODEL-FACING <advisory severity=… guidance=…> XML — the reader-facing text lives in details.notes[].note. The old extractor fell back to content for advisor entries, so the hover preview showed raw XML tags. The map path already unwrapped notes and documented the leak ("never surfaced"); this path was missed, and the old test fixture wrote content as plain text, so nothing caught it. Notes are now unwrapped from details.notes[].note (joined with "; ", blanks dropped) and a note-less entry yields an empty summary instead of falling back to the XML; the fixtures now use the real entry shape. Separately, daemonIsTurnStart was a hand-copied twin of the turn-start rule whose only guard was a comment (and that comment named the wrong source), yet turn boundaries decide the rail, the map and the entry-id jumps at once — so a drift is an app-wide miscount. It now delegates to isTurnStartEntry from @musepi/pi-wire, and that predicate was made total: journal lines are unvalidated JSON, so a type: "message" row with no message must be skipped rather than thrown, since one bad line would otherwise take the daemon down.
  • ⌘F 命中跳不过去 + 查找栏停靠在会话视图内:① 跳不过去的根因是类型不匹配 —— daemon 消息表的 timestamp 存的是数字(epoch 毫秒),而转录条目的 timestamp 是 ISO 字符串,两者恒不相等,解析返回 null 后翻页循环跑到 hasMore 为假便静默丢弃;计数仍显示 1/1,所以看起来正是"搜到了但不跳"。现按 epoch 毫秒归一化比对,并留 ±1 秒窗口 —— daemon 存的是消息的时间戳、转录存的是条目的时间戳,两者出自不同代码路径,实测一致但不是同一次读数;窗口内多个候选再用命中文本消歧。② 查找栏原先挂在三层外某个 focusMode ? … : … 三元表达式里的 Tailwind relative 上,偏移父级实际解析到了窗口根,于是整个界面而非会话视图圆角卡片;且 TurnRail 是贴着用户所选边缘的 24px 绝对定位覆盖层,固定 14px 内缩正好压在它上面。现改为挂在 .gui-chat-body(会话列自身的盒子,即 openchamber 的 chatRootRef 模型),偏移父级写进我们自己的 CSS 而非依赖工具类,并用 ResizeObserver 实测导轨占位后下发内缩量——不再硬编码边距。

    • EN: Fixed ⌘F hits that would not jump, and docked the find bar inside the conversation view. ① The jump never happened because of a type mismatch: the daemon's message table stores timestamp as a number (epoch ms) while transcript entries carry an ISO string, so the two never compared equal — the resolver returned null, the paging loop ran until hasMore was false, and the jump was dropped without a trace. The counter still read 1/1, which read as "found but won't jump". Comparison is now normalized to epoch ms with a ±1s window: the daemon stamps the message and the transcript stamps the entry, two different code paths that agree in practice but are not the same reading; when the window admits more than one candidate the matched text decides. ② The bar had been hanging off a Tailwind relative buried in a focusMode ? … : … ternary three levels up, so its offset parent resolved to the window root and it docked to the whole UI instead of the conversation card; separately the TurnRail is a 24px absolutely-positioned overlay hugging the edge the user picked, and a fixed 14px inset landed right on it. The bar now hangs off .gui-chat-body (the chat column's own box — openchamber's chatRootRef model), the offset parent is declared in our own stylesheet instead of a utility class, and a ResizeObserver measures the rail's footprint to publish the inset — no hardcoded margins.
  • 修复会话跳转定位到错误轮次:两轮会话若起始时间戳落在同一毫秒(顾问笔记与用户提问同刻、秒级精度的历史会话、乐观回显条目),导航条 / 消息树 / 轨迹 / ⌘F 查找都会滚到更早的那一轮 —— 这些路径都用时间戳作键,而查找一律取首个匹配。现改为用条目 id 作键;⌘F 这条链路的命中来自 daemon 消息表(不存条目 id),按命中文本消歧。

    • EN: Fixed jumps landing on the wrong turn. When two turns started within the same millisecond (an advisor note and the user's prompt in the same tick, second-precision history, optimistic echo rows), the turn rail, message tree, trajectory and ⌘F find all scrolled to the earlier turn — every one of those paths keyed on the timestamp, and every lookup took the first match. Jumps are now keyed on the entry id; the ⌘F path, whose hits come from the daemon message table (which stores no entry id), disambiguates by the matched text.
  • ⌘F 统一为会话内查找,侧栏会话搜索移至 ⌘⇧F:⌘F 查找栏上线后与侧栏搜索加速器冲突(两个 window 级监听各开各的),按出来的是侧栏搜索而不是会话内查找。现按 openchamber 语义分派:⌘F = 当前会话内查找(find-in-chat 注册表绑定),⌘⇧F = 侧栏会话搜索(tooltip 与快捷键同步更新),两者按 shift 修饰键区分、互不冒泡。

    • EN: ⌘F is now uniformly the in-chat find bar, and sidebar session search moved to ⌘⇧F. The find bar's launch conflicted with the sidebar search accelerator (two window-level listeners firing independently), so the sidebar search opened instead of the in-chat find. Dispatch now follows openchamber semantics: ⌘F = find within the current conversation (find-in-chat registry binding), ⌘⇧F = sidebar session search (tooltip and accelerator updated together); the two are distinguished by the shift modifier and no longer collide.
  • 删除会话级联清理子代理/顾问会话,直接删子会话不再复活:子代理 transcript 在深度 3(<slug>/<父文件基名>/<子id>.jsonl),既有的深度 2 清理 glob 永远扫不到——删父会话残留其顾问/子会话行(日志与物化行幸存),直接删顾问会话删不干净、下次 session.list 重扫又复活。修法:① deleteSession 先经列表扫描收集目标——自身的精确 transcript 路径(任意深度)+ 按父 id 匹配的全部子会话,任一目标在流式中即整体拒绝(不做半吊子级联);② 每个目标按精确路径删 transcript + 日志 + 暂停 sidecar + 物化行,父目录产物照旧递归清理;③ 父 id 推导收编为单一 subagentParentIdOf(列表嵌套与级联收集共用,防两处漂移)。5 例新契约测试(时间戳基名取 id / id 含下划线 / 深度 2 返回 null / 纯 slug 目录返回 null / 自身路径不自分)。

    • EN: Deleting a session now cascades to its subagent/advisor sessions, and deleting a child directly no longer resurrects it. Subagent transcripts live at depth 3 (<slug>/<parent-file-base>/<subId>.jsonl), invisible to the existing depth-2 cleanup glob — deleting a parent left its advisor/child rows behind (journals and materialized rows survived), and deleting an advisor directly never removed its transcript, so the next session.list re-scan brought it back. Fixes: ① deleteSession first collects targets via the listing scan — the exact transcript path of the session itself (any depth) plus every child matched by parent id; if ANY target is streaming the whole cascade is refused (no half cascades); ② each target loses its exact-path transcript + journal + pause sidecar + materialized row, with the parent's artifacts dir still recursive-cleaned as before; ③ parent-id derivation is consolidated into a single subagentParentIdOf shared by the list nesting and the cascade collection, so the two cannot drift. 5 new contract tests (timestamp base yields id / id with underscores / depth-2 returns null / plain slug dir returns null / self path not parented to itself).
  • 轨迹分支树的悬停分支操作按节点类型分派「重答 / 切换」:此前树行对所有节点只给一个「在此重答」按钮,但 session.branchAt 对 assistant/工具锚的语义是 leaf 直接落到该节点(= 纯切换,不回填、不重新回答)——标着"重答"点在旧分支的助手消息上实际只是把会话叶切过去,与地图修掉的假切换同源。现树行按 entry kind 分派:user 消息行只渲染「在此重答」(定位父级 + 回填草稿),assistant/工具行只渲染「切换到此分支」(leaf 落节点 + 关草稿回填 + 清遗留草稿,与地图右键同一语义)。3 例新契约测试(user 行只有重答 / assistant 行只有切换 / 工具结果行只有切换)。

    • EN: The trajectory branch tree's hover branch action is now dispatched by node type between "re-answer" and "switch". Previously every tree row offered only a "re-answer here" button, but session.branchAt on an assistant/tool anchor lands the leaf directly on that node — a pure switch with no draft backfill and no regeneration — so clicking "re-answer" on an old branch's assistant message merely moved the session leaf, the same fake-switch family the map fix addressed. Rows now dispatch on entry kind: user message rows show only "re-answer here" (position at parent + backfill draft), assistant/tool rows show only "switch to this branch" (leaf lands on the node + draft backfill disabled + leftover drafts cleared, same semantics as the map's context menu). 3 new contract tests (user row shows only re-answer / assistant row shows only switch / tool-result row shows only switch).
  • 会话地图「切换到此分支」改用轮末事件为锚,事件卡支持单击选中/双击跳转/独立右键菜单:此前切换锚误用轮首 user 消息——session.branchAt 对 user 消息定位到其父级并重答回填草稿,导致地图上怎么切都停在旧分支状态、输入框还残留旧草稿;现切换锚一律取轮末带 entryId 的 assistant/工具事件(leaf 落到该分支末尾,转录立即显示切到的分支),user 事件锚只保留给「在此重答」。事件卡从"单击即跳转"改为单击选中(kind 图标 + accent 选中态)、双击才跳转对话定位、右键菜单直接以该事件为锚(切换/重答/跳转/分叉不再只作用于整轮卡片)。switchToNode 新增 backfillDraft 选项,地图切换路径显式关回填并清掉上一轮重答遗留草稿。3 例新契约测试(事件卡 kind 图标渲染 / 选中态唯一 / 切换锚取轮末非 user 事件、重答锚取轮首 user 事件)。

    • EN: The session map's "switch to this branch" now anchors on the turn-end event, and event cards support click-to-select / double-click-to-jump / per-card context menu. The previous switch anchor wrongly used the turn's leading user message — session.branchAt answers a user message by positioning at its parent and refilling the draft, so switching on the map stayed stuck on the old branch state with a stale draft in the composer; the switch anchor is now always the last entryId-bearing assistant/tool event of the turn (the leaf lands at the branch tip and the transcript immediately shows the switched branch), while the user-event anchor is kept only for "re-answer here". Event cards changed from click-to-jump to click-to-select (kind icon + accent selected state), double-click to jump to the conversation, and a context menu anchored on the specific event (switch/re-answer/jump/fork no longer act only on the whole turn card). switchToNode gains a backfillDraft option; the map's switch path explicitly disables backfill and clears any leftover re-answer draft. 3 new contract tests (event card kind icons render / selected state is exclusive / switch anchor picks the turn-end non-user event, re-answer anchor picks the leading user event).
  • 会话序言条目(parentless 的 model_change / thinking_level_change)不再打断消息树的根判定:物化视图开头常有一串无父节点的簿记序言,首条 user 消息因此不再是"最老条目"——leaf-walk 的真根判据(游标抵达最老条目)整段失效,活跃路径过滤被跳过,撤回/重答后旧分支消息与新分支一起显示;轨迹深度链同因误判丢失 pathTurn,地图编号回退错误的 journal 序(实机会话 2 轮分支显示成 Turn 3),地图分叉贝塞尔也错误挂在上一个主线轮之下。修法:① walkLeafPath 新增"头部 root 级连续段"判定——序言段的 parentless 条目视为真根,中段漏打 parentId 仍判链断(顾问卡回归契约保留);② 轨迹 depthOf 的假根判定改用同一头部段集合;③ 分叉条高亮优先取 activePathIds 中的活跃子节点,不再近似为最后追加的子节点(切回旧兄弟分支后高亮滞留新分支的实机回归);④ 地图分支锚点改按树深度锚定到父轮(displayTurn = 分支深度 - 1)。4 例新契约测试(序言不破坏链 / 序言存在时中段假根仍判链断 / 序言拓扑编号恢复 2-1、2-2 / 兄弟轮分叉锚在深度-1 父轮)+ 高亮跟随活跃路径契约测试。

    • EN: Session prologue entries (parentless model_change / thinking_level_change) no longer break the message-tree root detection. The materialized view often opens with a run of parentless bookkeeping entries, so the first user message is no longer the "oldest entry" — the leaf-walk root criterion (cursor reaching the oldest entry) failed wholesale, active-path filtering was skipped, and after a rollback/re-answer the old branch showed alongside the new one; the trajectory depth chain misjudged the same way, losing pathTurn so the map numbering fell back to the wrong journal order (a 2-turn fork rendered as Turn 3), and the map's fork bezier anchored under the wrong mainline turn. Fixes: ① walkLeafPath gains a "leading root-level run" verdict — prologue-run parentless entries count as genuine roots while a mid-stream missing parentId still cuts the chain (advisor-card regression contract preserved); ② the trajectory depthOf false-root verdict uses the same leading-run set; ③ the branch bar's active highlight prefers the active-path child from activePathIds instead of approximating to the last-appended child (real-session regression: highlight stuck on the new branch after switching back to an old sibling); ④ the map's branch anchor keys on tree depth to the parent turn (displayTurn = branch depth - 1). 4 new contract tests (prologue doesn't break the chain / mid-stream false root still cuts with prologue present / prologue topology restores 2-1, 2-2 numbering / sibling-turn fork anchors at the depth-1 parent) plus an active-path highlight contract test.
  • assistant 消息上的重试按钮改为原地重新生成,不再重发用户消息:此前点重试是把该轮的用户消息重新发一遍——旧回复留在树上成为孤立分支,"重试"语义与直觉不符,还多花一次模型调用。修法:新增 session.regenerateAt RPC + AgentSession.regenerateAt(targetId)——把会话树切到目标 assistant 节点的父级(叶子移到父节点),随后由 agent 原地继续工作;守护与 branchAt 同口径(进行中/压缩中/目标非 assistant 返回失败,不发广播)。GUI 重试按钮改传本行 assistant 消息的 id(原来传的是用户消息 id),成功后跟随新叶(清 pinned 路径 + pulse),旧回复留在树上作为兄弟分支可回切。4 例新契约测试(视图键解析委托/leaf_moved 广播/busy 时失败且不广播/未知消息)。

    • EN: The retry button on an assistant message now regenerates in place instead of resending the user message. Previously, clicking retry resent that turn's user message — the old reply stayed on the tree as an orphaned branch, the "retry" semantics disagreed with intuition, and it cost an extra model call. Fix: new session.regenerateAt RPC + AgentSession.regenerateAt(targetId) — the session tree navigates to the target assistant node's parent (the leaf moves to the parent node) and the agent continues working in place; guards share the branchAt rule (in-flight / compacting / non-assistant target returns failure with no broadcast). The GUI retry button now passes the current row's assistant message id (it used to pass the user message id) and follows the new leaf on success (clears the pinned path + pulse); the old reply stays on the tree as a sibling branch you can switch back to. 4 new contract tests (view-key resolution delegation / leaf_moved broadcast / busy failure with no broadcast / unknown message).
  • 回退/切分支后概览面只呈现激活路径(轨迹/导航条不再把兄弟分支算进当前会话):回退到某节点重新发消息后,主会话转录已按 leaf-walk 过滤,但右侧轨迹的时间线与统计仍按全量 journal 轮次派生——"轮次"把兄弟分支的轮也计进去(实机会话 2 轮显示 6 轮)、轮次列表列出已回退分支的内容,导航条同样索引全会话轮次,层级与在视分支不符。修法:ChatView 新增 pathEntries——与转录同一套 leaf-walk 契约(walk complete 或 daemon 捎带 pinnedPath 才过滤,拓扑不可信回退全量旧行为),轨迹视图的时间线模式与统计改吃路径集(分支树模式保留全量淡显,它本身就是分支结构视图),导航条轮次按路径 entryId 过滤(仅 walk complete 时——pinned 集是尾窗截断,rail 还肩负全量索引 + 向上翻页职责)。7 例新契约测试钉死实机拓扑(会话 01a0f7a7:新分支叶走到真根判 complete、兄弟分支整批离开转录、尾窗断链/非最老无父节点两态一行不藏、pin 根与断链捎带路径两契约)。

    • EN: After a rollback / branch switch, the overview surfaces now show only the active branch path (the trajectory and turn rail no longer count sibling branches into the current session). The main transcript was already filtered by the leaf-walk, but the right-panel trajectory's timeline and stats were still derived from the FULL journal turn set — "turns" counted sibling-branch rounds (a real session with 2 turns showed 6), the turn list showed content from the rolled-back branch, and the rail indexed the whole session's turns, so their hierarchy disagreed with the branch in view. Fix: ChatView gains pathEntries — the same leaf-walk contract as the transcript (filter only when the walk completes or the daemon ships a pinned path; fall back to the full set when the topology is untrusted), the trajectory view's timeline mode and stats consume the path set (tree mode keeps the full set with dimming — it IS the branch-structure view), and the rail's turns are filtered by path entryId (only when the walk completes — the pinned set is a tail-window truncation, and the rail still serves as the full-session index + page-up channel). 7 new contract tests pin the real-session topology (session 01a0f7a7: the new-branch leaf walks to the genuine root and completes, sibling branches leave the transcript en masse, the tail-window chain-cut and non-oldest-parentless states hide nothing, plus the pin-to-root and cut-chain-with-pinned-path contracts).
  • view-store 落盘改增量投影,流式期间不再每 100ms 全表重写(P1-13):daemon 的 persist 调度此前每次 upsert 都 DELETE 全表 + 逐条重插 messages + 整体 JSON.stringify 快照——流式期间约每 100ms 跑一次的同步事务,长会话(10⁴ 行 × 10/s)直接阻塞事件循环。修法:MaterializedView 新增 drainDirtyEntries 脏通道(条目产生/内容替换点记账,persist 调度取走交给 ViewStore);ViewStore 维护 (sessionId → id→seq) 索引并做位置对齐判定——头部 id→seq 位置不变且尾部全是新 id 时只写 changed 行(已知 id 按 seq UPDATE,新 id 在 maxSeq+1 INSERT 并登记),前插/截断/重排失对齐回退全表重建(fail-safe);materialized_sessions 的 JSON 快照按 1s 节流写(force 可绕过,dispose/压缩路径保持即时落盘);remove 清理全部增量状态。6 例新契约测试钉死(内容 persist 只写 1 行且检索新鲜/追加只插尾行且 seq 连续/无变更 persist 写 0 行/前插失对齐回退全量且保持一致的/节流跳过与 force 绕过/remove 后复活全量重建)。

    • EN: The view-store now persists incrementally instead of rewriting the whole table every 100ms while streaming (P1-13). The daemon's persist scheduler used to DELETE the entire messages table + re-insert every row + JSON.stringify the whole snapshot on EVERY upsert — a synchronous transaction running roughly every 100ms during streaming, blocking the event loop outright on long sessions (10⁴ rows × 10/s). Fix: MaterializedView gains a drainDirtyEntries dirty channel (booked at every entry creation / content replacement, drained by the persist scheduler and handed to ViewStore); ViewStore keeps a (sessionId → id→seq) index and judges positional alignment — when the head's id→seq mapping is unchanged and the tail is all-new ids, only the changed rows are written (known ids UPDATE by seq, new ids INSERT at maxSeq+1 and are registered), while prepend/truncate/reorder misalignment falls back to a full-table rebuild (fail-safe); the materialized_sessions JSON snapshot is throttled to a 1s write interval (force bypasses; dispose/compaction paths still persist immediately); remove clears all incremental state. 6 new contract tests pin it (a content persist writes exactly 1 row and search stays fresh / an append inserts only the tail rows with contiguous seqs / a no-change persist writes zero rows / prepend misalignment falls back to a full rebuild and stays consistent / throttle skips and force bypasses / remove-then-resurrect rebuilds fully).
  • 轨迹/地图全量历史补全不再持内存双份副本,失败可重试(P0-4):ChatView 的 ensureFullHistory 此前把 session.history 分页拉回的每条 entry 原文存进第二份本地数组(长会话数千轮下是数百 MB 的重复内容,与 store 的 tail 条目还共享引用),且任一分页 RPC 失败即永久闩锁——无提示、无重试,轨迹/地图静默停留在尾窗局部历史。修法:摄入时按 OVERVIEW_TEXT_CAP(400,概览消费方的最大显示截断)预裁——新增 lightenOverviewEntry(长文本块/thinking/result/顾问 note 截 400、图片块剥掉 base64 data 保留标记),显示输出对所有概览消费方(treeTextOf / treeKindOf / treeVerdictOf / buildMessageTree)恒等,契约测试钉死;stale 游标改走 catch 而非静默 break;catch 复位闩锁并置 fullError,轨迹面板渲染显式降级提示(点击直达重试,i18n zh/en)。明细按需仍由 session.history 分页 + requestJump 覆盖,不另做。

    • EN: The trajectory/map full-history backfill no longer keeps a second in-memory copy of every entry, and failures are retryable (P0-4). ChatView's ensureFullHistory used to store every entry returned by the session.history pages verbatim into a second local array (hundreds of MB duplicated for long sessions, sharing references with the store's tail entries), and any single page RPC failure latched permanently — no notice, no retry; trajectory/map silently stayed on the partial tail window. Fix: entries are pre-trimmed at ingest to OVERVIEW_TEXT_CAP (400, the largest display truncation across overview consumers) — new lightenOverviewEntry (long text/thinking/result/advisor-note blocks capped at 400, image blocks stripped of their base64 data but keeping the marker); display output is identical for every overview consumer (treeTextOf / treeKindOf / treeVerdictOf / buildMessageTree), pinned by contract tests; a stale cursor now throws into catch instead of silently breaking; catch resets the latch and sets fullError, and the trajectory panel renders an explicit degraded-state notice (click goes straight to retry, i18n zh/en). Details on demand remain covered by session.history paging + requestJump, nothing duplicated.
  • 轨迹/地图派生改增量缓存,流式帧重算量与总条目脱钩(P1-11):轨迹面板与轮级地图挂在每帧换引用的 store 输出上(snapshot 每帧 spread 新数组、leafWalk 沿 snap.entries 重建出新 Set、roundDurations 每帧新数组),旧实现每个流式帧都对全量历史(全量补全后上万条)跑 buildTrajectoryTree 全量重算——两遍遍历 + 每条的文本截断/参数序列化,长会话下是每帧数百 ms 的热路径;且 depthOf 在父链断裂时跳过记忆化,单次重算内部再退化为 O(n²)(尾窗头部条目父在窗外是常态)。修法:新增 trajectory-derive 增量派生(turn-derive 先例)——按对象身份找公共前缀,前缀事件与运行态断点(轮计数/工具计数/首末 ts/回填索引/事件数)原样复用,只重跑增量片段;activePath 内容变化(GUI 每帧新 Set 引用,同内容视为同路径)/前插/收缩触发全量重派生;分组层 events 引用 + roundDurations 内容双重不变时返回上次结果引用,下游 memo 不失效;零变更帧 O(n) 指针比对后直接返回上次结果。depthOf 的断链/假根判定改为可记忆化结论(链上节点共享同一 undefined 判定,一次记下)。buildTrajectory 保持纯函数契约(新增可选 hooks/finalState,输出形状不变)。轨迹视图与地图画布两处接入。10 例新契约测试(probe 计数桩钉死:首调全量、流式追加只计新增、尾部替换只计被替换位、零变更帧不计且引用稳定、前插/路径变更全量、与一次性 buildTrajectory 任意操作序列共价、toolResult 回填跨断点稳定、分组层引用稳定)。

    • EN: The trajectory/map derivation is now incrementally cached, decoupling per-frame recompute from total entry count (P1-11). The trajectory panel and turn map sit on per-frame re-referenced store output (the snapshot spreads a new array every frame, leafWalk rebuilds a new Set along snap?.entries, roundDurations is a new array every frame), so the old implementation ran the full buildTrajectoryTree over the entire history (10k+ entries after full backfill) on EVERY streaming frame — two passes plus per-entry text truncation / argument serialization, a hundreds-of-ms-per-frame hot path on long sessions. Worse, depthOf skipped memoization when the parent chain broke, degrading a single recompute to O(n²) (a head-of-window entry whose parent is outside the window is the norm). Fix: new trajectory-derive incremental derivation (turn-derive precedent) — find the common prefix by object identity, reuse prefix events and the run-state checkpoint (turn count / tool count / first-last ts / backfill index / event count), and re-derive only the incremental fragment; activePath CONTENT change (the GUI passes a new Set reference every frame — same content counts as the same path), prepend, or shrink triggers a full re-derivation; the grouping layer returns the previous result by reference when both the events reference and the roundDurations content are unchanged, so downstream memos stay valid; a zero-change frame returns the previous result after an O(n) pointer scan. depthOf's broken-chain / fake-root verdicts are now memoizable conclusions (chain nodes share one undefined verdict, recorded once). buildTrajectory keeps its pure-function contract (new optional hooks/finalState, output shape unchanged). Wired into both the trajectory view and the map canvas. 10 new contract tests (probe counter pins it: first call full, streaming append counts only new entries, tail replacement counts only the replaced position, zero-change frames count nothing and keep reference stability, prepend/path change full, output equal to one-shot buildTrajectory across arbitrary op sequences, toolResult backfill stable across checkpoints, grouping-layer reference stability).
  • 会话外壳派生按拓扑版本号门控,流式帧不再全量遍历(P1-12):ChatView 有 11 个依赖 snap.entries 的 memo 在流式帧内全部重跑——branchChildren(O(n) 分支索引)、leafWalk(叶子链)、canvasRail(byId Map + 用户消息摘要)、activePathIds、visibleEntries、overviewEntries(还把全部条目 id 每帧 join 成签名串,O(n) 字符串分配);常驻的消息树面板关闭时也每帧跑 buildTurnTree + filterTree 并全量渲染;画布卡片 memo 被 ChatView 传入的内联箭头函数击穿(TmNodeCard 的 props 经 TurnMapCanvas 内部 useCallback 依赖链每帧换身份)。修法分两层:MaterializedView 新增 structureRev(只在 push/prepend 时 bump,内容 upsert 不换 id/parentId 故不 bump)与 entriesRev(任何变更都 bump),snapshot() 按 entriesRev 缓存输出数组——非内容帧(turn_start/end、agent 生命周期)复用上一帧的同一数组引用;ChatView 的拓扑 memo(branchChildren/effectiveLeaf/leafWalk/canvasRail/overviewEntries 合并键)改挂 structureRev,visibleEntries 保持随 entries 走(行更新靠新条目对象传播,是流式显示的必要成本,且其依赖的 leafWalk/activePathIds 已稳定);overviewEntries 的 O(n) 签名串换成 structureRev:length 键(id 集合是二者的纯函数);画布四个回调(onJumpToEntry/onSwitchToBranch/onBranchTo/onForkAt)与 forkFromMessage/switchToNode 用 useCallback 稳定化;ContextPanel 的 messageCount/firstTs 从每帧无 memo 的 O(n) filter 改为 structureRev 门控;MessageTree 的 tree/filter 挂 open 状态,关闭时零计算零渲染。6 例新契约测试钉死版本号语义(内容 upsert 不动 structureRev、push/prepend 双 bump、非内容帧引用复用、roundDurations 引用稳定、旧快照无 structureRev 可恢复)。

    • EN: The session shell's derivations are now gated on a topology revision, so streaming frames no longer walk the full entry list (P1-12). ChatView had 11 memos depending on snap.entries that all re-ran inside a streaming frame — branchChildren (O(n) branch index), leafWalk (leaf chain), canvasRail (byId map + user-message summaries), activePathIds, visibleEntries, overviewEntries (which also joined every entry id into a signature string per frame, an O(n) allocation); the permanently-mounted message-tree panel ran buildTurnTree + filterTree with a full render every frame even while closed; and the canvas card memo was broken by inline arrows from ChatView (TmNodeCard's props changed identity per frame through TurnMapCanvas's internal useCallback dep chain). Fix, in two layers: MaterializedView gains structureRev (bumped only on push/prepend — a content upsert never changes id/parentId) and entriesRev (bumped on every mutation), and snapshot() caches its output arrays by entriesRev — non-content frames (turn_start/end, agent lifecycle) reuse the exact same array reference as the previous frame; ChatView's topology memos (branchChildren/effectiveLeaf/leafWalk/canvasRail/overviewEntries merge key) gate on structureRev, while visibleEntries stays on entries (row updates ride on the new entry objects — a necessary cost of streaming display, and its leafWalk/activePathIds inputs are now stable); overviewEntries' O(n) signature string becomes a structureRev:length key (the id set is a pure function of the two); the four canvas callbacks (onJumpToEntry/onSwitchToBranch/onBranchTo/onForkAt) plus forkFromMessage/switchToNode are stabilized with useCallback; ContextPanel's messageCount/firstTs move from an unmemoized O(n) filter per frame to a structureRev-gated memo; MessageTree's tree/filter gate on open — zero work and zero rendering while closed. 6 new contract tests pin the revision semantics (content upsert leaves structureRev alone, push/prepend bump both, non-content frames reuse references, roundDurations reference-stable, old snapshots without structureRev still restore).

Added

  • TUI /extensions 仪表盘补齐子系统插件面(组件开关 + 新 kind 分组):此前 TUI 仪表盘有两处实质缺口——① voice/terminal/browser/computer/lsp/file 六类子系统内置单元在 ALL 视图被 kindOrder 白名单整组丢弃(provider 页可见、ALL 页不可见);② 插件「包含的组件」在 TUI 完全不可见不可操作(GUI 有组件开关行)。修复:kindOrder 补六类子系统 kind(各配图标与 i18n 分组标签);列表在插件行下渲染组件子行(缩进 + 勾选态),Space/点击开关走 daemon 同一条 componentDenyTarget 映射(tool → tools.disabled、stt/tts 引擎 → voice.disabledEngines 带前缀、terminal/browser/file 后端 → 各自 disabledBackends)直写隐藏设置键,下个会话工具集/引擎过滤即消费——不是展示层;检视面补「包含的组件」段(每组件状态 + 已翻译说明)。TUI 独立进程无 daemon 标注组件态,applyBuiltinMirrorState 扩展为同时按隐藏黑名单重解析组件状态(fail-open 与 daemon 同口径)。daemon setComponentEnabled 重构复用同一 helper,消除两处映射漂移面。9 例新契约测试(映射分支/TUI 组件标注/fail-open/镜像正交/子行渲染/开关回调两态)。

    • EN: The TUI /extensions dashboard gains the subsystem plugin surface (component toggles + new kind groups). Two real gaps existed: ① the six subsystem builtin units (voice/terminal/browser/computer/lsp/file) were dropped entirely from the ALL view by the kindOrder whitelist (visible on provider tabs, invisible on ALL); ② plugin "included components" were completely invisible and non-toggleable in the TUI (the GUI has component toggle rows). Fix: kindOrder gains the six subsystem kinds (each with an icon and an i18n group label); the list renders component child rows under a plugin row (indented + checkbox state), and Space/click routes through the same componentDenyTarget mapping the daemon uses (tool → tools.disabled, stt/tts engines → voice.disabledEngines with prefix, terminal/browser/file backends → their disabledBackends keys), writing the hidden settings keys directly so the next session's tool set / engine filtering consumes them — not a display layer. The inspector gains a "Components" section (per-component state + translated description). TUI standalone has no daemon to annotate component state, so applyBuiltinMirrorState now also re-resolves component state from the hidden denylists (fail-open, same rule as the daemon). The daemon's setComponentEnabled refactors onto the same helper, removing the two-copy drift surface. 9 new contract tests (mapping branches / TUI component annotation / fail-open / mirror orthogonality / child-row rendering / toggle callbacks in both states).
  • TUI /settings 插件归属键随插件停用联动隐藏(与 GUI 同一存储零漂移):GUI 语音设置早已按插件 state 隐藏 stt./tts. 段(voice.tsx excludePrefixes + 停用说明卡),TUI /settings 此前完全无此联动——插件停用了配置键还挂在面板里。修法:settings-defs CONDITIONS 新增四个谓词——sttPluginEnabled / ttsPluginEnabled / browserPluginEnabled(catch fail-open,与 daemon 同口径)/ computerPluginEnabled(不 fail-open,随 unsetDisabled 语义:未设置即隐藏);schema 侧给 stt.(含 vadEndMs/modelName/submitTrigger)、tts.(含 localModel/localVoice/autoRead/inputMode)、speech.*(含 mode/enhanced/voice)、browser.cdpUrl/headless、computer.display/maxWidth 挂上对应 condition。总开关本身(stt.enabled / speech.enabled / browser.enabled / lsp.enabled)一律不挂条件——停用后仍可见可重新启用,与 GUI「说明卡 + 一键启用」等价。顺带补齐 stt.language 的 ui 块(此前是插件 config 里唯一没有 /settings 入口的键)。数据源是 settingsMirror 同一批设置键,双端读同一份存储,不存在漂移面。7 例新契约测试(谓词跟随/总开关豁免/language 补块/fail-open 两向/unsetDisabled 语义/number 无 options 键不进面板的负契约)。

    • EN: TUI /settings plugin-owned keys hide when their plugin is disabled (same storage as the GUI, zero drift). The GUI voice settings already hid stt./tts. sections by plugin state (voice.tsx excludePrefixes + disabled note card); the TUI /settings had no such linkage — keys stayed in the panel with the plugin disabled. Fix: settings-defs CONDITIONS gains four predicates — sttPluginEnabled / ttsPluginEnabled / browserPluginEnabled (fail-open on error, same rule as the daemon) / computerPluginEnabled (no fail-open, follows unsetDisabled semantics: unset means hidden); the schema attaches the matching condition to stt.* (vadEndMs/modelName/submitTrigger), tts.* (localModel/localVoice/autoRead/inputMode), speech.* (mode/enhanced/voice), browser.cdpUrl/headless, and computer.display/maxWidth. The master switches themselves (stt.enabled / speech.enabled / browser.enabled / lsp.enabled) carry NO condition — they stay visible and re-enableable after disable, equivalent to the GUI's note card + one-click enable. Also adds the missing ui block for stt.language (previously the only plugin config key with no /settings entry). Both ends read the same settingsMirror keys from the same storage, so there is no drift surface. 7 new contract tests (predicate follows switch / master-switch exemption / language block / fail-open both ways / unsetDisabled semantics / negative contract that number-without-options keys stay out of the panel).
  • TUI /extensions 检视面新增「配置:」段(插件 config 如实呈现):此前 TUI 能看到插件声明了哪些配置字段,但看不到当前值。修法:applyBuiltinMirrorState 的 updateExtension 在组件标注之外,按 def.config 字段声明逐键 getRaw 收集已落盘值,经 coerceConfigValues 钳制后挂 configValues;检视面渲染「配置:」段(label 经 t() 翻译,未翻译的 GUI i18n 键跳过,缺 label 回退字段键,空值显示 dim 的「(default)」)。编辑入口统一在 /settings 同一批键,检视面不重复造表单——一个事实一个家。1 例新契约测试(builtin 单元 configValues 从设置直读)。

    • EN: The TUI /extensions inspector gains a "Config:" section (plugin config shown truthfully). Previously the TUI could see which config fields a plugin declared but not their current values. Fix: applyBuiltinMirrorState's updateExtension, in addition to component annotation, collects stored values per declared def.config field via getRaw, clamps them through coerceConfigValues, and attaches configValues; the inspector renders a "Config:" section (label through t(), untranslated GUI i18n keys skipped, missing label falls back to the field key, empty values show a dim "(default)"). Editing stays in /settings on the same keys — the inspector does not duplicate a form; one fact, one home. 1 new contract test (builtin unit configValues read straight from settings).

Changed

  • 兄弟轮次统一编号(撤回/重答后地图、轨迹、轮次导航条显示 2-1/2-2):此前回退到第 2 轮重新发问后,新的第 2 轮与旧的第 2 轮在所有概览面同名——用户无法区分激活的是哪个分支,直觉上的"2-1/2-2"式区分不存在。修法:buildTrajectory 新增轮起始兄弟索引(同父轮起始按 journal 序编号),轮标签 = 树深度 + 兄弟序(兄弟数 > 1 时 ${depth}-${sib},否则纯深度);user/advisor 轮起始给后续 assistant/tool 事件带当前标签,分组层以 displayTurnLabel 透传;地图画布(节点卡/分支车道头/悬停卡)、轨迹视图(轮头/检视器)统一改为标签优先渲染。断点续派生(增量缓存)把当前标签记入运行态快照(init 恢复),增量片段与一次性全量派生标签共价。2 例新契约测试(兄弟会话标签序列/线性会话纯深度)。

    • EN: Sibling turns get unified labels (after a rollback / re-answer, the map, trajectory, and turn rail show 2-1/2-2). Previously, rolling back to turn 2 and asking again produced two turn-2s with identical names across every overview surface — users could not tell which branch was active, and the intuitive "2-1/2-2" distinction did not exist. Fix: buildTrajectory gains a turn-start sibling index (siblings under the same parent turn-start numbered in journal order); the turn label is tree depth + sibling index (${depth}-${sib} when there is more than one sibling, plain depth otherwise); user/advisor turn starts stamp the current label onto subsequent assistant/tool events, and the grouping layer passes it through as displayTurnLabel; the map canvas (node cards / branch lane headers / hover cards) and the trajectory view (turn headers / inspector) all render the label first. The resumable derivation (incremental cache) records the current label in the run-state checkpoint (restored from init), so incremental fragments produce labels equivalent to a one-shot full derivation. 2 new contract tests (sibling-session label sequence / linear session plain depth).
  • 统一「扩展与插件」命名口径,清掉「扩展控制中心」残留:设置导航在早前已改名「扩展与插件」,但中心自身的呈现名仍是旧名。对齐三处——GUI ExtensionsCenter 标题块(ext center title)、TUI /extensions 仪表盘顶栏、TUI /extensions 命令描述——全部改为「扩展与插件 / Extensions & plugins」;顺带移除 client-core 词典中已无组件引用的死键 "extensions control center"。口径边界不变:遗留 omp/pi 包仍称扩展(OMP Extension Packages provider 不动),自有 cordis 单元仍称插件(MusePi Plugins providerName 不动),本次只统一汇总中心的呈现名。命令名 /extensions、provider id 等持久化标识符不变。

    • EN: Unify the "Extensions & plugins" naming and remove the leftover "Extension Control Center" labels. The settings nav was renamed to 扩展与插件 earlier, but the center's own presentation kept the old name. Three surfaces now align — the GUI ExtensionsCenter title block (ext center title), the TUI /extensions dashboard top border, and the TUI /extensions command description — all read "Extensions & plugins" / 扩展与插件; the dead dictionary key "extensions control center" (no component references it) is removed from client-core. The naming boundary is unchanged: legacy omp/pi packages stay "extensions" (the OMP Extension Packages provider is untouched), our own cordis units stay "plugins" (the MusePi Plugins providerName is untouched) — this cut only unifies the umbrella center's display name. Persistent identifiers (/extensions command name, provider ids) do not change.

Full Changelog: v0.5.2...v0.5.3