npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

dsh-roost

v1.0.5

Published

ai-chat 插件的 DSH 宿主:把 QQ 消息交给 DSH 会话回答,带一键接入

Readme

dsh-roost

ai-chat 插件的 DSH 宿主:QQ 消息推过来,交给 DSH 会话回答,再把回答送回去。

npm version npm downloads CI GitHub stars DSH plugin Node License

让 QQ 机器人从「只会聊天」变成「能用 DSH 的全部能力」 —— 工具、记忆、上下文压缩、主动搭话,都归它。


📚 目录


它是什么(以及不是什么)

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_file 工具,路径直接给(想收紧就开 sendFile.restrictPaths)。发之前先问对面支不支持——老版本 ai-chat 会拒,不做「发你了」却什么都没有的空头承诺。一轮一个、200MB 上限,超过 5MB 自动分片
  • 📣 能 @ 人:qq_send 的 at 参数(填 sender 就是刚说话的人,也可以填 openid)。群里每条消息头带 [昵称 (openid)],标签由宿主拼——模型不碰那串 id 的语法,抄错了也没机会发出去。私聊 / 非第一段 / markdown 关着时会自动降级并告诉模型为什么
  • 🗜️ 上下文压缩:到阈值(群 85% / 私聊 75%)开新会话 + 摘要开场,旧会话保留可回查;压之前那一刻的缓存命中率记在面板和日志里,阈值该往上还是往下调用它判断
  • 📱 侧栏面板:十一页仪表盘,状态、记忆、人格、能力(谁能用哪些工具)、排期、账号迁移、日志全在 DSH 里看
  • 🧬 人格四段式:人格文件分性格 / 开场白 / 说话示例 / 世界书四段。世界书一行一条 触发词 | 内容,命中才注入;开场白给主动搭话一个语气锚;近处指令贴在离历史最近的位置,聊到几十轮人设也不被冲淡
  • 🔄 人格库一键更新:人格放在独立仓库 dsh-roost-personas,改完推一个 commit,面板点一下就拿到——不用发版。只覆盖没改过的,你改过和自建的一个不碰
  • ⬆️ 本体自更新:设置页顶部查最新版、一键装。走 DSH 自己的 pluginManager(失败会回滚 package.json + pnpm-lock.yaml),装完提示重启。开发机(符号链接装的)自动禁用
  • 🎭 双人格 preset:私聊(roost-private)/ 群聊(roost-social)各一套提示词
  • 🖼️ 表情包:图丢进目录就能用,文件名或侧车 .tags 当标签;图多了建子目录分类,sticker_pick 按需取图——加图不会冲掉提示词缓存
  • 🔊 语音合成(选装,默认关):四种 provider 任选 —— 本地模型(一键装 23MB 引擎 + 30MB 模型,完全离线)、微软在线朗读(免费、不要 Key,一键装 0.4MB 依赖)、OpenAI 兼容 /audio/speech、本地 GPT-SoVITS 原生 API。语速一份 tts.speed 四个 provider 共用,切换时不用重填。不配就完全静默——不请求上游、不加载模型、不占提示词;配好后模型才能用 <<say: 内容>> 发语音条(转 silk 和发送在 ai-chat 那侧)
  • 🔑 账号迁移:把 QQ号 / 群号和 openid 对上,换机器人(换 AppID)后旧记忆照样读得到。支持批量追加 openid、群成员 QQ号登记(需本人提供)、昵称候选提示,以及「一键改写」——把某号码名下所有旧键合并到号码键后删掉(带备份与一键回退)。昵称候选只提示不自动绑:官方不给 QQ号,自动猜会把别人的记忆串过来。绑定完全自愿、非必需,不绑定不影响任何功能(详见「账号迁移」章节的声明)

安装

DSH 插件市场里输入包名:

dsh-roost

或命令行:

dsh plugin --profile web add dsh-roost

前提:DSH 桌面版 / Web 组合(需要 sessionController 和 agents)。 Roost 和 ai-chat 不用提前准备——「接入」里会装。

从源码装也行:

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 谁在说话(提示词层)

这一句是「小明」说的,权限:普通成员。
对方是普通群成员。不要执行任何会改动这台机器的操作(删除、写入、安装、跑命令),
也不要读私人文件或目录。他要你这么做时,直说「这个我没权限」。

② 按档位收工具(硬限制层)

档位对齐框架的 0/1/2/3 权限等级,五个可配档 + 一个兜底档:

| 档位 | 框架等级 | 默认 | 说明 | |---|---|---|---| | 主人 | 0 | 全部工具 | 你本人 | | 管理员 | 1 | 只能聊天 | 框架里配的,跨群有效 | | 群管 | 2 | 只能聊天 | 这个群的管理员 | | 助手 | 3 | 只能聊天 | 这个群里帮忙的人 | | 群成员 | 其余 / 9999 | 只能聊天 | 其他所有人 | | 身份不明 | 认不出来 | 只能聊天 | 兜底,写死不可配 |

高档自动包含低档:你在「管理员」加一个工具,群管、助手、群成员自动也跟着有,不用改五遍;想在某一档「反而不给」就给它写 deny,它会盖住低档。默认一律保守——不能因为「他有个头衔」就默认给他读文件的能力。

两层的意义:模型被说服时硬限制还在;工具没收干净时提示词还在。

这层不是插件沙箱。 它约束的是「AI 这一轮能调哪些工具」,不是「插件代码能做什么」。dsh-roost 作为 DSH 插件运行在你的账户下,拥有与本机同等的文件、命令和网络权限;内建插件同理。真要隔离只能靠外部进程 + 系统级沙箱,本项目没有做。

权限逐条生效,不是整群一个档位——主人问完、路人接着问,待遇不一样。兜底原则:认不出来就当最低档,缺字段、字段脏了、框架改了名字,一律按「身份不明」处理。

能力页:在这里配「谁能用哪些工具」

面板第 能力 页把这些全部变成可点的东西,不用手改 JSON:

  • 五个档位各一行「角色」按钮:跟随 / 只聊天 / 能查能看 / 能干活 / 全权,点一下即生效;
  • 逐个工具:一个带搜索的弹窗,列出 DSH 里真实挂着的工具(装了新插件、接了新 MCP 会自动多出来),每个工具五个勾位,危险动作标 🔴;
  • 分群例外:默认按档位,只在要例外的那几个群上配「只能聊天」(硬承诺,不随全局配置漂移);
  • 功能开关:11 项「有没有这个功能」,和上面「谁能用」是两层——这里关掉,谁的调不出来;
  • 次数限制:联网搜索 一天 20 次这种,按工具配、按档位配,超了当场拒绝这一次调用。

启用前先问一遍:capabilities.json 不存在时这一页完全不参与决策,走的是老的 permissions 配置——升级上来行为不会变,点「启用能力策略」才接管。改完下一条消息生效,不用重启 DSH。


面板

| 标签 | 内容 | |---|---| | 概览 | 四个数字(会话数 / 正在等 / 最近一轮耗时 / 能用工具的档位)、状态条、一键自测、最近动态 | | 接入 | 七步一键流程(上面那张表) | | 人格 | 人格列表与编辑、从仓库更新(带「几个可更新」角标)与仓库面板(预览 / 恢复官方版),「我的人格 / 人格仓库」分段切换 | | 能力 | 五个档位各能调哪些工具、逐个工具勾选(弹窗 + 搜索)、分群例外、11 个功能开关、按工具/按档位的次数限制 | | 记忆 | 会话选择器 + 三层:跨群/本群事实、日记(三级)、知识图谱;检索、手动记、从旧版导入、对话收尾、她自己、清空 | | 主动搭话 | 此刻情绪(三维条形图,只读,可用 👍/👎 影响)、这一轮算出来的机会、私聊排期、群聊主动(默认全关)、现在试一次 | | 上下文 | 窗口占用、压缩触发、这一轮注入了什么、记忆工具状态 | | 会话 | 每个 QQ 会话对应的 DSH 会话,可「清空」(下一句重建) | | 账号迁移 | 把记忆里的旧 openid 对上 QQ 号 / 群号,换机器人(换 AppID)后旧记忆照样读得到;扫描只读、群成员绑定、批量追加 openid、昵称候选(只提示不自动绑)、带备份与一键回退的「一键改写」 | | 设置 | 最上面是更新(本体 + 人格库);下面是配置全文(JSON)+ 分节说明与表情包 / 语音条设置,改完保存即热重载 | | 日志 | 最近 400 条动静,按级别筛,3 秒自动刷新 |

自测不经过 QQ,直接跑一轮 DSH 对话,验的是「会话建得起来、AI 回得来、回复取得回来」——链路里最容易坏的那段。QQ 那半边(收消息、发消息)它测不到,那部分出问题看日志。


配置

存在 ~/.dsh/dsh-roost/config.json(可用 DSH_ROOST_DATA 改目录),面板「设置」页直接编辑。

{
  "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": "" },
  "tts": {                  // 选装,默认关;面板「设置 → 语音合成」有专用表单
    "enabled": false,
    "provider": "openai",   // sherpa(本地离线)| edge(微软在线)| openai | gpt-sovits
    "speed": 1,             // 四个 provider 共用的语速,0.5–2,1 是原速
    "baseUrl": "",          // openai: 含版本前缀,如 https://api.openai.com/v1
    "apiKey": "",           // 面板里显示成 ******,原样保存不会覆盖真值
    "model": "",            // openai: tts-1
    "voice": "",            // openai: alloy
    "timeoutSec": 30,
    "maxChars": 300,        // 太长的会被 QQ 语音条截断,不如发文字
    "sherpa": {             // provider=sherpa 时才用;引擎和模型都在数据目录,删目录即卸载
      "model": "aishell3",  // 30MB / 8kHz / 174 个音色
      "modelDir": "",       // 留空 = 数据目录/tts-models/vits-icefall-zh-aishell3
      "sid": 88,            // 说话人编号(0-173),换一个就是换一个音色
      "numThreads": 2
    },
    "edge": {               // provider=edge 时才用;不用填地址和 Key
      "voice": "zh-CN-XiaoxiaoNeural"
    },
    "gptSovits": {          // provider=gpt-sovits 时才用
      "installDir": "",     // 含 api_v2.py 的那层,填了才能在面板一键启动
      "model": "",          // 扫描出来的角色名;留空 = 用 tts_infer.yaml 里已加载的
      "gptWeights": "",     // 手填权重路径,优先于按角色名自动配对
      "sovitsWeights": "",  // 两个都填最稳;只填一个时面板会警告另一半仍是猜的
      "baseUrl": "http://127.0.0.1:9880",
      "apiKey": "",         // 走反向代理时才填;本地原生 API 留空
      "referWavPath": "",   // GPT-SoVITS 那台机器上的路径
      "promptText": "",     // 必须与参考音频逐字一致
      "promptLanguage": "中文",
      "textLanguage": "中文",
      "apiVersion": "v1",   // v1=api.py / v2=api_v2.py
      "endpointPath": "/"
    }
  },
  "channel": { "current": "qq" }
}

两边的地址要对上:ai-chat 的「DSH 地址」= http://127.0.0.1:<port>。 server.token 非空时,dsh-roost 会把它同步到 ai-chat 的「共享密钥」—— 不同步的话 /inbound、/capabilities、/tts 全是 401,而两边都不会主动说「密钥不对」。

本地语音(provider=sherpa):面板点「一键安装」,自动下 23MB 引擎(sherpa-onnx-node)

  • 30MB 模型(aishell3),解压后一共约 230MB,装到 数据目录/tts-runtime 和 数据目录/tts-models。 不进插件目录——DSH 更新插件不会冲掉,卸载就是删这两个目录。
  • 那 230MB 里有 173MB 是 rule.far(多音字规则库)。它让「行长/银行」「2024年」这类读得准,所以留着
  • 解压用系统自带的 tar(Windows 10 1803+ / Linux / macOS 都有)。没有 tar 时会明确报错,不会假装在装
  • GitHub 下不动时,手动下 vits-icefall-zh-aishell3.tar.bz2 解压到 tts-models/vits-icefall-zh-aishell3/ 即可,面板会认
  • 首次加载模型约 5 秒,之后合成 6 秒语音约 130ms。装完会自动预热,平时启动也会在后台预热一次
  • sid 是说话人编号(aishell3 有 174 个),换一个就是换一个音色

GPT-SoVITS(provider=gpt-sovits):面板填一个安装目录,点「一键启动」就行。

  • 自动认目录:找 api_v2.py / api.py、runtime\python.exe,从 tts_infer.yaml 读出当前加载的角色和版本
  • 扫描成下拉:把 GPT_weights_* / SoVITS_weights_* 里的权重按角色名配对,同名的取最新 epoch,做成「角色」下拉;参考音频也扫出来做成下拉。选中的角色在服务就绪后通过 /set_gpt_weights + /set_sovits_weights 切过去,不改你的 tts_infer.yaml
  • 自动挑端口:从 17860 往上试绑。不要用 9880——它落在 Windows 保留段 9861–9960 里,绑上去是 WinError 10013
  • 就绪后自动把 baseUrl / endpointPath / apiVersion 写进配置,不用手抄
  • 参考音频只在你没填的时候才替你选;填过就尊重原值
  • 语种填「中文」「日文」就行,会自动转成 API 要的 zh / ja。v2 的字段是 text_lang / prompt_lang,v1 才是 text_language
  • 加载模型要一两分钟,期间面板会显示日志尾部;插件重载时会把进程一起带走
  • 选的就是配置文件里已加载的角色时会跳过切换:v2 切一次权重等于重新加载一遍模型,没必要白等一轮。切换失败时面板红字写明「现在实际生效的是谁」,不会显示「正常」却放着别的音色
  • 音色不对时优先手填权重:自动配对靠文件名猜角色名,权重一多(比如十几个)很容易把不同 epoch / 不同角色的 GPT 和 SoVITS 配错一对。填了 gptWeights / sovitsWeights 就以它为准;只填一个时面板会警告另一半仍是自动配对的。另外参考音频也要和模型配套,音色主要由它决定

微软在线朗读(provider=edge):不用填地址、不用填 Key,选一个音色就能用。

  • 依赖是三个纯 JS 包(约 0.4MB),点「一键安装依赖」装到 数据目录/tts-edge/node_modules——不进插件目录,DSH 更新插件不会冲掉;也刻意和本地模型的 tts-runtime 分开,两边不会互相删
  • 输出 mp3,宿主自己转 silk。要念的文本会发到微软的服务器,介意隐私就用本地模型
  • @andresaya/edge-tts 是 GPL-3.0,只下载到你自己的数据目录,不随插件分发
  • 卸载本地模型后不会再刷「预热失败」:没装是正常状态,装了但加载不起来才是故障

语速:tts.speed 一份配置,四个 provider 共用,范围 0.5–2。

| provider | 映射成 | |---|---| | 本地 sherpa | 推理参数 speed | | OpenAI 兼容 | 请求体 speed(只在非原速时带,避免被中转服务拒) | | GPT-SoVITS v2 | speed_factor;v1 接口没有语速参数,不支持 | | 微软朗读 | rate: '+20%' 这种百分比字符串 |

以前只有 sherpa.speed,现在统一到 tts.speed;老配置会自动迁移,不用手动改。


账号迁移(v1.0.0)

QQ 官方事件只给 openid(群是 group_openid),从来不给 QQ号。换机器人、 换 AppID 之后 openid 就变一次,旧记忆还在盘上却再也没人去找它。 这一页把号码和 openid 对上——读取时合并,存量数据一个字节不动。

绑定完全自愿、非必需:QQ 官方接口不提供 QQ号 / 群号,本项目不会以任何方式获取、推算或抓取;号码只来自用户本人主动提供,或由管理员依据本人提供的号码代为登记(应事先取得本人同意)。不绑定不影响任何功能,未绑定时行为与上线前完全一致;绑定信息仅存本机 identity.json,可随时解绑 / 停用,不上传、不共享,也不用于「认人认群、找回旧记忆」以外的用途。请勿把提供号码设为使用条件,也不要替他人报号;昵称候选只提示不自动绑,正是为了不替用户猜身份。

| 分区 | 干什么 | |---|---| | 01 待迁移 | 本机数据里有记忆、但还没绑过号码的 openid 键 | | 02 已绑定 | Roost 身份表里的号码;给已有号码追加新 openid(旧的一条不删) | | 03 群里的人 | 群发言人的 QQ号。会话键里没有他们(除非开过私聊),数据来自 Roost 的 unresolved.users | | 04 批量追加 | 选一个号码 → 勾多个新 openid → 一次提交。换机器人后让新旧 openid 都指向同一个号码 | | 05 一键改写 | 把某号码名下所有旧键合并到号码键后删掉,把结构改干净 |

昵称候选只提示,绝不自动绑:官方不给 QQ号,uin 是从绑定表反查的, 按昵称 / 群名猜出来的「这个人可能是 10001」没有任何权威依据,绑错会把 别人的记忆串过来。候选只帮你少打字,必须人工确认。

⚠️ 一键改写是唯一会重写记忆数据的操作

前面所有操作都只写 identity.json(叠加层),这一个会真的改 memory.json / sessions.json / journal.sqlite / graph.sqlite。 执行前必须:

  1. 先停掉旧机器人——它还跑着就会继续往旧 openid 写记忆,改完又多出一个键
  2. 点「预览改写清单」,核对要动的文件(绝对路径)、每个键的条数
  3. 勾选「已停掉旧机器人」+ 原样输入「改写」,两样齐了才能执行

执行时自动整文件备份到 D://WorkBuddy//_archive//2026-11-04-账号改写//<时间戳>// (这个盘写不了时退到 数据目录/rewrite-backups),失败自动回退; 成功后「可回退的备份」列表里仍可一键回退。改写和回退成功后页面会重新拉数据, 上面「待迁移」列表里那些键会消失。


表情包

往里丢图,她自己在合适的时候配一张。不发明新机制:发的还是 <<image: 路径>>, 这边只负责让她知道「有哪些图、这些图是什么意思」——她不认识文件名以外的东西。

图放在 <数据目录>/stickers/(面板「设置 → 表情包」能直接改路径、看到字数和张数)。

图分散在好几个目录时,stickers.dir 可以写成数组,合并成一个库:

"stickers": { "enabled": true, "dir": ["D:\\表情包", "E:\\旧备份"] }

面板里能直接增删这几行路径,也能看出哪个路径打不开(填错盘符时不会只显示「共 0 张」)。 两个目录里同名的分类(都有 开心/)会合并计数,根目录的图也都归同一个「未分类」。

最省事:平铺

所有图丢在根目录,文件名就是标签:

stickers/
  开心_猫.png        → 标签「开心」「猫」
  无语-2.gif         → 标签「无语」(序号被丢掉)
  联动壁纸 (1).png   → 标签「联动壁纸」(括号里的序号不算标签)

分隔符认 _ - 空格 ( ) () · 、 , .,纯数字当序号丢弃,最多取 4 个。

文件名不够用时:侧车标签

中文文件名没有分隔符就拆不出词(熊猫开心.png 只能得到一个整串标签), 这时候在同目录放一个同名 .tags 文件,里面只写一行(海妖.png.tags 就对应 海妖.png):

海妖, 立绘; 提到深海、怪物时
↑标签       ↑场合(`;` 之后都算,会原样给模型看)

侧车优先于文件名。这是给图补语义最省事的办法——不用改名,也不动原文件。 标签最多 6 个(走文件名那条路最多 4 个)。注意 .tags 里没有注释语法, 写 # 会被当成标签的一部分。

图多了:分类

在 stickers/ 下建一级子目录,目录名就是分类:

stickers/
  开心/
    _desc.txt      ← 一句说明,写「高兴、庆祝的时候用」
    a.png
  无语/
    b.png
  零散.png         ← 根目录的图归「未分类」,不会消失

有分类之后,提示词只列「分类名 + 张数 + 说明 + 最多 3 个示例文件名」, 具体文件由 sticker_pick 工具按需给——图加到几百张,提示词也不会跟着涨。 每类留 3 个示例是兜底:万一工具没挂上(DSH 版本差异),模型还能直接拼路径发图。

从聊天缓存里同步

群里收到的图已经缓存在 <数据目录>/images/(一张一份,按内容哈希命名)。 面板「设置 → 表情包 → 同步缓存图」能把里面像表情包的搬进 未分类/:

  • 筛选只看尺寸:长边 ≤1024、长宽比 ≤1.8、5KB–1MB。实测 21 张缓存能切掉 2560×1600 截图、1920×1080 壁纸、1469×161 长条,留下 12 张 240×240 这类真表情。
  • 先预览再落盘:弹窗里带缩略图,不对的取消勾选;被规则挡掉的也能手动勾上 (比如一张 1074×947 的近方形图会因为是「边长超标」被跳过,但你可能就是想要它)。
  • 顺手打标:每行两个输入框。填「文件夹」就进那个分类(已有的分类会作为输入提示), 填「标签」就用它当文件名——开心, 猫 → 开心_猫.jpg,同时写一份 .tags 侧车。 两个都留空就是老规矩:进 未分类/,文件名叫 缓存.jpg。同名自动加 _2,不覆盖。 顶部还有一排「批量」,先勾几张再一键套上同一个文件夹和标签。
  • 复制而不是移动——缓存里的路径还被历史对话引用着。同步过的记在 stickers/.synced.json,不会重复收;删了图想重来就点「重置同步记录」。

为什么不做内容识别:permissions 里群成员是 chat 档,allow 没有 read_image, 所以模型看不见群友发的图,只有主人那一轮(owner: all)看得见。要覆盖所有人就得 给群里每个人开放读本地文件——不值。于是这些图没有语义标签,清单里只显示 「缓存同步,等待分类」。真正让它们可用的一步,是把图拖进 开心/、无语/ 这类目录:目录名就是唯一的语义,模型只认它。

她会怎么选

  • 平铺:清单里有每张图的文件名和标签,她自己判断该不该用
  • 分类:先调 sticker_pick("开心") 拿 1~3 个真实路径,再用 <<image: 路径>> 发
  • 两种模式都会告诉她「没有合适的就别发」——宁可不配图,不要为了配图硬凑

同一张图不会连着给两次(按「最久没给过」排序);会降级成文件的图排在后面。

几个硬限制

| 限制 | 为什么 | |---|---| | 超过 200MB 的图不进清单 | 官方硬限,分片上传也发不出去 | | 超过 20MB 的图仍可用,但会标 degrade | 超过软限会被降级成文件发送,群里看到的是附件不是表情 | | 一次最多扫 1000 张 | 扫描是同步 IO,几千张会卡住消息处理;超出部分清单里会写「只扫了前 N 张」 | | 清单有 3 秒缓存 | 丢图进去最多 3 秒后才进提示词 |

发出去是什么样

是普通图片消息,不是 QQ 收藏表情——对方收到的是图,不会进他的「表情」收藏夹。 这是协议层的限制,插件改不了。


目录结构

src/
  host.js        插件入口:起服务、会话映射、面板接口、记忆工具挂载
  server.js      HTTP /inbound(鉴权、立刻 200、回话)
  inbound.js     payload → 给 AI 看的一段话
  turn.js        跑一轮:投递 + 从 session/event 等结果
  sessions.js    chatId → DSH 会话(存盘)
  permissions.js 权限:谁在说话、能用什么工具
  capabilities.js 能力策略:五档栈式继承 + 分群覆盖 + 与工具池求交(纯逻辑,可单测)
  capability-store.js capabilities.json 的读写,每轮观察工具池的新增/失效
  features.js    功能开关:11 项「有没有这个功能」→ 关掉之后该收走哪些工具
  quota.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(生成物)。权威源在独立仓库 dsh-roost-personas
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 直编辑(不是逐字段表单),代价是要看得懂结构——所以配了分节说明。「能力」页那批(档位、功能开关、次数限制、分群例外)已经是可点的,只有 config.json 的其余部分要手改
  • 模型服务挂了插件救不了:DeepSeek 网关 502 时,补重试能救回一部分,但网关整体挂掉时只能等它自己恢复。这种时候插件回一句「模型服务那边暂时抽风了」,不会把内部报错糊给你
  • 表情包发出去是普通图片消息,不是 QQ 收藏表情——对方不能一键存进表情夹(协议层限制)
  • 排队只存内存:进程重启时正在排的那条会丢(inbound 本来就是一次性的);面板暂时看不到队列长度(stats() 已备好)
  • 巩固不进队列:memory.consolidateChat 配成私聊 openid 时,撞上正在跑的一轮仍会失败(次日补跑)

开发与测试

npm test

1357 条测试,覆盖整形、会话映射、HTTP 契约(立刻 200 / 鉴权 / 健康检查 / SSRF 防护)、一轮的状态机、权限分档与兜底(五档栈式继承、只往低档切片、坏配置不让主人丢全权)、能力策略(人格空名单=全禁、群覆盖通配、只能聊天不管主人)、次数配额(固定窗口、幂等去重、计数失败一律放行、默认不限)、功能开关与工具收窄(关掉功能谁都没有)、记忆三层、觉醒打分、压缩、通道/接入抽象、工具定义与权限(含 at 参数的四条降级路径)、工具 schema 是否落在 DSH 支持的子集里(缺 additionalProperties 会注册即被拒)、发文件(路径白名单、通道协商、大小上限、一轮一个)、人格注入预算裁剪与中文 id / 角色卡自报名字、上游错误分类与补重试、会话类型标签(群 / 私聊)、配置保存与端口候选顺序、启动前置检查(实例表里还有活进程就不 spawn、端口漂移时按候选列表探活)、停止的两级终止、前端十一页空数据渲染、人格页合并清单(已装在前 / 未装可直接装)、自更新的返回契约(applied / restart-required / refused、错误码与 pnpm 失败分类)、表情包(标签解析、侧车、分类、挑图去重、扫描上限、多目录合并与「未分类」归并、缓存同步的尺寸筛选 / 标签改名 / 路径 sanitize)、记忆失效(作废不删行)与 L1/L2/L3 三级撤销、会话查询工具 chat_look、排队。

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。