dsh-cyber-im-avatar
Verifieddsh-cyber-im-avatar · v0.8.0 · MIT · Web UI
IM 分身壳:在 DSH 侧边栏值守飞书/企业微信,命中新消息唤醒绑定 DSH 会话,由 agent 按 skill 闭环自己调 CLI、调研、回复;面板只做消息展示、通知与手动发送。
Install
dsh plugin add dsh-cyber-im-avatar Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
dsh-cyber-im-avatar · IM 分身壳
把仓库根目录那套 skill(SKILL.md + scripts/)做成 DeepSeek Harness 里的常驻壳:
侧边栏一个席位,点开是收件箱 + 分身实例 + 服务对象档案 + 手动发送。
分工(spec 0004):插件只当壳,skill 是唯一的脑子。
| 层 | 干什么 |
|---|---|
| 壳(本插件) | 按节奏拉取(耳朵)、命中唤醒绑定 DSH 会话(嘴)、消息展示 / 通知 / 手动发送(脸) |
| skill | 闭环协议、平台差异、铁律、故障自愈 |
| agent 会话 | 被唤醒后全权处理:自己调 IM CLI 拉上下文、调研、起草、发送 |
cyber-im-avatar/dsh-plugin/
├─ package.json # dsh.bundle(patch) + dsh.client(platform/inject)
├─ cordis.patch.yml # 把 bundle 插进 web 组合
├─ lib/index.js # 宿主半:同源路由 + token + 装 skill + 注入 DSH 服务
├─ lib/app.js # /api/* 路由表与参数校验
├─ lib/state.js # state.json:配置 / 值守名单 / 最近 10 条指针
├─ lib/poller.js # 轮询、分类、收件箱、命中后唤醒
├─ lib/turns.js # live turn:resolveAgent / followup / 等 turn/end / 取消
├─ lib/im.js # 平台适配:lark-cli / wecom-cli 封装 + @我 判定
├─ lib/profiles.js # 服务对象画像(profiles/<platform>/<chat_id>.md)
├─ lib/workspaces.js # 工作区列表(读 ~/.dsh/storages/workspace.json)
├─ lib/sessions.js # DSH 会话目录路径换算(只读)
├─ lib/client.js # 构建产物(内联了 web/panel.js)
└─ web/panel.js # 面板 UI 的唯一真源(原生 DOM,无框架)
这个插件不在本机开任何端口。 面板数据走 DSH 自己的 Web 服务(同源),
后端就是宿主进程里的这些 ESM 模块;整台机器上唯一的监听 socket 是 DSH 那个。
没有 Python 服务、没有 stdio 管道、没有 SQLite、没有 dsh headless 子进程。
装
# 需要一个 DSH(web profile)。已发到 npm,直接装包名:
dsh plugin --profile web add dsh-cyber-im-avatar
dsh web # 重启后侧边栏底部出现「IM 值守」
# 或者装本地目录(改代码时用这个)
dsh plugin --profile web add ~/cyber-im-avatar/dsh-plugin
想让它出现在 DSH 的「插件 → 发现」里可搜可点,把市场索引加进去:
https://raw.githubusercontent.com/buger-gif/cyber-im-avatar/main/dsh-plugin/market/index.json
前置:lark-cli / wecom-cli 已安装并登录。skill 不用单独装:插件首次启动会把
cyber-im-avatar 装到 ~/.agents/skills/cyber-im-avatar/(npm 包自带副本;本地
link 安装读仓库根目录)。已经手装过同一份的不会被覆盖,只打一行提示。
起 DSH 建议用仓库里的脚本,别直接 node bin.js web &:
./scripts/start-dsh.sh # 已在跑就只打印地址(幂等)
./scripts/start-dsh.sh --restart # 先停再起
它做两件事:让 DSH 进独立会话(关掉终端也不会被连带关闭),并清掉代理变量与
NODE_OPTIONS 注入项。token 落到 ~/.dsh/web-token(600)。
命中 → 唤醒 → 同会话续跑
值守会话来了新消息(私聊消息 / 群 @我),插件在宿主里拿绑定的 DSH 会话跑一轮 live turn,任务文本只有两样东西:
- 首行
/cyber-im-avatar—— DSH 的 user invocation,直接把 skill 正文注入这一步 (闭环、平台差异、铁律、故障自愈都在 skill 里,prompt 不再抄); - 命中载荷(平台、chat_id、最近 10 条、画像正文)+ 一句「监听由插件负责,不要挂」。
agent 在这一轮里自己用 lark-cli 拉上下文、去绑定工作区核实事实(只读)、
起草并发送或等你在会话里确认——它有多少工具就用多少,插件不封顶。
壳内不挂监听:轮询、水位、唤醒都是插件的事。agent 不运行
listen_cli/bootstrap.py,也不重挂监听——壳内沙箱是workspace-write,~/.im-standin在工作区外,写不了;也不需要写。
- 同一通 IM 会话 = 一个长期复用的 DSH 会话(
chats.dsh_session),跨命中、 跨重启累积历史;在 DSH 侧边栏能看到、能点开接着聊。 - 在飞 turn 可打断:同一会话又来了新消息 →
agent.cancel旧 turn → 同 session 重起,任务里注明「上一轮被新消息打断,以最新上下文为准」。 - 批次合并:同一轮 poll 拉到多条新消息只起一个 turn,不让 agent 被连发消息叫醒 N 次。
- 并发安全:就是宿主里的同一个 agent,DSH UI 打开它不再抢
session.lock; 另一个 DSH 宿主持有写锁时才报session_locked_other_host。 - 失败重喊:turn 失败/超时后,积压还在就下一轮再喊;不自动连环重试。
- 新建会话:实例卡片或会话详情点「新建会话」→ 打断在飞 turn、解绑当前会话; 下次命中开新会话。旧会话保留在侧边栏,只是不再续聊。这是唯一的换会话入口。
单轮上限默认 600 秒(dsh.timeout);有打断机制后,超时只剩防呆。
模型与会话都走 DSH
不需要在这儿再配一遍模型。插件在宿主进程内拿 DSH 的 sessionController /
sessions,模型、网关、凭据全部由 DSH 自己解析:
| 要什么 | 从哪读 |
|---|---|
| 会话 / agent | ctx.sessionController.resolveAgent() 或 create() |
| 模型 / 网关 / 凭据 | DSH 自己的 profile 配置与凭据库,插件不读、不存、不回传 |
| 工作区 | ctx.workspaceRegistry + ~/.dsh/storages/workspace.json(只读展示) |
所以「DSH 里能正常对话」=「值守能干活」。插件没有第二份 key、没有 headless profile、 没有起草模型设置。
实例的生命周期
一通值守会话 = 一个分身实例 = 一个 DSH 会话。插件管四件事:
| 事 | 行为 |
|---|---|
| 建 | 首个命中时 `create({workspaceId |
| 续 | 永久复用;历史与工具调用都落在同一个 session-… 里 |
| 断 | 新消息到达时 agent.cancel 在飞 turn,同 session 重起 |
| 换 | 只有用户点「新建会话」才换;旧会话留在侧边栏 |
实例卡片显示绑定会话、累计轮数、最近一次 turn 是否成功。累计轮数超过 50 只提示 「可新建」,不自动清理——上下文越长每轮 token 越贵,怎么做由你决定。
会话所在 cwd 与绑定的工作区不一致时(换了工作区),DSH 会拒用旧会话, 插件会丢掉旧 id 在正确工作区重开,并在实例卡上提示「已自动新建」。 显式工作区目录存在但未登记进 DSH 时,仍先恢复旧会话,不会每轮白开新的。
拉取节奏:定时 / 按需 / 手动
| 路径 | 什么时候发生 | 拉什么 |
|---|---|---|
| 定时 | 默认每 10 分钟一轮(可调,下限 10 分钟,上限 240) | 值守中的私聊,以及你逐个勾了「定时检测 @我」的群 |
| 按需 | 点开某个会话时 | 在线拉 30 条;群聊平时完全不进定时轮询,只有这一下 |
| 手动 | 顶部「同步」、会话里的「拉取」、「设置 → 立即拉一轮」 | 不受 10 分钟间隔约束;同一会话 20 秒冷却,连点不会重复打接口 |
面板通知每 60 秒刷新一次(角标 / 收件箱 / 处理中状态),和拉取节奏是两件事。
为什么要给群单独一套规则:群消息量远大于私聊,绝大多数与你无关。真做群值守时, 在那个群卡片上勾「定时检测 @我」,它才按上面的间隔轮询;不勾就只在点开时拉一次。
每轮只刷一次会话清单(拿群名、判断私聊还是群),不读消息正文;正文只在真正要拉 那个会话时才取。本地每个会话只留最新 10 条,更早的正文与历史去平台在线查。
配置在 state.json 的 config 字段(老 panel.json 只在一次性迁移时读):
"poll": {
"interval_min": 10, // 下限 10,写 1 也会被抬到 10
"dm_scope": "recent", // 保留口径;实际候选只看 watching 私聊
"dm_recent": 20,
"per_chat_limit": 10,
"refresh_chatlist": true,
"cooldown_sec": 20
},
"dsh": { "timeout": 600 }
从 0.8.x 升级先跑 node scripts/migrate-state.mjs:旧库每会话最新 10 条、会话配置、
panel.json 配置与 env.json 目标索引搬进 state.json,旧库改名为
panel.db.migrated。老配置键 poll_interval / recent_chats / per_chat_limit /
backfill_hours / poll_watched_only 会自动翻译,port、llm.*、reply.*、
dsh.profile、ui.* 直接丢弃。
屏蔽:让某个群 / 某个人闭嘴
屏蔽之后彻底不同步:
| 屏蔽之后 | 行为 |
|---|---|
| 定时轮 | 不进(哪怕这个群还勾着「定时检测 @我」) |
| 点开会话 | 不补拉,只显示屏蔽前的历史 |
| 手动单会话拉取 | 直接拒拉,一次 CLI 调用都不发 |
| 收件箱 / 未读角标 | 里里外外都没有它,/api/inbox 与计数都不含它 |
| 已经压着的待回 | 屏蔽那一刻标成 ignored |
| 值守配置 | 保留;取消屏蔽后照常恢复(不补历史) |
入口:收件箱每行右侧的小铃铛、会话详情头部的「屏蔽」、分身页卡片上的「屏蔽」; 要放回来就去「分身 → 已屏蔽」。
用
点侧边栏「IM 值守」(带未读红点徽章),面板停靠在右侧栏,五个页签:
| 页签 | 内容 |
|---|---|
| 收件箱 | 待回 / 未读 / @我 / 私聊 / 客服 / 全部;按会话聚合,点一行进详情 |
| 分身 | 值守中的实例:工作区、DSH 会话、轮数、处理中状态、去会话、新建会话、屏蔽;下面是群聊列表、已屏蔽列表、搜人/搜群加入 |
| 服务对象 | 每个对象一份画像文件:直接读、直接改;agent 起草前只读这一份,不展示历史消息 |
| 体检 | 轮询节奏与范围、授权状态、DSH 运行时、skill 路径、最近错误 |
| 设置 | 值守节奏(分钟 + 私聊范围 + 立即拉一轮)、值守范围 |
去会话:实例卡与会话详情头部的「去会话」直接调 DSH 客户端的
uiWorkspace.openSession(sessionId)(侧边栏点会话用的同一条路),把主界面切到
绑定的那个 DSH 会话——看 agent 怎么调研、草稿写到哪、要不要确认,都在那边。
停靠态会顺手在目标会话的右侧栏补开值守面板,左会话右收件箱一起看;
老版本 DSH 没有这个服务时,退回复制会话 id,不假装跳成功。
会话详情里:画像摘要 → 消息气泡 → 手动输入发送 / 忽略。头部可换工作区、 开关值守、新建会话、拉取、屏蔽;有待回且 agent 空闲时多一个 现在处理, 不用等下一条新消息就能让它处理积压(只取最新 10 条)。agent 回复会由下一轮 拉取以「我」的消息出现,不需要面板再起草。
确认,还是全自动
没有插件开关了——节奏由你和 agent 在值守会话里约定:
- 默认:agent 起草后把回复留在会话里,你说「发」它才发(下一轮
is_self消息被拉回来,收件箱的待回自动收敛)。 - 全自动:你在那条会话里明确说「以后这个人的消息直接回」,agent 记住并照办。
- 涉及钱、合同、对外承诺:skill 铁律要求停下等你,任何模式都不替你承诺。
面板里的手动发送永远可用,且不经过模型——手打的就是你本人。
在 DSH 里长什么样
不是独立窗口,也不是浮层:侧边栏底部一颗入口席位(sidebar.footer.action,带未读
角标),点开后面板停靠在 DSH 右侧栏(sidebar.right.pane.tab,和文件 / 终端
同一个位置),左边继续看值守会话,右边处理消息。
点开后是和会话并排的右侧栏,不再遮住主界面。
- 右侧栏布局由 DSH 按会话保存:这个会话开过一次,下次回来还在;换到没开过的 会话时点一下左下角席位即可。
- 详情里的「去会话」会切主界面到绑定会话,并把值守面板在目标会话的右侧栏补开, 两边一起看。
- 需要带右侧栏扩展点的 DSH(按 0.2.1-rc.5 对齐)。没有右侧栏服务的版本不提供 浮层回退:侧边栏席位仍在,点击会提示「这个 DSH 版本没有右侧栏服务」。
公开仓库不保存界面截图,避免真实或演示会话内容进入历史;界面可以用
node scripts/shot-dsh.mjs在本地起一个干净 DSH 复现。
外观
只有浅色和深色两套配色,默认跟随 DSH 自己的主题设置,面板里没有外观选项。
DSH 把主题解析结果写在 body[data-ds-dark-theme] 上(固定深色主题还会在
html[data-ds-theme-source] 留一份镜像),面板用 CSS 祖先选择器直接读这两个标记,
切换主题立即生效,不需要刷新,也不写 state.json。
值守会话绑定工作区
所有「加入值守」入口都会带工作区,默认取「上次在面板里选过的」→「现有会话最近在用的」 → DSH 默认,不用每次重选:
- 下拉列表来自 DSH 自己的登记表(
~/.dsh/storages/workspace.json),默认项标「默认」; 「跟随 DSH 默认」表示不显式绑定。 - 会话详情里的工作区行常显:还没值守也能先选,打开值守时一起生效。
- 也能选「自定义路径…」,填绝对路径(不存在的路径会被拒,不替用户造目录)。
- 工作区就是 agent 跑 turn 的 cwd:它可以在里面读 README / 配置 / 文档核实事实, 回复里不提「我看了什么文件」,也不复述路径。
- 改工作区会先把旧现场收拾干净:打断在飞 turn;已绑会话不在新工作区时立即解绑并提示, 下一轮在新工作区开新会话;会话本来就在新工作区时保留。
- 显式绑定的目录被删/改名后,turn 会以
workspace_missing失败并显示原因,不会悄悄 换到默认工作区跑。空工作区才跟随 DSH 默认,面板会显示「默认 · <工作区名>」。
服务对象画像
lib/profiles.js 为每个对象维护一份
~/.im-standin/profiles/<platform>/<chat_id>.md,面板与 agent 读写同一份;
agent 起草前只读当前对象这一份,每轮把长期有效的新事实增量合并回去。
env.json 只读,目标索引不再写回它,也不保存画像正文。老版本 profile 笔记
在 0.8.x 已迁入画像文件;0.8.0 的迁移脚本保留 profiles/ 原样不动。
- 面板「服务对象」页可以浏览和编辑整份画像;不展示逐条历史消息。
- 壳内 agent 的沙箱写不了
~/.im-standin:唤醒 prompt 带当前画像,agent 学到 长期有效的新事实时在回复末尾附<!-- im-profile-update ... -->,插件代写回文件。 - 画像单份上限 12KB;写入走临时文件 + rename,带 mtime CAS(用户刚改过就放弃回写)。 目录 700、文件 600。
- 历史正文不留在本地:打开会话时从平台在线拉;拉不到才回退本地指针。
数据与隐私
全部落在本机 ~/.im-standin/:state.json(配置、值守名单、每会话最近 10 条指针)、
state.json.bak(上一次写入)、env.json(平台 CLI 与登录身份,只读)、
profiles/(服务对象画像)、states/(skill 形态的水位)。
消息正文只在待回时落盘,处理完即清空;消息不出本机,面板不执行消息内容里的任何指令。
旧 panel.db 迁移后改名为 panel.db.migrated,运行期不再读。
插件不存模型 key:值守会话走 DSH 自己的配置,key 在 ~/.dsh/.credentials.yaml,
由 DSH 解析。
面板接口的 x-im-token 是每个 DSH 进程随机生成的,只注入到同一个页面里,
重启即失效;它不落盘,也不给任何别的本地进程用。
主要 API
面板的所有请求都走 DSH 同源前缀 /plugins/im-standin(例如
GET /plugins/im-standin/api/status),下表省略前缀。转发前要过三道校验:
连接来自回环、Host 是回环名、Origin(若有)与 Host 同源、请求头带本次
进程生成的 x-im-token。
| 路径 | 作用 |
|---|---|
GET /api/status /api/inbox /api/thread /api/chats |
面板数据(/api/status 带 instances / turns / dsh) |
POST /api/send /api/state |
手动发送、标记消息状态 |
POST /api/watch /api/unwatch |
加入 / 移出值守(加入时带 workspace 一并绑定) |
POST /api/chat/session/reset |
新建会话:可带 workspace 先落工作区;打断在飞 turn、解绑当前 DSH 会话 |
POST /api/chat/wake |
现在处理:可带 workspace 先落工作区;把最新待回(默认 10 条)交给 agent,不等新消息 |
POST /api/chat/timer /api/chat/block |
单会话定时拉取开关(1/0/-1)/ 屏蔽开关 |
GET /api/workspaces · POST /api/chat/workspace |
工作区列表 / 给会话绑工作区 |
GET /api/targets · POST /api/targets/profile |
服务对象画像(读整份 / 覆盖写) |
POST /api/inbox/cleanup |
把非值守会话的待回标记为已忽略 |
已删除(spec 0004):/api/draft、/api/suggestion、/api/chat/plan-confirm。
已知限制
- 企业微信 CLI 没有会话枚举接口,只能对已登记的会话值守(面板里手动加)。
- 群消息里的 @ 用 mentions 的 open_id 精确判定;缺少 mentions 且只剩
@_user_N占位符时按「不是 @我」处理。 - 打断用
agent.cancel;agent 已发出的消息不会撤回(下一条消息触发新一轮即可)。 - 永久复用会话 = 上下文单调增长,token 成本随轮数上升;插件只提示,不自动新建。
- DSH 仍是 0.2 预览版,插件 API 可能随版本变化。本插件按 0.2.1-rc.5 对齐。
调试
# 迁移预演:只打印会写什么,不动 state.json / panel.db
node scripts/migrate-state.mjs --dry-run
# 看后端在回什么:打开 DSH 面板(同源 fetch),或跑 Node 测试
node --test
# 改完 web/panel.js 必跑:重建浏览器半
python3 scripts/build_client.py
# 截图(headless Chrome + CDP,对 DSH 页面拍)
node scripts/shot.mjs "http://127.0.0.1:3080/?token=$(cat ~/.dsh/web-token)" /tmp/shot.png 9000 1280 860
# README 那几张:起一个干净工作区的 DSH(它自己的 3099),注入示例数据后连拍
node scripts/shot-dsh.mjs screenshots
以上命令都在本目录(dsh-plugin/)执行。
DSH 起不来、turn 起不来这类问题,按 docs/implementation/runbooks/troubleshoot.md 排查。