dsh-observer — 副脑 / 孵化主脑
安装
所有四个包都没有发布到 npm,且 npm 上的裸名
dsh-memory/dsh-observer已被他人占用。 因此统一使用 GitHub 直装——这是目前唯一可靠的安装方式。
一键安装四个包
dsh plugin add github:corvus-dsh/dsh-paradise
dsh plugin add github:corvus-dsh/dsh-memory
dsh plugin add github:corvus-dsh/dsh-graft-slice
dsh plugin add github:corvus-dsh/dsh-observer
顺序有意义:dsh-paradise 是内核,其余三个都依赖它提供的协议。先装内核,后装业务插件。
装完确认
dsh plugin list
应该看到四行,fiberPhase 都是 active:
include:dsh-paradise dsh-paradise active
include:dsh-graft-slice dsh-graft-slice active
include:dsh-memory dsh-memory active
include:dsh-observer dsh-observer active
如果某个是 null 或显示「异常」,看宿主日志里该包的启动错误——最常见的原因是 Node 版本低于 22.5。
卸载
dsh plugin remove dsh-observer
dsh plugin remove dsh-graft-slice
dsh plugin remove dsh-memory
dsh plugin remove dsh-paradise
从源码运行(不装插件)
如果只是想跑独立宿主和 Web UI,不需要 DSH:
git clone https://github.com/corvus-dsh/dsh-paradise
cd dsh-paradise
node dist/harness-server.js
# 浏览器打开 http://127.0.0.1:38080
详见《星火 OS 独立运行指南》。
要求
| 项 | 版本 |
|---|---|
| Node | ≥ 22.5.0(node:sqlite 需要) |
| DSH | ≥ 0.2.1-rc.5 |
| 外部依赖 | 零。全部只用 Node 内置模块 |
为什么不用 npm
npm 上的 dsh-memory 和 dsh-observer 这两个名字已被第三方注册,包内容与我们无关。
我们不愿为一个名字支付赎金,也不愿让用户装到别人的包,所以改用了作用域命名
(@corvus-dsh/*)并主推 GitHub 直装。
等 @corvus-dsh 作用域下的包正式发版后,npm install @corvus-dsh/dsh-memory
也会可用,但GitHub 直装始终是更稳妥的方式——它直接取仓库当前状态,
不经过 npm 的发布延迟。
v1.9.0(2026-11-05)— 写路径切换到彼岸层:本插件成为dsh-paradise的第一个真实业务消费者孵化 =
paradise.session.create(items)+paradise.session.trigger;回滚 =paradise.deleteSession。observer-inject.ts保留在盘作为历史存档,但不再被调用——只有两个只读助手(cwd 供体链、错误类型) 仍从它导入。零开关、零共享状态、一条活路径。⚠️ 依赖性质变了:彼岸层从"可选"变成"孵化必需"。缺层不再降级为"磁盘核对跳过", 而是拒绝孵化并给出
PARADISE_WRITE_UNAVAILABLE+ stage——因为写路径本身就是那一层。
v1.9.0:三个调用点换了,回执字段一个没变
| observer 原来调用 | 现在调用 | 位置 |
|---|---|---|
injectColdSession(ctx, {...}) |
paradise.session.create({ cwd, title, inheritFrom, items }) |
paradise-write.ts |
igniteSession(mainId, {...}) |
paradise.session.trigger(session, { text, mode }) |
同上 |
rollbackInjectedSession(ctx, id, why) |
paradise.deleteSession(ctx, id, why) |
同上 |
回执映射是刻意保留的:briefingRole / briefingVisible / briefingEventSeq / briefingSourceKind
全部从适配器的 rendered[] + contentSeq 推导出来,而不是照抄请求。工具结果与既有测试读的字段没变。
切换中暴露的两个真实缺陷(都被测试当场抓住)
- 协议
create只接受title,装不下 plan。 第一版把 plan 当 title 传,落地成"会话里是它自己的名字"。 → 协议create增加items?,理由写进接口注释:顺序写入的宿主上,内容必须在创建时放置。 - 运行时旋钮没继承。 忘了传
inheritFrom,主脑会在不同 provider/model/preset 下开跑。 → 协议create增加inheritFrom?,同样写进注释。
这两个都不是"改一行调用点"能避免的——它们证明切换本身就是一次协议检验。
v1.8.0:磁盘读取器迁入彼岸层
src/core/disk-log.ts 已删除。经 src/core/paradise-bridge.ts 懒加载 dsh-paradise 的读取器。
缺层时是具名降级(pathSource: 'paradise-unavailable'),回执 11 个字段不变。
v1.7.0:副脑成为监理
只读监听主脑事件流;连续 3 次同工具失败或连续 5 轮不推进 TODO → 一条 steer 纠偏(三道限流);
完成/空闲/被切片 → 后台 memory_harvest。决策在 src/core/monitor.ts(纯函数、可脱宿主测试)。
测试
当前 181 / 181。 preflight 15 项,其中三项是 v1.9.0 新增的切换守卫:
- 产物里写路径必须经
ensureWriteAdapter → createDshAdapters; - 缺层时必须
PARADISE_WRITE_UNAVAILABLE(不允许静默 no-op 或崩溃); src/index.ts不得再调用任何归档的写函数(injectColdSession/rollbackInjectedSession/igniteSession)。
测试数从 208 变 181,差额去哪了
不是能力损失,是去处变了:
| 原位置 | 内容 | 现在 |
|---|---|---|
cold-inject.test.mjs 24 条 |
tape 校验器逐条断言 | 重写为 11 条(断言新路径的属性)+ 迁入 paradise |
ignition.test.mjs 19 条 |
点火通道内部 | 重写为 9 条(经回执断言)+ paradise write-lifecycle |
visibility.test.mjs 4 条 |
tape 形状 | 迁入 dsh-paradise/test/shape-validation.test.mjs(18 条) |
原则:测试必须钉住会执行的那份代码。钉住归档模块的测试,等于给死人做体检。
已知边界
- 彼岸层现在是硬依赖。 缺层 → 拒绝孵化(有 stage、有 code、有原因)。这是"零开关"的必然代价,也是它诚实的地方。
observer-inject.ts仍被 inlined,因为两个只读助手还在里面。想彻底移出,得把那两个助手搬走——那是下一次清理,不是这次切换。inject只覆盖创建时放置;向已存在会话追加仍未接线(层里如实报NOT_WIRED)。graft-slice一行未动。
许可
MIT
历史:v1.8.0 为什么用懒加载而不是 import
本插件是单文件 bundle,composer 只内联相对模块。一句 import ... from 'dsh-paradise' 会活到
产物里,缺包时在 import() 阶段失败 —— 整个插件不激活。v1.9.0 之后彼岸层是写路径的必需依赖,
但"必需"不等于"静态导入":缺层时应当是拒绝孵化并说明原因,而不是整个插件消失。
| 规则 | 做法 |
|---|---|
| 不用静态 bare import | 运行时 await import(specifier),两个候选:dsh-paradise → ../dsh-paradise/index.js |
| 懒 + 缓存 | 第一次需要时解析;失败也缓存,不会每次调用都去探文件系统 |
| 缺层 = 具名事实 | PARADISE_WRITE_UNAVAILABLE + stage,读路径则是 pathSource: 'paradise-unavailable' |
| 回执不变 | DiskReading → DiskLogReport、WriteReceipt → injected 两处映射,用户可见字段零变化 |
| 可观测 | observer_status 返回 paradise: { resolved, available, via, reason, specifiers, consequence } |
核心原则:「读不到」绝不能打扮成「没有」,「写不了」也绝不能打扮成「成功了」。
v1.7.0(2026-11-05)— 副脑不再孵化完就休眠:它成为监理(supervisor)三段式(调研 → 卡片确认 → 孵化)与自动点火已在 v1.5/1.6 稳住。v1.7.0 补上孵化之后的职责: 只读监听主脑的事件流,连续失败或停滞时投递一条纠偏提示,节点完成/空闲/被切片时后台调用
memory_harvest。决策在src/core/monitor.ts(纯函数、无时钟、无服务,可脱宿主测试), 投递在src/core/supervisor.ts。
v1.7.0 的结构
src/core/monitor.ts 纯状态机:失败连击 / TODO 停滞 / 冷却 / 上限 / 提炼闸门
src/core/supervisor.ts 投递:sessionController.prompt(steer) + tools.execute(memory_harvest)
src/index.ts 接线:孵化后启动监理、切片后先提炼再重建、observer_status 暴露状态
一、失败纠偏
| 触发 | 条件 | 动作 |
|---|---|---|
| 连续失败 | 同一个工具连续 3 次失败结果 | 发一条纠偏,建议 memory_search 或换方案 |
| 停滞 | 连续 5 个 turn/end 且 TODO 清单没有移动 |
发一条纠偏,建议先如实更新清单 |
三道限流,缺一不可:冷却(5 分钟)+ 日志距离(≥12 个事件)+ 硬上限(每个主脑最多 4 次)。 "重写一份一模一样的清单"不算推进,"另一次失败前有成功"会清零连击——这两条是防误报的核心。
二、投递形状(这里有一条硬约束)
纠偏不走日志写入。developer/message 是 step-scoped,宿主按该会话当前打开的 turn/step
校验;监理无从得知那一对,而 {turn: 0, step: 0} 是 v1.2.2 被拒的写法本身。
所以投递走宿主自己的通道 sessionController.prompt(request, {mode: 'steer'})——steer 落进
正在进行的回合,turn/step 由宿主负责;被拒绝时退化为 queue(下一回合开头到达)。
三、后台提炼(memory_harvest)
| 时机 | reason | 闸门 |
|---|---|---|
| 清单全部完成 | completed |
同一份完成清单只提炼一次 |
| 安静 90 秒 | idle |
还没关闭过任何回合则跳过 |
| 主脑被切片 | replaced |
绕过冷却,但每个主脑只一次 |
第三条是真正能提炼到东西的那条:memory_harvest 读的是交接账本里的已退休会话,
活着的主脑不在其中。所以回执如实说明"扫描了 N 个已退休会话,本次有没有主脑",
而不是一律报"记忆已提炼"。
v1.3.0(2026-11-05)— 孵化改为「冷 tape 物理注入」:工作区强制继承、方案真正落盘上一版(1.2.x)用
sessionController.create()建会话、再用sessions.get(id).append()写方案。 这条路在本宿主上根本走不通,两个后果都实测到了:工作区没继承(落到「选择工作区」孤儿页), 方案也没写进去(developer/message是 step-scoped,新建会话没有打开的 turn,宿主直接拒绝)。
v1.3.0 的两处手术
一、强制继承工作区
cwd 依次从发起会话自己身上取,取不到就拒绝创建,绝不产生孤儿会话:
| 顺序 | 来源 | 说明 |
|---|---|---|
| 1 | sessions.get(id).header.cwd |
不可变的创建元数据,最权威 |
| 2 | sessions.get(id).cwd |
live 会话直接暴露的字段 |
| 3 | workingDirectory.get(session) |
有效目录(含用户后来的 cd) |
| 4 | sessionQuery.readSession(id).session.cwd |
非 live 会话的持久化 header |
返回值必须通过 isAbsoluteCwd(盘符 / UNC / / 开头)。空、相对路径、取不到 —— 三者都抛
CWD_REQUIRED,并且一个 id 都不会被预留。sessions.prepare(id, { meta: { cwd } }) 返回后还会
再校验一次 prepared.header.cwd === cwd(不等即 CWD_MISMATCH)。
二、移植 graft-slice 的物理注入
主脑是冷产物,唯一的写入者是 store 自带的句柄(与 dsh-graft-slice 同一套):
sessions.prepare(id, { meta: { cwd } }) ← 零继承、detached,不进 live store
buildInjectTape → materializeInjectTape → assertInjectTape ← 全在内存里校验
sessionPersistence.create(prepared.header, { inheritedEventCount: 0 })
handle.append(events) → handle.flush() → handle.close()
sessionQuery.readSession(id) ← 冷读校验:读回来的就是我们写进去的
写出的序列(src/core/observer-inject.ts 里由测试逐条断言):
| # | 事件 | turn/step | surfaceOp |
|---|---|---|---|
| 0 | turn/start |
1 / — | — |
| 1 | step/start |
1 / 1 | — |
| 2 | system/message |
1 / 1 | append ← 第一个 surface 节点(受保护 head) |
| 3 | developer/message |
1 / 1 | append ← 方案 + 武器库 |
| 4 | session/title |
— | — |
| 5 | step/end |
1 / 1 | — |
| 6 | turn/end |
1 / 1,reason: { kind: 'completed' } |
— |
| 7+ | permission/preset · sandbox/mode · approval/policy · model/selection |
— | — |
关于「第一事件」的一处刻意偏离,理由不是偏好。
system/message必须是第一个 surface 节点,但它不能是第一个 事件:它自身携带{ turn, step },宿主按「当前打开的那对 turn/step」校验(requireStep),冷读也是同一套规则。 写在turn/start之前,等于turn === null时写一条 step-scoped 消息 —— 正是 v1.2.2 被拒的那类写入。 真实宿主也是这个顺序:dsh-graft-slice/test/fixtures/real-session.v4.jsonl.zstd里turn/start=seq 4、step/start=seq 6、system/message=seq 7。src/core/observer-inject.ts的assertInjectTape把这条规则写成了断言,test/cold-inject.test.mjs逐条覆盖。
失败即抛出,回滚不留垃圾
observer_spawn_main 的成功路径只有一条;任何一步失败都会:
- 保留原始堆栈原样带出(
孵化失败@<stage> [<code>]:<原始 message>+原始堆栈), 工具返回值同时给出ok:false/stage/error/stack/rollback; - 若会话已经
create过,先 回滚:discardPrepared→remove/delete→sessions.remove, 并在删除后用stat核实它真的没了(删不掉就写[孵化失败]标题,绝不留一个"看起来能用"的空壳); - 绑定表只在产物存在之后写;写失败会先把刚写的主脑回滚掉再抛错,
recordPair自身在save()失败时把内存表恢复原样 ——ok:false就意味着"什么都没发生"。
工作区里的另一半
- 冷会话通过
workspaceRegistry.resolveByPath(cwd)+attachSession(id)挂回发起会话的分组; - 再
emit('api-session/added', …)通告客户端(Web UI 只从这个事件认识会话), 失败都记为warnings,不影响孵化成功。 - 载荷先过
assertSessionSummary(P1-3):宿主的assertJsonArgs只校验载荷可 JSON 序列化, 不校验sessionId是字符串——而客户端与dsh-api-remotes都拿它当 key(resources.session)。 不合格的载荷会被跳过并记 warning,绝不发出去:那是一个在远端静默丢弃、本地看不到任何征兆的失败。
工具一览
| 工具 | 作用 |
|---|---|
observer_spawn_main |
孵化入口:把已确认的方案以冷 tape 注入新主脑,本会话成为副脑 |
observer_create |
反向:主脑召唤一个跟随它的副脑(走 live 路径 + 关闭骨架) |
observer_bind |
为已存在的会话补写指针(手工/历史场景) |
observer_status |
只读绑定表 + 每个主脑的监理状态;ackFocus: true 消费一次性焦点请求 |
observer_consult |
开一个不绑定主会话的「前置需求磋商副脑」 |
observer-spawn(命令) |
手动入口;命令通道每次会留一行,这是该通道的固有代价 |
服务依赖
inject = ['tools', 'sessionController', 'sessions', 'sessionPersistence', 'sessionQuery']
sessions/sessionPersistence/sessionQuery是必需的:没有合法写入者就拒绝孵化, 绝不退化回 live append。sessionController现在用于给副脑一半改标题、给主脑投递纠偏(prompt(steer))与点火, 缺了只记一条警告(点火失败会写进回执,不会静默)。workspaceRegistry保持晚绑定:headless profile 没有它,丢侧边栏分组不能代价是丢主脑。- 服务读取统一走
resolveService(ctx, name, method):同时接受ctx.<name>与ctx.get(name), 并按方法挑选真正可用的那个访问器(工具注册表的直接属性可能只有register, 而execute在get('tools')上)。 - 可选依赖
dsh-paradise:磁盘交叉核对经由它。缺了不影响孵化、点火、监理,只在回执里具名 降级(见上方 v1.8.0 一节)。
测试
node scripts/build.mjs && node --test test/*.test.mjs
当前:204 / 204。 覆盖:
- 冷 tape 物理注入(
incubation.test.mjs):cwd 来自发起会话 header、persistence.create带inheritedEventCount: 0、事件序列与surfaceOp逐条断言、turn 必须闭合、 冷读校验通过、通告与工作区挂载、运行时常量作为 log-only 事件跟在 turn 之后; - 注入内核(
cold-inject.test.mjs):越界/开环/无 system head 的 tape 在预留 id 之前就被拒; prepared session 从不被append;append/flush 失败会回滚且堆栈原样上报; 无sessionPersistence时拒绝而不退化;删不掉时必须标记[孵化失败]; - 宿主形态(
host-shape.test.mjs):只给ctx.get/ 只给直接属性两种接线都能孵化成功; - 记忆调度(
memory-scheduling.test.mjs):武器库追加在方案之后、semantic:false、 没有 dsh-memory 照样孵化; - 监理(
monitor.test.mjs,27 条):连续 3 次同工具失败/连续 5 轮不推进 TODO 各产生一条 纠偏;成功清零、不同工具各自计数、重写同一份清单不算推进;冷却 + 日志距离 + 上限三道限流; 投递走sessionController.prompt(steer)而绝不写主脑日志;memory_harvest报告 到底提炼了谁;切片主脑会先提炼再重建监理;重启后旧配对自动恢复监理; - 彼岸层桥接(
paradise-bridge.test.mjs,14 条):层能从同级目录解析、回执 11 个字段一个不少、 宿主给的路径优先于推导、真实多帧文件端到端读出、缺包降级指名道姓且不破坏孵化、 解析只做一次并缓存、observer_status在读取发生前就能报告依赖状态、 本地disk-log.ts确实已删除且产物里没有 bare import; - 配对与转生:任意一半被切片只改指针、一个会话只属一个配对、重新孵化会解散旧配对;
- 包契约:无 BOM、版本一致、每个工具的
output契约完备、schema 词汇在 Loader 子集内。
测试专用出口(批次 3 第 1 组): 入口导出 16 个名字,其中 9 个是 test seam——
injectionInternals、diskLogInternals、policyInternals、ignitionInternals、confirmationInternals、
monitorInternals、supervisorInternals、debugObserverState、debugFollowTransfer。
它们不是公开 API:形态、名字、是否存在都不保证跨版本,改它们不算破坏性变更。
test/test-seam-inventory.test.mjs 把整个导出面钉成字面量清单,所以新增 seam 必须是有意动作——
优先折进已有的 *Internals 对象(多一个函数不花新名字),其次才新增顶层名字并在 commit 里写明理由。
其中 injectionInternals 与 diskLogInternals 同时是跨仓测试契约:dsh-paradise 的测试套件在用,
scripts/preflight.mjs 还会断言 diskLogInternals 存在。重命名或折叠它们不是本地改动,而是两仓 CI 的耦合——
要做就得两边一次改完、一起推。本批次因此只做了「声明收敛 + 清单锁」,折叠作为已计价项记录(见源码里的 seam 边界块)。
配置
- id: dsh-observer
name: dsh-observer
config:
bindingsPath: "C:\\Users\\<you>\\.dsh\\profiles\\<profile>\\data\\dsh-observer\\observer-bindings.json"
bindingsPath 建议显式给出:desktop 宿主不把 DSH_PROFILE_DIR 传给 Electron 主进程时会回落到默认路径。
已知结构性限制(为什么没有按钮)
详见同级文档《副脑插件架构复盘与底层缺陷报告-2026-11-05.md》:D1 无插件自有 Remote、
D2 commands.execute 是广播、D3 归属只有宿主权威。因此入口是模型工具,不是 UI 按钮。
许可
MIT
No comments yet. Be the first to write one.