🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

QQ 桥接 tdcq9594/dsh-roost

DeepSeek Harnessv0.10.19
tdcq95944 天前有提交已提供中文文档扫描:含敏感能力

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-roost
npm 源 · dsh-roost 的发布者账号已校验与本仓库一致

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:仅索引
是什么
dsh 原生插件 · platform
装得上吗
静态安装检查通过
未做真实安装
L4 · 真实安装等级说明 →
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 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
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

可执行外部命令读写/删除本地文件读取剪贴板或密钥环境变量写入剪贴板(不计风险)发起外部网络请求
查看扫描证据(共 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.org
    src/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.com
    src/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

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
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。
预览分享卡片长什么样 ↓tdcq9594/dsh-roost
分享 · DeepSeek Harness Hub
tdcq9594/dsh-roost

DSH 插件:让 AI 通过 Roost 住进 QQ —— 官方接口,不用 hook 客户端

这是你的仓库?认领条目

回到顶部