DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

orange-start /

orange-start/dsh-tingxue

Verified

DeepSeek Harness 双模式插件:人格化 QQ 对话(LanceDB 长期记忆 + 自建关系图谱)+ 用完即焚的隔离文件处理会话

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@b4b37a6e

dsh-tingxue · 听雪

DeepSeek Harness 的双模式虚拟生命插件:一个有人格、有长期记忆的 QQ 聊天对象,外加一个用完即焚的隔离文件处理会话。

License Node DSH Plugin Client

听雪是一个跑在 DeepSeek Harness 上的自建插件。它的记忆不依赖任何托管服务——LanceDB 本地文件即库,关系图谱自己实现,语义推理走可插拔的小 LLM。


目录

  • 特性
  • 前置要求
  • 安装
  • 快速开始
  • 配置
  • 架构
  • 会话隔离与模式自愈
  • 记忆服务(HTTP API)
  • AstrBot 群聊对接
  • 开发
  • 路线图
  • 文档索引
  • 贡献
  • 许可证

特性

双模式 聊天模式(日常对话,人格 + 长期记忆,上下文结构对缓存友好)与 agent 模式(隔离的纯文件处理会话,用完即焚,归档进记忆)
全自动切换 全程只有 /agentstart 和 /agentstop 两条指令,自动建会话、自动绑定、自动回绑,不需要手动 /bind
自建记忆库 LanceDB 嵌入式向量库,本地文件即库、零服务器;4 张表(记忆条目 / 实体 / 关系 / 最新信息)
自建关系图谱 实体抽取 → 实体链接 → 三元组关系(带时间窗口与溯源)→ 图遍历增强检索;参考 Graphiti / Mem0 实践
可交互图谱 UI 零依赖 canvas 力导向蜘蛛网:节点聚类、邻居高亮、标签碰撞避免、小地图导航,离线可用
可插拔模型层 embedding 与语义推理抽象成统一接口,换模型只改配置,插件逻辑零改动
图形化配置 29 个配置项做成图形界面,设置侧边栏独立成页 + 插件配置标签页两个入口,含覆盖标记与单点重置
推送隔离 通过 dsh-notifier 的 route:agents 精确分流,其他 DSH 会话的通知与审批不会打扰 QQ
模式自愈 启动自检 + 运行期 reconcile:mode 与 QQ 真实绑定不一致时自动退回聊天模式,不会静默丢掉人格与记忆

前置要求

依赖 版本 说明
Node.js >= 22.19 见 package.json 的 engines
DeepSeek Harness 0.1.1-rc.2 或更高 插件运行宿主
dsh-notifier 0.9.0 + 补丁 QQ 通道。⚠️ 必须钉死 0.9.0 并打补丁,不是普通的 ^0.9.0(见 安装 与 发布形态与安装流程.md §4)
pnpm 任意近期版本 安装 @lancedb/lancedb 时需要解析原生依赖
一个 LLM / embedding 端点 — 默认走 sta1n 供应商,也可切换本地 OpenAI 兼容端点

外部依赖仅两个运行时包:@lancedb/lancedb 与 apache-arrow(版本锁 18.1.0)。


安装

[!IMPORTANT] 先装 dsh-notifier,并把它钉在 0.9.0 + 打上补丁(见下方第 2 步)。这不是可选项——不打补丁,QQ 收文件功能整个不存在(DSH 侧靠补丁拼出的 [文件] 名称\n下载地址: url 标记来下载文件),且超 2000 字会被硬切半句。原因与替代方案见 发布形态与安装流程.md §4。

# 1) 在 DSH profile 目录安装本插件
#    Windows 默认路径:%USERPROFILE%\.dsh\profiles\web
cd ~/.dsh/profiles/web
pnpm add file:/absolute/path/to/dsh-tingxue

# 2) 把 dsh-tingxue 加入 profile 的 package.json
#    "dsh": { "profile": { "bundles": [ ..., "dsh-tingxue" ] } }
#    —— 漏这一步会得到「已安装,未生效:未声明 dsh.bundle,已作为普通依赖安装」

# 3) 重启 DSH 让插件挂载

前置的 dsh-notifier(必须先做):

cd ~/.dsh/profiles/web
pnpm add dsh-notifier@0.9.0          # 必须钉 0.9.0,补丁的行号基于此版本

# 把补丁放进 profiles/web/patches/,然后在 pnpm-workspace.yaml 里接线:
#   patchedDependencies:
#     dsh-notifier: patches/dsh-notifier.patch
pnpm install                          # 让补丁生效

补丁生效的验证:node_modules/dsh-notifier/src/inbound/_qq-segment.mjs 存在,且 src/inbound/message.mjs 里能搜到 parseQQFileAttachments。

[!IMPORTANT] DSH 插件的源码是进程启动时加载、不热重载的。改完源码必须两步走:① 同步到 profile 的 node_modules 副本 → ② 重启 DSH。 cordis.patch.yml 的配置改动会热重载,但 .mjs / client.js 源码不会。 浏览器端同理:__DSH_BOOT__ 是加载 HTML 那一刻注入的,重启 DSH 后还需要硬刷新页面(Ctrl + Shift + R)。


快速开始

装好并重启后,在 QQ 里直接说话即可进入聊天模式。

(直接发消息)          → 聊天模式:有人格、有记忆、四块上下文组装
/agentstart           → 进入 agent 模式:新建隔离会话并自动绑定
(发文件、多轮讨论)      → 文件作为上下文持续处理
/agentstop            → 归档对话到记忆库、删除工作副本、销毁隔离会话、自动回绑聊天会话

两条指令之外没有任何手动步骤——不用 /bind,也不用 /unbind。

发文件:QQ 附件经 [文件] 标记由 pre-step 下载到 dataDir/workcopy/ 并注入上下文。文本文件直接进上下文(单文件上限 5 MB、正文截取 8000 字符);二进制文件只给文件名与大小元信息。聊天模式与 agent 模式都支持。

图谱面板:默认监听 http://127.0.0.1:8765,浏览器打开即可看到实时的实体关系蜘蛛网。


配置

配置方式

有两种,优先级从高到低:

  1. 图形界面(推荐)——DSH Web GUI 的「设置」:
    • 设置侧边栏 →「听雪」(主入口,独占一页)
    • 设置 → 插件 → 插件配置(次入口,卡片形态)
    • 两处入口共用同一个 settings scope,内容永远同步;写入 settings 用户层($DSH_HOME/settings.yaml)
  2. 配置文件——profile 的 cordis.patch.yml,作为 settings 的 base 层保留

界面里标「已覆盖」的字段表示它覆盖了部署配置,可单点重置回落到部署层。写入用 revision 设栅,表单漂移会被拒绝而不是覆盖并发修改。

Host 与浏览器两个半侧各有一份同构的字段表(src/settings/index.mjs 的 SETTINGS_FIELDS 与 client/client.js 的 FIELDS),当前各 29 项,分组与键一一对应。

配置项

配置项 默认 说明
人格与记忆
profilePath '' 听雪档案 txt 路径(人格提示词,由用户自写)
dataDir cwd/.dsh-tingxue 记忆库数据目录(LanceDB 本地文件即库,state.json 也在这里)
recentRounds 10 最近 N 轮滑动窗口(仅在打开「重复注入最近对话」时生效)
injectRecentRounds false 重复注入最近对话。默认关:DSH 会话历史本身已含最近对话,再注入一遍等于同一段话付两次 token(实测约 1.5K/轮)
memoryBudgetTokens 1600 向量记忆检索块 token 上限
latestInfoBudgetTokens 1000 最新信息块 token 上限
模型
modelBackend sta1n 模型后端:sta1n / local / custom
embeddingModel gemini-embedding-2 向量模型(同库单一模型铁律)。设置页可点「选择模型」从端点列表里挑
embeddingDimensions 3072 向量维度,须与模型实际输出一致
llmModel gemini-3.1-flash-lite 语义推理小 LLM(实体抽取 / 摘要)。设置页可点「选择模型」从端点列表里挑
baseURL '' local / custom 时的 OpenAI 兼容 base URL
apiKey '' 留空则回落到 DSH 凭据服务里的 STA1N_API_KEY
双模式命令
agentStartKeyword /agentstart 进入 agent 模式的关键词
agentStopKeyword /agentstop 退出 agent 模式的关键词
fileDeleteScope workcopy 文件删除范围:workcopy 只删工作副本 / keep 一律保留
绑定与推送
channel qq 绑定通道(dsh-notifier 的 channel)
userId '' 绑定用户(dsh-notifier 的 userId)
chatSessionId '' 自愈回绑用的可信聊天会话 id
notifierStateFile '' 留空用默认的 dsh-notifier state.json
routeWorkspace dsh 被静音的默认 workspace
quietOtherWorkspace true 只让听雪自己的消息送达 QQ
qqStatusNotice true QQ 是否显示任务状态提示(🚀 任务开始 / ✅ 任务完成 / ⏹ 任务已中止 / ⏱ 心跳 / ⚠️ 疑似卡住)。关掉后这些状态行不发,回复正文照常送达(回复本体走 turn/end 通知的正文,见 交接文档.md §16.2);❌ 出错通知保留
approvalAllowlistOnly true 审批是否只推 QQ 对话会话。打开后只有被显式放行出站的会话(听雪聊天会话 / tingxue-agent-* 隔离会话)的批准询问发到 QQ,其他 DSH 会话的审批只在桌面弹(需配套的 dsh-notifier 补丁,见 交接文档.md §16.3)
面板与服务
graphDashboardEnable true 是否启用关系图谱面板
graphDashboardHost 127.0.0.1 图谱面板监听地址(默认仅本机 loopback)
graphDashboardPort 8765 图谱面板端口
memoryServiceEnable true 是否启用记忆服务(管家)
memoryServiceHost 127.0.0.1 记忆服务监听地址
memoryServicePort 8766 记忆服务端口

还有若干只在配置文件中生效的字段(routeWorkspaceChannels、provider、model、cwd 等),不暴露在界面上。

生效时机

  • 标了 applies: 'restart' 的命名空间:dataDir、模型、端口这类启动期读取的字段,改动需重启 DSH。
  • recentRounds、injectRecentRounds、记忆 / 最新信息预算这类按次读取的字段,保存后立即生效。
  • qqStatusNotice / approvalAllowlistOnly 保存后立即生效:它们被写成 dsh-notifier 的 prefs:tingxue 状态键,由 dsh-notifier 补丁在每次推送 / 审批时实时读取(500ms 读收敛)。
  • graphDashboardEnable / memoryServiceEnable 等开关由插件启动期读取,需重启 DSH。

架构

项目结构

dsh-tingxue/
├── src/                          # Host 半侧
│   ├── plugin-entry.mjs          # 插件入口:组装各层、接入 DSH 事件管线、注入与推送隔离
│   ├── models/index.mjs          # 模型适配层(embedding + 小 LLM + 模型列表,可插拔)
│   ├── memory/store.mjs          # LanceDB 记忆存储(4 张表)
│   ├── graph/index.mjs           # 关系图谱(抽取 / 链接 / 关系 / 图遍历)
│   ├── graph-dashboard/index.mjs # 关系图谱面板(零依赖 canvas 力导向蜘蛛网)
│   ├── memory-service/index.mjs  # 记忆服务(管家):DSH 唯一写者,HTTP API 供 AstrBot 对接
│   ├── model-catalog/index.mjs   # 模型目录路由:设置页模型选择器问端点有哪些模型
│   ├── context/assemble.mjs      # 聊天模式四块上下文组装
│   ├── context/inject.mjs        # 注入接线:异步瀑布取真值(避开 section.text 同步求值)
│   ├── http/index.mjs            # 共享 HTTP 工具(端口探测 / JSON 读写响应)
│   ├── state/index.mjs           # 双模式状态机(持久化 + 滑动窗口裁剪)
│   ├── commands/index.mjs        # 关键词命令处理(含绑定失败回滚)
│   ├── bind/index.mjs            # dsh-notifier 绑定读写 + 推送隔离 + 通知偏好
│   ├── settings/index.mjs        # Host 半侧:注册 settings 命名空间
│   ├── segment.mjs               # QQ 长消息句子完整分段器
│   └── agent/index.mjs           # agent 模式(文件生命周期、归档)
├── client/client.js              # 浏览器半侧:手写 lazy-CJS bundle(设置界面)
├── test/                         # 单元测试(含 test/patches/ 补丁验证,不随主测试集)
├── astrbot-plugin/               # AstrBot 群聊对接插件(Python)
├── research/                     # 第三方参考材料(不进公开仓库)
├── cordis.patch.yml              # bundle patch(插件行声明)
└── package.json

记忆库表结构(LanceDB)

表 用途 关键字段
memories 记忆条目(向量检索主表) text vector scene identity source createdAt entityIds
entities 实体节点 name type summary vector
relations 关系边(三元组) sourceId targetId relation validFrom validTo
latest 最新信息(文件摘要 / 待办) kind text createdAt

聊天模式上下文组装

顺序固定,前缀稳定以命中缓存:

[1] 听雪档案        —— 从 profilePath 读取,固定不变(稳定缓存前缀)
[2] 向量记忆检索     —— 按当前输入语义检索,有界(默认 1600 token),命中才插入
[3] 最近 N 轮对话    —— 默认关闭(见下方说明)
[4] 最新信息        —— 最近文件摘要 / 待办,有界(默认 1000 token)

关于 [3]:DSH 会话本身就是「全部交互历史的仅追加真源,LLM 消息历史由它派生」(packages/core/session/README.md)。最近对话本来就在会话历史里,插件再塞进 system 一遍 = 同一段话在模型眼里出现两次,实测每轮白付约 1.5K token。因此 [3] 由 injectRecentRounds 守卫,默认不注入;只有换绑到新会话、会话历史不可用时才需要打开。

关于 [4]:组装逻辑(src/context/assemble.mjs)具备该块,但 pushLatest() 目前在插件里没有调用点,实测 71 条请求命中 0 次——属死代码,不是故障。要用需先接上写入侧。

注入走异步瀑布,不走 section.text(重要)

DSH 的 SystemPrompt.section() 要求 text 是同步函数返回 string。但 assemble() 的内部顺序是:

packages/core/system-prompt/src/index.ts:510   同步求值每个 section.text   ← 缓存在这里被读走
packages/core/system-prompt/src/index.ts:532   await system-prompt/assemble 瀑布
packages/core/agent-loop/src/agent.ts:230      preStep 先 await assemble(),再发 agent/pre-step

同步求值结构上必然早于任何异步检索。所以「异步预算写入缓存 + text() 同步读缓存」的做法不是偶发竞态,而是首轮必空、之后恒错位一轮——实测表现就是 system 提示词在 6827(DSH 本体裸大小,一块都没注入)与 13872 之间抖动,人格块命中率只有 42%。

正确做法是 system-prompt/assemble 瀑布(src/context/inject.mjs):它能 await,且返回值是权威装配。section 只注册一个空占位块保住名字与顺序,真值在瀑布里就地组装后就位替换。DSH 自己的 installModelSelection(packages/core/agent/src/model-selection.ts:40)也是这么用的。

实现要点:

点 原因
就位替换,不 push 到末尾 块声明 order: 100,push 会掉到 100–199 的工具指引之后,破坏块顺序
校验 context.agent === agent 瀑布是 scope 继承的,不校验会收到别的会话的装配 → 跨会话串线
按用户输入串缓存 assemble() 每个 step 都跑一次;多步回合(工具调用)不缓存就每步都 embed + 向量检索
未 ready / agent 模式返回 null 且不落缓存键 否则 mode 切回聊天后会一直读到空串
installContextInjection 幂等 有 agent/created 与启动补注入两个入口,重复 section() 会因重名抛错
预热 + 瀑布共用同一次检索 embed + 检索实测约 790ms。在写 lastUserText 的监听器里不 await 地预热,瀑布 await 同一个 in-flight promise——既把延迟藏起来,又不重复检索
缓存带代号,只有最新那次能落缓存 用户连发两条时,先发起的慢检索晚回来会用旧结果覆盖新结果
refreshDisposer 不碰缓存 它注册得比写 lastUserText 的监听器早;在那里 invalidate 会把刚预热好的结果丢掉,白白多检索一次
检索输入与缓存键用 turnInput,不用 lastUserText assistant/message 每个 step 都触发,step0 结束会把 lastUserText 清空(配对写滑动窗口 + 记忆)。若检索用它,step1 起 currentInput 变空 → 记忆块在同一轮里整块消失。这两个变量不能合并:lastUserText 是「待配对输入」(配对后清空),turnInput 是「本轮输入」(整轮不变)

回归测试见 test/inject.test.mjs:它用假 systemPrompt 精确复刻「先同步读 text → 再 await 瀑布」的顺序,因此能真正抓住这个 bug(在旧设计下会失败)。另有并发与多 step 回归:预热与瀑布共用同一次检索、慢检索晚回来不覆盖新结果、同一轮 step1 起记忆块不消失(同轮 system 必须完全一致)。

关系图谱

  • 实体抽取:小 LLM 从文本抽取实体(person / place / thing / concept)
  • 实体链接:实体名归一化 + 向量相似,把新实体关联到已有实体
  • 关系建立:抽取实体间三元组关系,带时间窗口与溯源
  • 图遍历:从种子实体沿关系扩展,用于增强记忆检索(assembleContext 里从命中记忆的 entityIds 扩展 depth=1、limit=5)
  • 交互 UI:graph-dashboard 自绘 canvas 力导向蜘蛛网,零外部依赖、离线可用

图谱面板的工程细节:

  • 性能:拖动节点 / 空白平移时暂停每帧 O(n²) 全对斥力计算(animOn=false 仅静态重绘),松手后短暂恢复模拟让邻域收敛;鼠标与触屏双 handler 均已处理。
  • 视觉:① 按类型聚类(clusterCenters)② hover 邻居高亮 + 非邻居淡出(focusSet)③ 标签碰撞避免(labelRects 包围盒跳过重叠)④ 右下角小地图(标准缩略图模式,点击跳转视口)⑤ 背景网格固定视口,不随缩放平移移动。
  • 两个坑:HTML 模板字符串里的内联 onclick 绝不能用 \' 转义单引号(要用 HTML 实体 ');canvas 顶部有 56px 标题栏,所有事件坐标必须经 canvasOffset() + evtPos() 转成画布相对坐标。

双模式与会话隔离

聊天模式(默认) QQ 一进来就是聊天会话,四块上下文组装,有人格与长期记忆。

agent 模式(隔离会话,用完即焚)

  • 进入:/agentstart → 用 ctx.agents.create() 新建隔离会话(只注入听雪档案,不含聊天记忆与历史)→ 自动绑定。
  • 处理:文件作为上下文持续处理,可多轮。
  • 退出:/agentstop → 归档对话与文件摘要到记忆库 → 删除工作副本 → dispose() 销毁隔离会话 → 自动回绑聊天会话。

会话隔离铁律

session/event 与 agent/inbox/inserted 监听器对所有会话生效,因此必须按 session.id 过滤:聊天模式只在 sessionId === chatSessionId 时写记忆;agent 模式只在 isAgentSession(session) 时补全待归档轮次。两种会话的记忆与上下文完全隔离。

dsh-tingxue-context 段的注册位置

systemPrompt.section 是作用域继承的:注册在插件 root ctx 会对该 profile 下所有会话生效(人格/记忆泄漏到无关会话 = 上下文串线)。因此本插件把它注册在聊天会话自己的 agent 作用域(agent.ctx.get('systemPrompt').section() + agent.ctx.effect),只对聊天会话可见,并随该 agent dispose 自动卸载。

推送隔离

通过 dsh-notifier 的 route:agents 做出口分流:静默默认 workspace(channels: []),再精确放行听雪的聊天会话。插件 init 时写一次,/agentstart / /agentstop 期间动态维护。结果是其他 DSH 会话的 turn/end、审批与错误通知不再广播到 QQ。

审批与远程提问是另一条更容易漏的出口:approval/router.mjs 的 resolveApprovalChannels() 只取 channelTypes、忽略 quiet,而空集还会回落全局广播。配套补丁把判据换成「只推 route:agents 里被显式放行的会话」,见 交接文档.md §16.3 / §16.8。

模型适配层(可插拔)

src/models/index.mjs 把两种能力抽象成统一接口:

embed(texts)        // 文本 → 向量
complete(prompt)    // 小 LLM 语义推理
后端 说明
sta1n(默认) 走 sta1n 供应商的 OpenAI 兼容端点,零部署
local 走本地 OpenAI 兼容端点(ollama / llama.cpp)
custom 完全自定义

接口有三个方法:embed(texts) 文本 → 向量、complete(prompt) 小 LLM 语义推理、listModels() 列出端点当前提供的模型(供设置页的选择器用,见下节)。

换模型只需改配置,插件逻辑零改动。 注意记忆库的单一向量模型铁律:同库内必须模型与维度一致,换 embedding 模型需要全量重嵌入。

设置页的模型选择器

「向量模型」和「语义推理小 LLM」两个字段旁边有一个选择模型按钮:点开询问当前端点提供哪些模型,从真实列表里挑一个填进去(带搜索框,sta1n 的列表有 102 项,靠眼睛翻不现实),不用手敲模型 id。输入框照旧可以直接手填。

数据通路:

环节 位置 说明
客户端弹窗 client/client.js 的 ModelPicker 结构照 DSH「设置 → 模型」的 ModelListEditor:询问端点 → 从候选里挑
HTTP 路由 src/model-catalog/index.mjs POST /dsh-tingxue/models
适配层 src/models/index.mjs 的 listModels() 打 GET <base>/v1/models

两个实现选择值得记下来:

  • 走宿主自己的 webServer(与 GUI 同一个端口),不另开监听端口。因此不存在跨源问题,也不需要额外的鉴权层——和 GUI 同源。DSH 把 llm.discoverModels 钉在 loopback 上,这里用同样的判据(同源 + 回环兜底),因为它同样会拿着一把可能明文的 key 去问端点。
  • 不用 DSH 的 llm.discoverModels seam:那个由 llm-pi-ai 适配器回答,只认它命名空间里声明过的 provider profile;听雪的模型后端是插件自己的配置(modelBackend / baseURL / apiKey),问它只会得到「未知 provider」。这里用听雪自己的适配层问同一条协议,端点、协议、key 的解析方式与真正发请求时完全一致——选择器看到的就是实际会用的那份列表。

请求体是「表单当前显示的值」而非已保存的配置:刚填进去、还没保存的端点或 key 也能立刻试。服务端只读,不写任何配置,apiKey 只用于这一次询问、不存储也不回显。

宿主没有 webServer 服务时路由静默跳过,选择器退化成手填模型 id,不影响其他功能。

API Key 的解析走 DSH credentials 服务,每次 resolve 时重新 ctx.get('credentials'),不能在 init 时同步捕获闭包——init 早于 credentials 挂载时闭包会固定为 undefined,之后每次取 key 都 401。模型适配层与模型目录路由共用 plugin-entry.mjs 里的同一份 resolveApiKey,保证两条路径取到同一把 key。

换 key 只改一处:$DSH_HOME/.credentials.yaml 里的 STA1N_API_KEY(sta1n 后端的 apiKeyEnv)。插件、运行时副本、AstrBot 插件都不需要改——插件经凭据服务取 key,AstrBot 经 HTTP 服务间接使用。改完重启 DSH 生效。

settings.yaml 里 llm-pi-ai.providers.sta1n.models 是模型下拉列表,必须与实际线上列表一致,否则选到已下线的模型会直接 400/404。核对方式:

# 拉线上列表(需要有效 key),与 settings.yaml 的 - id: 条目对比
node -e "fetch('https://cdn.sta1n.cn/v1/models',{headers:{Authorization:'Bearer '+process.env.K}}).then(r=>r.json()).then(j=>console.log(j.data.map(m=>m.id).sort().join('\n')))"

会话隔离与模式自愈

这是本项目历史上踩得最贵的两个坑,均已修复并固化。

症状

state.json 的 mode 残留在 agent,但 dsh-notifier 的 bind:qq:<userId> 实际还指向聊天会话。此时:

  • QQ 消息仍然投递到聊天会话(绑定没变);
  • 但上下文注入在 agent 模式下直接返回空(旧实现是 refreshContext() 置空 cachedContextText,现在等价于 buildContextText() 在 state.isAgentMode() 时返回 null);
  • 结果:人格、记忆、最近对话三块全部静默消失,而"隔离"根本没发生——静默失忆的最坏组合。

真机实测:system 提示词从 11,682 字符掉到 6,827 字符,听雪的块全部消失,QQ 那头毫无提示。

根因

  1. POST /agentstart 路径里,createIsolatedAgent() 无论 setBinding() 成功与否都返回 { ok: true },调用方只检查 result.ok → 绑定没切成也照样 state.enterAgent()。
  2. 「agent 模式就置空注入」是唯一一处"置空"逻辑(旧实现是 refreshContext() 的第二行,现在等价于 buildContextText() 里的 state.isAgentMode() 早退),agent 模式一旦误判就全线失守。
  3. mode 是持久化的,误判会跨重启存活。

三层防御(已实现)

层 位置 行为
写前守卫 commands/index.mjs createIsolatedAgent() 回传 bound;/agentstart 在 !result.bound 时调用 rollbackIsolatedAgent()(dispose 会话 + 清精确放行路由)并保持聊天模式,直接回复失败原因
启动自检 plugin-entry.mjs §5.1.3 启动时若 state.isAgentMode() 且 bind 未指向 state.agentSessionId,立即 exitAgent() 并告警
运行期 reconcile plugin-entry.mjs agent/inbox/inserted 命中聊天会话且处于 agent 模式时调用 reconcileAgentMode()(reconciling 标志防重入),比对真实绑定;漂移则 exitAgent() + ensureContextText(true) 强制重组装恢复注入

判据统一为一句话:agent 模式只有在 QQ 确实绑着那个隔离会话时才算数。

副作用记录:/agentstart 曾经在失败路径留下 295 字节的空壳隔离会话(tingxue-agent-*)。rollbackIsolatedAgent() 就是为了不留残留而加的。


记忆服务(HTTP API)

src/memory-service/index.mjs 是记忆的唯一写者,默认监听 http://127.0.0.1:8766(仅 loopback)。它把所有写操作经内部先进先出队列串行化,保证 LanceDB 永远只有一个写者,规避多进程并发写冲突。

方法 路径 说明
POST /memory 写入一条记忆(服务端负责 embedding)。快速返回:只同步落库正文与向量,实体抽取挪到响应之后异步做
GET /memory/search 按语义检索记忆(q / limit / scene)
GET /memory/entities 列出实体(上限 1500)
GET /memory/relations 列出关系(上限 6000)
GET /memory/expand 从种子实体沿关系扩展(entityId / depth)
GET /profile 返回人格文本(供多端共用同一份人格)
GET /health 健康检查

为什么 /memory 是快速返回:实体抽取是一次慢的小 LLM 调用。早期实现把它放在响应路径里,AstrBot 客户端 15 s 超时先到,日志只留一句空异常的 asyncio.TimeoutError,服务端其实已经写成功 → 客户端重试就写出重复记忆。现在响应体带 async: true,抽取在 writeChain 之后跑,失败只记日志。


AstrBot 群聊对接

目标是让 DSH(私人) 与 AstrBot(群聊) 互相独立、共享同一份记忆与人格:

DSH(唯一写者)──写──► 记忆库(LanceDB) ◄──HTTP── AstrBot
        │                        ▲
        └── 暴露 HTTP 记忆服务 ───┘
  • DSH 是唯一写者:AstrBot 只通过 HTTP 记忆服务读写,不直接碰 LanceDB 文件。
  • embedding 一致性:embedding 统一由 DSH 生成,AstrBot 只传文本。
  • 人格通用:两边各自加载同一份人格 txt,DSH 通过 GET /profile 提供。
  • 记忆储存策略(仿人脑):不按群组织,每个人物是独立实体节点,人物之间有关系边,记忆挂到相关人物上。

对接插件在 astrbot-plugin/(当前 v1.3.0),提供群聊 + 私聊自动记忆与 /回忆 /图谱 /记忆状态 命令,并严格隔离私聊。它通过 @filter.on_llm_request() 在 LLM 请求发出前检索记忆与人格,追加进 system_prompt(位于滚动窗口历史之前,滚动窗口与压缩都动不到)。

记忆注入与人格注入是两个独立开关(inject_memory / inject_profile),各自失败都不影响另一路。

[!NOTE] AstrBot 侧 LLM 建议使用 DeepSeek 官方端点:sta1n 对 openai SDK 的 x-stainless-* 请求头一律返回 403。


开发

环境准备

git clone <repo-url>
cd dsh-tingxue
pnpm install

运行测试

# 方式一:一次性跑全部
pnpm test

# 方式二:逐个运行(推荐在受限沙箱下使用)
# node --test 在部分沙箱环境会因 spawn EPERM 失败,直接运行测试文件即可
node "test/store.test.mjs"
node "test/context.test.mjs"
node "test/graph.test.mjs"
node "test/bind.test.mjs"
node "test/dashboard.test.mjs"
node "test/memory-service.test.mjs"
node "test/segment.test.mjs"
node "test/settings.test.mjs"      # Host 半侧:settings 命名空间注册
node "test/client-card.test.mjs"   # 浏览器半侧:设置界面 bundle
node "test/state.test.mjs"         # 双模式状态机 + 滑动窗口裁剪
node "test/inject.test.mjs"        # 注入时序(异步瀑布取真值)
node "test/selfcheck.test.mjs"     # 自检脚本自身(证明它会红,不是橡皮图章)
node "test/notifier-suppress.test.mjs"     # 命令消费不该被报成「任务被阻塞」
node "test/bigint.test.mjs"        # LanceDB Int64 是 BigInt(含真机契约:expand 不得挂死)
node "test/agent-mode-e2e.test.mjs"        # /agentstart → /agentstop 真机全流程(34 项)
node "test/patches/_qq-segment.test.mjs"   # 历史 QQ 分段补丁(不随主测试集)

运行状态自检

一条命令看清「重启后有没有退回旧毛病」——修完代码最容易踩的坑就是忘了重启,跑的还是旧代码:

node scripts/selfcheck.mjs          # 人类可读;有问题 exit 1,全绿 exit 0
node scripts/selfcheck.mjs --json   # 机器可读
node scripts/selfcheck.mjs --full   # 解全部日志帧(默认只解尾部 2000 帧,快)

输出示例:

  ✓ 运行代码是最新的
      DSH 启动 2026/9/25 15:58:38 ≥ 源码改动 2026/9/25 15:56:37
  ✓ 滑动窗口
      13 轮 / 1426 字符;latestInfo 0 条;mode=chat
  ✓ 注入轮内恒定
      本次启动(15:58:38)之后 2 轮均无轮内变动;system 长度 14193 / 14016
  ✓ 无重复/累赘上下文
      14016 字符 · 54 工具 · 人格块在 · 身份行 1 次

零成本:只读本地文件 + 打 /health、/profile 这类纯本地端点,绝不调用 /memory/search 之类会触发 embedding 的接口(那是要花钱的)。只读:不写记忆库、不改任何配置。

检查项见 test/selfcheck.test.mjs。核心判据是「同一轮内 system 长度必须唯一」——新回合首条 header 天然带 change 是正常行为,不是漂移;真正的 bug 特征是长度在一轮内出现多个值(记忆块中途消失)。

AstrBot 侧离线自检(不联网、不写记忆库):

cd astrbot-plugin
python test_private_handler.py

开发注意事项

  1. 不热重载——源码改动必须同步到 profile 的 node_modules 副本并重启 DSH,浏览器端还要硬刷新页面。同步后请核对 SHA256。
  2. settings 命名空间注册要内联 ctx.get('settings') + settings.register(),不要导入 installSettingsSection / settingsNamespace 这两个具名导出:上游删除过它们,而缺失的具名导出是模块求值期的 SyntaxError,会让宿主启动失败。没有 settings 服务的旧宿主下应优雅降级。
  3. 两半侧字段表必须同步改——src/settings/index.mjs 的 SETTINGS_FIELDS 与 client/client.js 的 FIELDS,漏一边会出现「Host 认这个键、界面画不出来」或反之。
  4. 异步上下文不要靠 section.text 同步缓存——assemble() 同步求值 text 之后才跑瀑布,所以同步缓存首轮必空、之后恒错位一轮。要用 system-prompt/assemble 瀑布(见 上下文组装 与 src/context/inject.mjs)。
  5. 注册作用域决定可见范围——要只对聊天会话生效,就必须注册在 agent 作用域,不能注册在插件 root ctx。
  6. 跨插件不要做值导入——client bundle 的纯净度门禁拒绝跨插件的值导入,协作走 cordis 服务。

路线图

  • 聊天模式 + 长期记忆
  • agent 模式隔离会话(全自动双会话)
  • 关系图谱 + 可交互蜘蛛网 UI
  • 记忆服务(HTTP)与 AstrBot 群聊对接
  • 图形化配置(设置侧边栏独立入口)
  • 模式自愈(启动自检 + 运行期 reconcile)
  • 记忆写入去重(/memory 快速返回 + 异步实体抽取)
  • 注入时序收敛(改走 system-prompt/assemble 异步瀑布取真值;section.text 同步求值结构上不可能赶上检索)
  • 运行状态自检(scripts/selfcheck.mjs,九项,零成本只读)
  • 图谱面板「鹰眼模式」小地图(内容随主视图缩放,作为标准缩略图模式的可选增强)
  • 记忆库主干架构的多端一致性加固

文档索引

文档 内容
README.md 本文件:安装、配置、架构、开发、排障
CONTRIBUTING.md 贡献指南:四条铁律、上下文注入约束、测试约定
CHANGELOG.md 版本变更记录
SECURITY.md 安全策略与敏感面说明
astrbot-plugin/README.md AstrBot 插件说明
astrbot-plugin/SYNC.md AstrBot 插件同步铁律

内部文档不随仓库发布。 设计文档(需求规格.md、交接文档.md、记忆库主干架构*.md、astrbot接入方案.md、调研报告-*.md、发布注意事项.md)已由 .gitignore 挡在仓库外——它们含本机路径、会话 ID 等隐私信息。克隆本仓库看不到这些文件是正常的。


贡献

这是一个个人自用项目,没有正式的贡献流程。如果你基于它做二次开发:

  1. 先确认改动落在正确的半侧——Host(src/)还是浏览器(client/)。
  2. 新功能请补对应测试,并保持 node test/*.test.mjs 全绿。
  3. 提交前检查四条铁律没有被破坏:会话隔离、注册作用域、单一向量模型、源码必须同步到 node_modules 并重启。

许可证

MIT

—/ 5

No ratings yet

Verified DSH bundle

Commit b4b37a6ea892

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout