DSH 插件:让 AI 通过 Roost 住进 QQ —— 官方接口,不用 hook 客户端
37.7/100
综合分
12
GitHub 星标
12,729
npm 周下载
—/5
用户评分
数据截至 2026/10/31 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add dsh-roostnpm 源 · dsh-roost 的发布者账号已校验与本仓库一致
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:仅索引
是什么
dsh 原生插件 · platform
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 4 天前
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
社区实装还没有人打卡 · 装过的第一个就是你
安装兼容性检查✓ 自动检查通过展开查看检查明细 ↓
以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。
✓npm 包dsh-roost @ 0.10.19
✓Node 引擎要求 >=22 · 基线 Node 22.19 满足
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明
验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/10/30 21:11:40
安全扫描自动 · 静态分析源码v2.7.0🔵 扫描:含敏感能力尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。
可执行外部命令读写/删除本地文件读取剪贴板或密钥环境变量写入剪贴板(不计风险)发起外部网络请求
查看扫描证据(共 138 处,展示前 116 条)
- 发起网络请求src/host.js:629
const r = await fetch(url, { signal: AbortSignal.timeout(5000) }); - 写入本地文件src/host.js:1178
mkdirSync(dir, { recursive: true }); - 发起网络请求client/client.js:49
const r = await fetch(API + p, { cache: 'no-store', ...init }); - 写入剪贴板client/client.js:826
navigator.clipboard?.writeText(t) - 读取剪贴板client/client.js:1335
text = await navigator.clipboard.readText() - 写入剪贴板client/client.js:1598
await navigator.clipboard.writeText(raw) - 写入剪贴板client/client.js:1671
try { navigator.clipboard?.writeText(stickers?.dir || ''); toast('目录路径已复制') } - 写入本地文件src/config.js:16
if (!existsSync(d)) mkdirSync(d, { recursive: true }); - 读取本地文件src/config.js:16
if (!existsSync(d)) mkdirSync(d, { recursive: true }); - 读取本地文件src/config.js:352
saved = JSON.parse(readFileSync(FILE(), 'utf8')); - 写入本地文件src/config.js:403
writeFileSync(FILE(), JSON.stringify(cfg, null, 2), 'utf8'); - 读取本地文件src/sessions.js:18
const raw = JSON.parse(readFileSync(file, 'utf8')); - 写入本地文件src/sessions.js:26
writeFileSync(file, JSON.stringify(map, null, 2), 'utf8'); - 写入本地文件src/turn.js:34
mkdirSync(dir, { recursive: true }); - 写入本地文件src/turn.js:36
writeFileSync(file, buf); - 发起网络请求src/server.js:69
res = await fetch(url, { - 执行外部命令来自配置/变量src/setup.js:49
child = spawn(cmd, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] }); - 请求外部主机registry.npmjs.orgsrc/setup.js:166
const r = await fetch('https://registry.npmjs.org/qq-bot-roost/latest', { - 发起网络请求src/setup.js:166
const r = await fetch('https://registry.npmjs.org/qq-bot-roost/latest', { - 请求外部主机gitee.comsrc/setup.js:179
const r = await fetch(`https://gitee.com/api/v5/repos/${REPO}/tags`, { - 发起网络请求src/setup.js:179
const r = await fetch(`https://gitee.com/api/v5/repos/${REPO}/tags`, { - 读取本地文件src/setup.js:197
return JSON.parse(readFileSync(join(dirOf(), 'config', 'bot.json'), 'utf8')); - 读取本地文件src/setup.js:241
const files = readdirSync(base) - 读取本地文件src/setup.js:245
const c = JSON.parse(readFileSync(join(base, f), 'utf8')); - 发起网络请求src/setup.js:270
const res = await fetch(url, { - 读取本地文件src/setup.js:296
if (!existsSync(join(dir, 'src', 'main.js'))) return false; - 读取本地文件src/setup.js:297
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); - 读取本地文件src/setup.js:298
return pkg?.name === 'qq-bot-roost' || existsSync(join(dir, 'plugins')); - 发起网络请求src/setup.js:372
const r = await fetch(`${baseUrl()}/api/status`, { signal: AbortSignal.timeout(1500) }); - 写入本地文件src/setup.js:547
mkdirSync(stage, { recursive: true }); - 发起网络请求src/setup.js:562
const res = await fetch(src.url, { signal: AbortSignal.timeout(120000), redirect: 'follow' }); - 写入本地文件src/setup.js:564
await pipeline(Readable.fromWeb(res.body), createWriteStream(tmp)); - 写入本地文件src/setup.js:585
mkdirSync(dir, { recursive: true }); - 删除本地文件src/setup.js:597
rmSync(tmp, { force: true }); - 删除本地文件递归src/setup.js:598
rmSync(stage, { recursive: true, force: true }); - 写入本地文件src/setup.js:616
mkdirSync(botsDir, { recursive: true }); - 写入本地文件src/setup.js:619
writeFileSync(file, JSON.stringify(cfg, null, 2), 'utf8'); - 写入本地文件src/setup.js:623
mkdirSync(join(dir, 'config'), { recursive: true }); - 执行外部命令来自配置/变量src/setup.js:671
child = spawn(process.execPath, ['src/main.js', '--serve', '--no-open'], { - 读取本地文件src/memory.js:165
raw = JSON.parse(readFileSync(file(), 'utf8')) || {}; - 写入本地文件src/memory.js:203
mkdirSync(dataDir(), { recursive: true }); - 写入本地文件src/memory.js:205
writeFileSync(tmp, JSON.stringify(m, null, 2), 'utf8'); - 写入本地文件src/memory.js:206
renameSync(tmp, file()); - 写入本地文件src/journal.js:154
mkdirSync(dirname(file), { recursive: true }); - 读取本地文件src/journal.js:233
let needVacuum = existsSync(vacuumMark); - 写入本地文件src/journal.js:241
writeFileSync(vacuumMark, String(Date.now()), 'utf8'); - 删除本地文件src/journal.js:705
unlinkSync(vacuumMark); - 读取本地文件src/journal.js:738
if (existsSync(file)) { - 读取本地文件src/journal.js:739
for (const line of readFileSync(file, 'utf8').split('\n')) { - 写入本地文件src/journal.js:762
writeFileSync(file, rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : ''), 'utf8'); - 写入本地文件src/journal.js:789
appendFileSync(file, JSON.stringify(normRow(row)) + '\n', 'utf8'); - 写入本地文件src/graph.js:70
mkdirSync(dirname(file), { recursive: true }); - 读取本地文件src/self.js:79
const raw = JSON.parse(readFileSync(file(), 'utf8')); - 写入本地文件src/self.js:92
mkdirSync(dataDir(), { recursive: true }); - 写入本地文件src/self.js:94
writeFileSync(tmp, JSON.stringify(load(), null, 2), 'utf8'); - 写入本地文件src/self.js:95
renameSync(tmp, file()); - 发起网络请求src/embed.js:76
const res = await fetch(`${c.baseUrl}/embeddings`, { - 读取本地文件src/mood.js:146
const raw = JSON.parse(readFileSync(file(), 'utf8')); - 写入本地文件src/mood.js:171
mkdirSync(dataDir(), { recursive: true }); - 写入本地文件src/mood.js:173
writeFileSync(tmp, JSON.stringify(load(), null, 2), 'utf8'); - 写入本地文件src/mood.js:174
renameSync(tmp, file()); - 读取本地文件src/closing.js:120
const raw = JSON.parse(readFileSync(file(), 'utf8')); - 写入本地文件src/closing.js:130
mkdirSync(dataDir(), { recursive: true }); - 写入本地文件src/closing.js:132
writeFileSync(tmp, JSON.stringify(load(), null, 2), 'utf8'); - 写入本地文件src/closing.js:133
renameSync(tmp, file()); - 写入本地文件src/personas.js:148
mkdirSync(dir(), { recursive: true }); - 读取本地文件src/personas.js:151
src = readdirSync(builtin()); - 读取本地文件src/personas.js:161
if (existsSync(f)) continue; - 写入本地文件src/personas.js:163
copyFileSync(join(builtin(), n), f); - 读取本地文件src/personas.js:184
for (const n of readdirSync(dir())) { - 读取本地文件src/personas.js:188
const p = parsePersona(id, readFileSync(join(dir(), n), 'utf8')); - 读取本地文件src/personas.js:199
if (!existsSync(f)) return null; - 读取本地文件src/personas.js:200
const raw = readFileSync(f, 'utf8'); - 写入本地文件src/personas.js:208
writeFileSync(join(dir(), `${clean}.md`), String(raw ?? ''), 'utf8'); - 删除本地文件src/personas.js:216
if (existsSync(f)) unlinkSync(f); - 读取本地文件src/persona-repo.js:40
return JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8')).version || '0.0.0'; - 读取本地文件src/persona-repo.js:224
const j = JSON.parse(readFileSync(syncedPath(), 'utf8')); - 写入本地文件src/persona-repo.js:233
mkdirSync(join(file, '..'), { recursive: true }); - 写入本地文件src/persona-repo.js:235
writeFileSync(tmp, text, 'utf8'); - 写入本地文件src/persona-repo.js:236
renameSync(tmp, file); - 读取本地文件src/persona-repo.js:251
const j = JSON.parse(readFileSync(cachePath(), 'utf8')); - 读取本地文件src/persona-repo.js:338
return existsSync(f) ? hashText(readFileSync(f, 'utf8')) : null; - 读取本地文件src/persona-repo.js:338
return existsSync(f) ? hashText(readFileSync(f, 'utf8')) : null; - 读取本地文件src/persona-repo.js:370
const local = existsSync(f) ? hashText(readFileSync(f, 'utf8')) : null; - 写入本地文件src/persona-repo.js:406
mkdirSync(bdir, { recursive: true }); - 删除本地文件递归src/persona-repo.js:419
rmSync(join(root, d), { recursive: true, force: true }); - 读取本地文件src/update.js:134
if (existsSync(join(p, 'pnpm-workspace.yaml'))) return p; - 读取本地文件src/update.js:152
if (!existsSync(p)) return 'unknown'; - 读取本地文件src/update.js:153
return lstatSync(p).isSymbolicLink() ? 'linked' : 'real'; - 读取本地文件src/update.js:170
return JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8')).version || '0.0.0'; - 读取本地文件src/update.js:263
if (!existsSync(f)) throw new Error(`找不到 ${f},没法加豁免(可能不是 pnpm 装的)`); - 读取本地文件src/update.js:264
const before = readFileSync(f, 'utf8'); - 写入本地文件src/update.js:268
writeFileSync(bak, before, 'utf8'); - 写入本地文件src/update.js:271
writeFileSync(tmp, r.text, 'utf8'); - 写入本地文件src/update.js:272
renameSync(tmp, f); - 写入本地文件src/stickers.js:41
if (!existsSync(d)) mkdirSync(d, { recursive: true }); - 读取本地文件src/stickers.js:41
if (!existsSync(d)) mkdirSync(d, { recursive: true }); - 读取本地文件src/stickers.js:53
for (const n of readdirSync(d)) { - 读取本地文件src/stickers.js:57
if (!statSync(p).isFile()) continue; - 读取本地文件src/speakup.js:201
state = { ...state, ...JSON.parse(readFileSync(file(), 'utf8')) }; - 写入本地文件src/speakup.js:206
mkdirSync(dirname(file()), { recursive: true }); - 写入本地文件src/speakup.js:207
writeFileSync(file(), JSON.stringify(state), 'utf8'); - 写入本地文件src/audit.js:29
mkdirSync(dir, { recursive: true }); - 写入本地文件src/audit.js:44
appendFileSync(join(dir, 'audit.log'), `${JSON.stringify(row)}\n`, 'utf8'); - 读取本地文件src/awake.js:417
state = { ...state, ...JSON.parse(readFileSync(file(), 'utf8')) }; - 写入本地文件src/awake.js:422
mkdirSync(dirname(file()), { recursive: true }); - 写入本地文件src/awake.js:423
writeFileSync(file(), JSON.stringify(state), 'utf8'); - 读取本地文件src/delivery.js:73
const raw = JSON.parse(readFileSync(file, 'utf8')); - 写入本地文件src/delivery.js:100
mkdirSync(dirname(file), { recursive: true }); - 写入本地文件src/delivery.js:102
writeFileSync(tmp, JSON.stringify(m, null, 2), 'utf8'); - 写入本地文件src/delivery.js:103
renameSync(tmp, file); - 读取本地文件src/lease.js:52
const j = JSON.parse(readFileSync(file, 'utf8')); - 写入本地文件src/lease.js:70
if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - 读取本地文件src/lease.js:70
if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - 写入本地文件src/lease.js:72
writeFileSync(tmp, JSON.stringify(all, null, 2), 'utf8'); - 写入本地文件src/lease.js:73
renameSync(tmp, file);
扫描于 2026/11/2(1 天前) · 范围 入口闭包 + 全包扫描 · 来源 npm 发布包
涉及外部主机:registry.npmjs.org、gitee.com
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
最近更新
在 GitHub 查看全部 →v0.10.222026-11-01
· 用量面板:这个群到底花了多少 token
面板「上下文」页新增「05 用量」:近 7 天的计费 / 输出 token、缓存命中率,以及最费的三个会话。按「天 × 会话」记在 data/usage.json,只存数字不存内容。
不折算金额——订阅制或免费额度下算出来的美元数不是账单,显示出来只会误导。
几个容易错的地方都处理了:DSH 的 tokenUsage 是会话累计值,直接累加会指数膨胀;压缩后累计归零按全量记;读数落盘,重启不会重复计数;压缩关掉时也照样量(否则关压缩 = 统计静默失效)。
· 四段耗时:知道慢在哪一段
一条消息穿过四段,现在前三段都能看到:
| 段 | 从哪到哪 |
|---|---|
| 排队 | 内核收到事件 → 插件拿到 |
| 准备 | 插件准备 → 宿主开始处理 |
| 生成 | 宿主这一轮(模型 + 落盘) |
面板「最近一轮」显示 排队 3ms / 准备 12ms / 生成 1830ms。缺时间点显示 — 而不是 0,时钟回拨夹到 0。第四段(回发)在 ai-chat 那边,宿主看不到,不假装有。
· 主干开始拆(阶段 8 前两刀)
applyMemoryMarkers(190 行)和 journalAdd 各自成模块,依赖从参数进来。这两块以前零测试——因为闭包吃遍了 memory/journal/self/closing/mood,想测就得起整个宿主。现在补了 16 项行为测试,拆分前后逐行 diff 为 0。
v0.10.212026-10-31
· 记忆能改口了
新增 >:同槽位取代只认字面相似(0.75),「住在杭州」→「搬到上海」这种它抓不到。现在模型可以说清楚在纠正哪句,宿主按片段定位旧事实并打 until(不删,面板还看得见历史)。命中 0 条或多条都不猜。
· 每条消息有链路 id
进站生成 traceId,每轮日志前缀 [trace:xxx],lastTurn 和面板概览都能看到。用户说「刚才那条没回」,拿这个 id 就能定位那次请求。
· 面板鉴权只认宿主 Peer
/quick 删掉了 body.actor 判权——那是调用方自己填的,写 {bucket:owner} 就能自称主人。现在只认 fence() 从 connection.admit() 拿到的 Peer。写操作落 data/audit.log(字段白名单,不记参数内容)。
· 标记不再漏到群里
全角冒号(>)、未闭合标记、下游标记(image/voice/button/say/silence)三种漏法都修了;applyMemoryMarkers 补了 15 项测试。
· 记忆工具重名要说清楚
注册前先读 registry.schemas(),命中同名工具直接返回「工具名被占用:memory_search」,不再等 register 抛一句含糊的 already registered。
v0.10.202026-10-30
这一版修的是「面板上说得好好的,实际没生效」那一类毛病。两个问题都是从代码里查出来、再用真实配置复现过的。
· 一、17321 老是被占?其实是它从来没被尝试过
面板长期挂着「17322(配置 17321,被占了)」,可 Get-NetTCPConnection -LocalPort 17321 一条记录都没有,net 直接 bind 成功。
问题出在候选端口的顺序:candidates(want, lastActual) 把上次实际用的排在配置值前面。于是只要历史上因为任何原因(旧实例没退干净、手工改过配置)漂到过 17322,之后每次启动都直接绑它并成功,17321 一次都不会被尝试 —— 而且只会一路往后爬,17323、17324……
面板那句话也是自己脑补的:它只比较 wantPort !== actualPort 就写死「被占了」,既没问过系统,也没问过服务端到底撞没撞 EADDRINUSE。
改成 want 优先、lastActual 第二:配置端口空着就回它,真被占才退到上次那个,书签照样有效。面板改说「已顺延」,不再替用户断言原因。
已经漂走的:重启 DSH 就会绑回 17321,ai-chat 的地址由 syncAdapterPort 自动同步,不用手改。
· 二、「配置全文」的五个静默失败
设置页那个直接编辑 JSON 的框,承诺是「改什么就是什么」,实际有五处对不上。
· 删掉的字段删不掉。saveConfig 是 merge(loadConfig(), next),只遍历你提交的 key —— 没提交的从磁盘旧值补回来。实测删掉 memory.deepEvery 和整个 workspace 段,保存后原样还在。现在基线换成 DEFAULTS,删掉 = 回落默认值。
· tokenSet 污染配置文件。它是 publicConfig 为了让面板知道「密钥设过没有」加的展示字段,但面板从来没读过它,而它会被原样提交回来写进 config.json,并且写进去就删不掉(merge 会从旧值补回)。现在 publicConfig 先裁剪再打码。
· 提示存好了,磁盘没改。JSON.parse('null' | '[]' | '"abc"' | '123') 都能过 parse,而 merge 见到非对象直接返回原配置,界面照样弹「存好了」。现在前后端都拦,返回 400 并说清该传什么形状。
· 端口填错也能存。填 "abc" 保存成功,然后 listen('abc') 抛 EACCES,而顺延逻辑只认 EADDRINUSE —— 服务直接起不来,面板只有一句 permission denied abc。现在校验 1–65535,并说清范围。
· 服务端的警告被吞。端口换了但 ai-chat 没同步时,服务端把原因放在 message 里,面板成功分支却固定显示「存好了(下一轮生效)」。现在透传服务端 message。
· 三、顺手修的
· 只有端口或密钥变了才重启监听。以前无条件 startServer():改一个记忆条数也会关掉再重建收消息服务,那几百毫秒里 ai-chat 推过来就是 connection refused,端口还会重新 bind 一次。
· 保存后回填 draft。服务端会做归一化(补默认值、删展示字段、升级旧默认值),不回填的话你看到的是一份并不存在的配置,下次改别的字段就基于它继续改。
· 分节说明补齐 closing / qqTools / update 三段;测试也加了反向断言 —— 以前只查「前端列的 ⊂ 后端有」,后端新加的段漏列永远抓不到。
· 段名点击改成直接定位选中。原来提示「在下面的配置全文里搜」,但浏览器的页内查找不搜 textarea 的内容,按提示去搜根本搜不到。
· 四、一个差点带出去的回归
saveConfig 基线换成 DEFAULTS 之后,/config 里那句 if (token === '****') delete body.server.token 就变成了主动提交「没有 token」 —— 真密钥会被默认空串顶掉。以前基线是磁盘配置,delete 之后 merge 会保留真值,所以那句话在当时是对的。
实测确认会被冲掉,改成显式回填,并加测试钉死。没有跑测试就交付的话,保存一次配置密钥就没了。
· 五、验证
测试 667 → 675。三次变异验证都抓到回归:基线换回 loadConfig、去掉裁剪、密钥改回 delete,三次都红。
端到端用真实配置的副本跑过:面板看不到 tokenSet、磁盘上的清掉了、其余字段逐项一致、删段回落默认、persona.byChat 与数组都保住。
pruneTo 有两个必须的特
数据来自仓库的 GitHub Release,可能与插件实际版本不一致。
README
由 DeepSeek 最新模型翻译生成dsh-roost
ai-chat 插件的 DSH 宿主:QQ 消息推过来,交给 DSH 会话回答,再把回答送回去。
让 QQ 机器人从「只会聊天」变成「能用 DSH 的全部能力」 —— 工具、记忆、上下文压缩、主动搭话,都归它。
📚 目录
- 它是什么(以及不是什么)
- 特性
- 安装
- 一键接入
- 权限:AI 知道是谁在说话
- 面板
- 配置
- 目录结构
- 和 Aurabot 的对比
- 已知限制
- 开发与测试
- 许可
它是什么(以及不是什么)
QQ ⇄ Roost 内核 ⇄ ai-chat 插件 ⇄ POST /inbound ⇄ 本插件 ⇄ DSH 会话 ⇄ DeepSeek ⇄ POST /reply ⇄
它不碰 QQ。 收消息、整形(语音 ASR / 引用 / 合并转发)、发消息、切段、防吵(冷却 / 配额 / 认领)全在 ai-chat 那边。这边只做三件事:
1. 起一个固定端口的 HTTP 服务,等 ai-chat 推消息
2. 把消息喂给对应的 DSH 会话,等 AI 说完
3. 把回复发回 ai-chat 给的地址
为什么要它:ai-chat 直连模型时,AI 只有「聊天」这一件事。
展开完整文档(README 剩余 1 节)
dsh-roost
ai-chat 插件的 DSH 宿主:QQ 消息推过来,交给 DSH 会话回答,再把回答送回去。
让 QQ 机器人从「只会聊天」变成「能用 DSH 的全部能力」 —— 工具、记忆、上下文压缩、主动搭话,都归它。
📚 目录
- 它是什么(以及不是什么)
- 特性
- 安装
- 一键接入
- 权限:AI 知道是谁在说话
- 面板
- 配置
- 目录结构
- 和 Aurabot 的对比
- 已知限制
- 开发与测试
- 许可
它是什么(以及不是什么)
QQ ⇄ Roost 内核 ⇄ ai-chat 插件 ⇄ POST /inbound ⇄ 本插件 ⇄ DSH 会话 ⇄ DeepSeek ⇄ POST /reply ⇄
它不碰 QQ。 收消息、整形(语音 ASR / 引用 / 合并转发)、发消息、切段、防吵(冷却 / 配额 / 认领)全在 ai-chat 那边。这边只做三件事:
1. 起一个固定端口的 HTTP 服务,等 ai-chat 推消息
2. 把消息喂给对应的 DSH 会话,等 AI 说完
3. 把回复发回 ai-chat 给的地址
为什么要它:ai-chat 直连模型时,AI 只有「聊天」这一件事。挂到 DSH 上之后,它能用 DSH 的工具(文件、命令)、三层记忆、上下文压缩——这是 adapter: dsh 唯一的价值,也是它比直连贵的地方。
特性
- 🗨️ 一个 QQ 会话 = 一个 DSH 会话:断线重连、重启之后映射还在,会话不丢
- 🧭 一键接入:六步向导 + 「一键就绪」,Roost 本体、ai-chat 插件、适配器配置都能自动装
- 🔐 逐条权限判定:每一句都告诉 AI「谁在说、什么档位」,并按档位收本地工具(不是整群一刀切)
- 🧠 三层记忆:跨群/本群事实 + 日记(三级衰减 + 关键词/向量检索)+ 知识图谱(实体·关系·结束时间)
- 🛠️ 记忆工具:给模型 memory_search / memory_note 两把「拉」的手,能主动翻旧账
- 🌙 凌晨巩固:打分、升降级、到期压缩、抽三元组、提取约定,全在凌晨做,不花白天的钱
- 🌅 觉醒:约定/想你/随性三类机会打分,到点「问她要不要说」,不是到点硬发
- 💬 对话收尾:她判断这段聊完了就记一句「收在哪 / 下次怎么接」——不发言、不动记忆。下一轮接得上,刚说完晚安 90 分钟内也不会又冒出来(只做私聊)
- 🛠️ 发言走工具:qq_send / qq_close 由模型自己调——分几段说、说完要不要退场、一句都不说,都它定。调几次就是几条独立消息(不是拼成一条),不再是一整段文本被动切段
- 📣 能 @ 人:qq_send 的 at 参数(填 sender 就是刚说话的人,也可以填 openid)。群里每条消息头带 [昵称 (openid)],标签由宿主拼——模型不碰那串 id 的语法,抄错了也没机会发出去。私聊 / 非第一段 / markdown 关着时会自动降级并告诉模型为什么
- 🗜️ 上下文压缩:到阈值(群 85% / 私聊 75%)开新会话 + 摘要开场,旧会话保留可回查;压之前那一刻的缓存命中率记在面板和日志里,阈值该往上还是往下调用它判断
- 📱 侧栏面板:九页仪表盘,状态、记忆、人格、排期、日志全在 DSH 里看
- 🧬 人格四段式:人格文件分性格 / 开场白 / 说话示例 / 世界书四段。世界书一行一条 触发词 | 内容,命中才注入;开场白给主动搭话一个语气锚;近处指令贴在离历史最近的位置,聊到几十轮人设也不被冲淡
- 🔄 人格库一键更新:内置人格放在仓库的 personas/.md,改完推个 commit,面板点一下就拿到——不用发版。只覆盖没改过的,你改过和自建的一个不碰
- ⬆️ 本体自更新:设置页顶部查最新版、一键装。走 DSH 自己的 pluginManager(失败会回滚 package.json + pnpm-lock.yaml),装完提示重启。开发机(符号链接装的)自动禁用
- 🎭 双人格 preset:私聊(roost-private)/ 群聊(roost-social)各一套提示词
安装
DSH 插件市场里输入包名:
dsh-roost
或命令行:
bash
dsh plugin --profile web add dsh-roost
前提:DSH 桌面版 / Web 组合(需要 sessionController 和 agents)。
Roost 和 ai-chat 不用提前准备——「接入」里会装。
从源码装也行: bash
git clone https://github.com/tdcq9594/dsh-roost.git
一键接入
装好插件后,侧栏「Roost → 接入」是一条七步路径,能自动的都自动做掉:
| 步 | 做什么 | 自动? |
| 1 | 装 Roost(下载解压,零依赖,不用 npm install) | ✅ 点一下 · 已装过可点「已装过?」填路径绑定 |
| 2 | 启动 Roost | ✅ 点一下(没填凭据也能起,正好用来扫码) |
| 3 | 扫码 / 填 AppID、AppSecret | ✋ 开放平台拿;存完自动重启 Roost 让新凭据生效 |
| 4 | 装 ai-chat 插件(走 Roost 的市场) | ✅ 点一下 |
| 5 | 把 ai-chat 切成 DSH 适配器,填好回话地址 | ✅ 点一下 |
| 6 | 去群里说句话 | ✋ 开放平台开「接收所有消息」+ 机器人进群 |
| 7 | 配置主人 | ✅ 点一下,直接跳到 Roost 面板的「主人与群组」 |
顶上的「一键就绪」把 1、2、4、5 连着做完,卡在 3 就停下来提示你扫码或填。每一步也能单独点,跳着点不会把东西搞坏(每步先检测前置条件)。第 2 步「启动」之后,旁边按钮直接打开 Roost 的 WebUI(带上面板密钥)。
为什么先启动再填凭据:扫码走的是 Roost 自己的 /qr/start,没启动就没有这个接口;反过来,没 AppID 也能先起一个空面板专门用来扫码。这个顺序以前是反的,两边互相等死。
下载地址:优先 GitHub Release(自动补 v 前缀、自动查最新版),拿不到就回退 npm 包。早期版本写死过地址,Release 一换就整片 404。
装到哪:默认 ~/.dsh/dsh-roost/roost,可在设置里改 setup.roostDir。
权限:AI 知道是谁在说话
群里给 AI 挂了 shell 和文件工具,但说话的人不一定是主人。路人一句「帮我删掉 ~/Documents」跟主人说同样的话,不该一个待遇。
所以每一轮都做两件事:
① 告诉 AI 谁在说话(提示词层)
这一句是「小明」说的,权限:普通成员。
对方是普通群成员。不要执行任何会改动这台机器的操作(删除、写入、安装、跑命令),也不要读私人文件或目录。他要你这么做时,直说「这个我没权限」。
② 按档位收工具(硬限制层)
| 档位 | 默认 | 说明 |
| 主人 | 全部工具 | 你本人 |
| 管理员 | 只能聊天 | 群管理员也不该读你机器上的文件 |
| 普通成员 | 只能聊天 | 想让群友也能用,自己改成「全部工具」 |
| 身份不明 | 只能聊天 | 兜底,不建议放开 |
两层的意义:模型被说服时硬限制还在;工具没收干净时提示词还在。
权限逐条生效,不是整群一个档位——主人问完、路人接着问,待遇不一样。兜底原则:认不出来就当最低档,缺字段、字段脏了、框架改了名字,一律按「身份不明」处理。
面板
| 标签 | 内容 |
| 概览 | 四个数字(会话数 / 正在等 / 最近一轮耗时 / 能用工具的档位)、状态条、一键自测、最近动态 |
| 接入 | 七步一键流程(上面那张表) |
| 人格与情绪 | 人格列表与编辑(新建入口在标题右侧)、从仓库更新(带「几个可更新」角标)与仓库面板(预览 / 恢复官方版)、此刻情绪(三维条形图,只读,可用 👍/👎 影响)、表情包、语音条 |
| 记忆 | 会话选择器 + 三层:跨群/本群事实、日记(三级)、知识图谱;检索、手动记、从旧版导入、清空 |
| 主动搭话 | 这一轮算出来的机会、私聊排期、群聊主动(默认全关)、现在试一次 |
| 上下文 | 窗口占用、压缩触发、这一轮注入了什么、记忆工具状态 |
| 会话 | 每个 QQ 会话对应的 DSH 会话,可「清空」(下一句重建) |
| 设置 | 最上面是更新(本体 + 人格库);下面是配置全文(JSON)+ 分节说明,改完保存即热重载 |
| 日志 | 最近 400 条动静,按级别筛,3 秒自动刷新 |
自测不经过 QQ,直接跑一轮 DSH 对话,验的是「会话建得起来、AI 回得来、回复取得回来」——链路里最容易坏的那段。QQ 那半边(收消息、发消息)它测不到,那部分出问题看日志。
配置
存在 ~/.dsh/dsh-roost/config.json(可用 DSH_ROOST_DATA 改目录),面板「设置」页直接编辑。
jsonc
{
"server": {
"port": 17321, // ai-chat 的「DSH 地址」默认指向这里,两边要一致 "token": "" // 共享密钥;留空不校验(只绑本机时可行) },
"chat": {
"turnTimeoutSec": 150, // 要小于 ai-chat 那边的 180 秒
"upstreamRetry": { // 上游 502/429/超时时的补重试
"enabled": true,
"attempts": 1, // 补几次(1 = 失败后再补 1 轮,总共 2 轮) "delayMs": 3000, // 补之前等多久 "budgetSec": 170 // 整轮总预算,必须小于 ai-chat 的 180 } },
"workspace": {
"dir": "" // QQ 会话挂哪个工作区:留空=单开 <数据目录>/workspace, // __default__=用 DSH 默认工作区,其它值=自定义目录 },
"permissions": {
"owner": "all", // 主人:全部工具 "admin": "chat", // 管理员:只能聊天 "member": "chat", // 普通成员:只能聊天 "unknown": "chat" // 兜底:认不出身份只给聊天 },
"setup": { "roostDir": "", "version": "v0.8.6", "registration": "qr" },
"memory": { / 日记寿命、注入条数、多少轮全量重注、记忆工具开关、向量检索 / },
"compact": { / 群聊/私聊两档压缩阈值、开场带几条原话 / },
"awake": { / 清醒时段、机会分门槛、群白名单 / },
"speakUp": { / 私聊排期:间隔/免打扰/每日上限 / },
"persona": { "current": "default", "byChat": {} },
"stickers": { "enabled": true, "dir": "" },
"channel": { "current": "qq" }
}
两边的地址要对上:ai-chat 的「DSH 地址」= http://127.0.0.1:<port>。
目录结构
src/
host.js 插件入口:起服务、会话映射、面板接口、记忆工具挂载 server.js HTTP /inbound(鉴权、立刻 200、回话) inbound.js payload → 给 AI 看的一段话 turn.js 跑一轮:投递 + 从 session/event 等结果 sessions.js chatId → DSH 会话(存盘) permissions.js 权限:谁在说话、能用什么工具 setup.js 一键流程:下载解压、写配置、起停、装插件、配适配器 config.js 配置读写(默认值 + 合并) memory.js 三层记忆第一层:跨群/本群事实 journal.js 日记:三级衰减 + FTS5 关键词 + 可选向量检索(RRF 融合)
graph.js 知识图谱:实体 / 关系(双时态) / 原始记录
embed.js 可选向量:只接一个 /embeddings 端点,不配就纯关键词 tokenize.js 中文切词(索引侧存二元组,FTS5 对中文是废的) consolidate.js 凌晨巩固:打分 → 升降级 → 到期压缩 → 抽三元组 → 提取约定 personas.js 人格库:本地 personas/.md 的读写(正文分四段) persona-repo.js 人格仓库:拉 index.json、一键更新、恢复官方版、静态扫描 update.js 本体自更新:查最新版、装法检测(符号链接禁用)、豁免 24 小时冷却 version.js 版本号比较(三处共用,按数字比不按字符串比) awake.js 觉醒:约定/想你/随性三类机会纯函数打分 compact.js 上下文压缩:读占用 → 开新会话 + 摘要开场 channel.js 通道抽象(以后接别的平台时切这里) registration.js 接入方式抽象(扫码 / 手动填) lease.js 端口租约:端口被占时自动换 sync-retry.js 「等一下再试」的退避调度(Roost 比我们晚起时用来补同步 ai-chat 地址) tools.js 记忆工具(memory_search / memory_note)定义与挂载 client/ client.js 侧栏面板(React,沿用 DSH 主题变量) cordis.patch.yml 两档 agent preset(私聊 / 群聊) personas/ 内置人格(8 份)+ index.json(生成物,sha256 清单) scripts/ 生成人格索引、dry-run、同步 GitHub docs/ 设计文档、调研、和 Aurabot 的对比
核心技术点:DSH 没有「发一句返回一句」的接口,是事件驱动的。turn.js 监听 session/event,收 assistant/message(可能来多次,取最后一次)和 turn/end(reason.kind === 'completed' 才算说完)。
和 Aurabot 的对比
角色设计参考了 Aurabot(dsh-aurabot-qq):一个 QQ 会话对一个 DSH 会话、人格独占系统提示词、安全默认、按会话挂东西、用 agent.followup + sessions.flush 投递、从 session/event 取回复。
传输层完全不同:Aurabot 走 OneBot(SnowLuma,hook 本机 QQ 客户端),这里走 ai-chat 插件(Roost 官方接口)。所以 Aurabot 里那批 OneBot 特有的东西(数字 QQ 号、base64 语音段)这里一个都不需要。
一句话:传输层我们更稳(官方接口),对话体验它更成熟。 只想用现成的,先去试试 Aurabot。
已知限制
- 只支持 QQ(channel.current = 'qq'),通道抽象已备好但还没接第二个平台
- 语音靠官方 ASR(asr_refer_text),没有本地语音模型;TTS 音色由 ai-chat 设置,面板只读
- 面板是 JSON 直编辑配置(不是逐字段表单),代价是要看得懂结构——所以配了分节说明
- 模型服务挂了插件救不了:DeepSeek 网关 502 时,补重试能救回一部分,但网关整体挂掉时只能等它自己恢复。这种时候插件回一句「模型服务那边暂时抽风了」,不会把内部报错糊给你
开发与测试 bash
npm test
675 条测试,覆盖整形、会话映射、HTTP 契约(立刻 200 / 鉴权 / 健康检查 / SSRF 防护)、一轮的状态机、权限分档与兜底、记忆三层、觉醒打分、压缩、通道/接入抽象、工具定义与权限(含 at 参数的四条降级路径)、上游错误分类与补重试、会话类型标签(群 / 私聊)、配置保存与端口候选顺序、前端九页空数据渲染。
CI(GitHub Actions,Node 22 / 24 矩阵)跑同样这些,外加三件单测覆盖不到的:
- node --check 全量语法检查(TDZ 这类运行时错它查不出,但拼错、少括号能拦住)
- scripts/dry-run.mjs 用假 ctx 真跑一遍 apply(),确认插件能加载、路由能注册
- npm pack --dry-run 检查 files 字段没漏 src/host.js / client/client.js——打包漏文件,用户装上去就是坏的
许可
沿用 Roost 的许可:永久免费,禁止任何形式的收费与商业使用。全文见 LICENSE。预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
tdcq9594/dsh-roost
DSH 插件:让 AI 通过 Roost 住进 QQ —— 官方接口,不用 hook 客户端
相关插件 · 平台集成
智能体工程课walkinglabs/learn-harness-engineering
一门项目制课程,教你搭建环境、管理状态、做验证、加控制机制,让 AI 编程智能体稳定干活。
★ 21,155已汉化DSH
71.7
视频翻译配音liuzhao1225/YouDub-webui
开源视频本地化工作台:导入视频,识别、翻译并逐句克隆原声,生成字幕和配音成片。
★ 5,792已汉化Cordis
71.4
失败经验库Ikalus1988/MisakaNet
📚 零依赖、由 git 支撑的微课程库,让 AI 智能体异步分享并检索经过验证的调试经验。| https://misakanet.org
★ 550已汉化DSH
71.2
桌面智能体工作区freestylefly/wesight
WeSight 是开源桌面控制台,面向本地 AI 智能体。它帮你安装或复用 Claude…
★ 933已汉化MCP
71
IM 机器人接入xmanrui/dsh-im
让 DeepSeek Harness 触手可及 Connecting DeepSeek Harness
★ 3,110已汉化DeepSeek 客户端
70.9
中国平台研究引擎Jesseovo/last30days-skill-cn
给 AI Agent 的中文互联网研究 Skill:检索 8 大平台最近 30…
★ 2,031已汉化DSH
70.7
这是你的仓库?认领条目