Bundle
dsh-group-feed
给 dsh 的群聊上下文投喂:非 @ 的消息零成本攒批挂进会话上下文,每天定时把每个会话日结成 markdown 再开新会话(摘要失败的不清)。
- Source
- JackZo400
- License
- MIT
- Updated
- Updated 24 days ago
Readme
# dsh-group-feed
[English](README.en.md) | 简体中文
给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)的**群聊上下文投喂**插件:
让 Agent 在群里不当聋子,又不用为群里每一句话醒一次。
它不是又一个"聊天机器人记忆",它管的是**钱**和**上下文**这两笔账——群里绝大多数消息
根本不是对 Agent 说的,处理它们的方式决定了这个 Agent 是"接得上话"还是"答得驴唇不对马嘴"。
---
## 为什么需要它
**不这么做,会怎样**
只有两种结局,都在线上见过:
**结局一:Agent 是个聋子。**
群里只在被 @ 的时候才唤醒它。它醒来时对刚才聊了什么一无所知——前面三个人在说"那就八点,
老地方",它被 @ 之后回了一句"什么八点?"。被 @ 的次数越多,这种答非所问越多。
**结局二:它听得见,但你为每一句话付钱。**
让每条群消息都触发一次模型调用,看起来最省事。但群里 30 条消息里大概只有 1 条是 @ 它的,
剩下 29 条每一句都唤醒一次模型、都带一整段上下文——**账单是前者的几十倍**,
而且它会在没人跟它说话的时候自说自话。
这个插件走第三条路:**非 @ 的消息攒着,攒成一段,用零 token 的方式挂进那个会话的上下文。**
## 安装
```bash
dsh plugin --profile web add github:JackZo400/dsh-group-feed
```
配置抄 `cordis.patch.yml` 里那份就行。
## 两个机制
### 1. 攒批投喂
```
群里有人说话(没 @ 它)
→ note(key, who, text) 只是攒着,不唤醒模型、不花钱
→ 攒够 25 条,或者等了 40 分钟
→ 拼成一段(从最新往旧取,最多 40 条 / 1200 字)
→ sink.inject(key, 段) 挂进那个会话的上下文:零 token、不唤醒
→ 它下次真被 @ 的时候,这段背景和 @ 一起来到它面前
```
两个阈值是"或者"的关系:热闹的群按条数投,冷清的群按时间投,免得等一整天。
同一会话**最多挂 3 段**没被读到的:再投只是把下次被 @ 那一轮的输入撑爆,收益是负的。
攒着不投的那几段不会丢,等它读掉一段立刻补上。
### 2. 每日日结
每天 04:10(本地时间),对每个有内容的会话做一次:
```
读当天原文 → 压成一段 300~500 字的中文摘要(一次便宜模型调用)
→ 所有会话的摘要先写进 <dir>/digest/<日期>.md
→ 写成功了,才回头把会话开新的(= 清掉,下次消息重建)
```
**摘要失败的那个会话,一个字节都不许清。** 宁可不整理、多留一天,也不能出现
"会话没了、摘要也没有"。这条纪律比省钱重要。
两个小细则,都是同一条纪律推出来的:
- **不到 40 字**的会话(就一两句寒暄)不花摘要钱,直接开新会话——它本来也没什么可留下的。
- **出口还没挂上**的时候(一个会话都看不见),这次日结直接跳过,连日期都不记:
记了就等于白白跳过一天,等你的通道插件起来时,今天这份已经"做过了"。
## 通道插件怎么接进来
这个插件**不认识任何聊天协议**。它只认一个"会话 key"(`group:demo` 这种字符串)。
你那边是 QQ、Discord 还是 Telegram,对它没区别。
### 进:把消息喂进来
```js
// 收到一条群里**非 @** 的消息(@ 的那些照旧走你自己的流程,别喂进来)
const feed = ctx.get('groupFeed')
feed.note(`group:${chatId}`, senderName, text)
```
`note()` 是纯内存操作:不联网、不花钱、不唤醒模型,随便调。
### 出:实现一个「投喂出口」(sink)
注册成 cordis 服务(默认名 `groupFeedSink`),或者晚一点用 `attach()` 挂上去:
```js
ctx.provide('groupFeedSink', {
// 把一段背景挂进这个会话的上下文。**不许唤醒模型**——这是整个插件的立身之本。
// 会话还没开(投不进去)就返回 false,插件会继续攒着,下一轮再试。
inject(key, text) {
const rec = sessions.get(key)
if (!rec?.agent) return false
rec.agent.inject({ id: randomUUID(), role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } })
return true
},
// 这个会话里还挂着几段没被读到的(答不上来就返回 0,别返回大数——那会把投喂永久堵死)
pending: (key) => sessions.get(key)?.agent?.inbox?.nextStep?.length ?? 0,
// 清会话 = 开新的。没清成请**返回 false**,插件会把它记进日结文件和日志
reset(key) {
sessions.get(key)?.agent?.dispose?.()
sessions.delete(key)
return true
},
// 现在有内容的会话有哪些(日结整理这些)
keys: () => [...sessions.keys()],
// 当天的原文,日结喂给摘要器用。
// 读不到就 **抛错**,或者干脆不提供这个函数 —— 插件会退成"读不到内容,不清会话"。
// 千万不要返回空字符串:那会被当成"今天没什么可整理的",会话就被清了。
transcript: async (key) => readSessionText(key),
// 显示名,日志和日结标题用
title: (key) => groupNameOf(key),
})
```
加载顺序无所谓:出口是**每次用的时候现取**的。你的通道插件比它晚加载,就晚一点
`ctx.get('groupFeed').attach(sink)` 挂上去。
### 服务接口(暴露出去的那个 `groupFeed`)
| 方法 | 干什么 |
| --- | --- |
| `note(key, who, text, at?)` | 攒一条非 @ 的消息。返回这个会话现在攒了几条 |
| `flush(key?, at?)` | 立刻投(跳过条数与等待判定,"挂满 3 段"那道闸还在)。你知道它刚读完时调 |
| `digest(at?)` | 立刻做一次日结(不看时刻)。做完当天不会再做第二遍 |
| `tick(at?)` | 手动推一次心跳(自检 / 嵌进别人的定时器时用) |
| `status()` | 攒了多少、投了几段、日结上次什么时候跑的、出口挂上没有 |
| `attach(sink)` | 运行时挂投喂出口(传 `null` 摘掉) |
## 数据落在哪
```
<dir>/ 默认 <启动目录>/.group-feed
├── state.json 只记一件事:日结今天做过没有(重启不重复做)
└── digest/2026-09-28.md 当天每个会话一段摘要 + 状态(谁清了、谁没清、为什么)
```
`state.json` 是原子写的(先写 `.tmp` 再 rename):半截写会留下一个坏 JSON,
虽然有自愈兜着,但"今天已做"这个信息会一起丢——于是重启就再摘一遍、再花一遍钱。
## 三条纪律(改代码前请先读这三条)
1. **摘要失败不清会话。** `summarize` 抛错、返回空、读不到原文、没有 API key —— 一律
`kept`,那个会话一个字都不动。代价是"某天没整理",收益是"话不会丢"。
2. **先落盘,后清会话。** 摘要在内存里,文件才是唯一持久的副本。先清后写,
正好在磁盘满的时候把唯一一份记录丢在内存里。
3. **同一天只做一次日结。** 日期在动手之前就落盘。跑挂了明天再说——同一批会话
一分钟重试一次,只会反复烧摘要钱(内容没丢,明天照样摘得到)。
## 测试
```bash
node test/selftest.mjs # 纯逻辑:阈值、截断、日结编排、状态自愈(假时钟 + 假注入 + 假摘要)
node test/plugin-selftest.mjs # 接线:假 ctx 把 apply() 跑一遍,验服务与配置
```
两个都不联网、不调模型、不真等 40 分钟,秒级跑完,而且**挂了就是 exit 1**。
`selftest.mjs` 覆盖了那三条纪律的失败路径:摘要抛错、返回空串、读不到原文、
写文件抛错、清会话返回 false —— 每一个都断言"那个会话没有被清"。
## 已知局限
- **攒批只在内存里。** 进程重启,还没投出去的那几段就没了(原始聊天记录还在,
丢的只是"背景"这一段)。要持久化就得让通道自己把没投的存下来,那是通道的事。
- **日结要通道给原文。** 插件不读别人的会话轨迹格式——那是每个通道自己的事。
没给 `transcript` 就永远不清会话(安全的那一边)。
- **04:10 到 08:00 之间进程没在跑,当天就不做了。** 宁可少做一次,也不在白天
把人正在用的会话清掉。要补就自己调 `digest()`。
- **每段投喂本身还是要花钱的**(下次被 @ 时多带那 1200 字)。所以 `textMax` 别往大调,
投喂是"背景",不是"档案"。
- **没有工具(tool)出口**:它的输入是别人说的话,不是模型提的问题;产出(日结)
本来就该出现在下一轮的上下文里。少一个工具就少一份常驻提示词开销。
## License
MIT © 2026 JackZo400
---
## English
→ Full English README: [README.en.md](README.en.md)
Install
dsh plugin --profile web add github:JackZo400/dsh-group-feed#f2992b2aa32ef383a17630150586d7424bb3f052
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-group-feed from the hub