# dsh-group-feed
[简体中文](README.md) | English
A **group-chat context feed** plugin for
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh): it keeps the
agent from being deaf in a group chat, without waking it for every message in the group.
It is not yet another "chatbot memory". It manages two accounts: **money** and
**context**. The overwhelming majority of group messages are not addressed to the agent at
all, and the way you handle them decides whether this agent "picks up the thread" or
"answers something wildly off".
---
## Why you need it
**What happens if you don't.**
There are only two endings, and both have been seen in production:
**Ending one: the agent is deaf.**
In the group it is only woken when it is mentioned. When it wakes up it knows nothing
about what was just being discussed — three people were saying "then eight o'clock, the
usual place", and after being mentioned it replies "what eight o'clock?". The more often
it is mentioned, the more of these off-target answers you get.
**Ending two: it hears you, but you pay for every message.**
Making every group message trigger a model call looks like the easiest option. But only
about 1 of every 30 group messages mentions it; the other 29 each wake the model and each
carry a whole stretch of context — **the bill is dozens of times that of the first
option**, and it will start talking to itself when nobody is talking to it.
This plugin takes a third path: **non-mention messages are buffered, batched into one
block, and attached to that session's context the zero-token way.**
## Install
```bash
dsh plugin --profile web add github:JackZo400/dsh-group-feed
```
Just copy the configuration from `cordis.patch.yml`.
## The two mechanisms
### 1. batched feeding
```
群里有人说话(没 @ 它)
→ note(key, who, text) 只是攒着,不唤醒模型、不花钱
→ 攒够 25 条,或者等了 40 分钟
→ 拼成一段(从最新往旧取,最多 40 条 / 1200 字)
→ sink.inject(key, 段) 挂进那个会话的上下文:零 token、不唤醒
→ 它下次真被 @ 的时候,这段背景和 @ 一起来到它面前
```
The two thresholds are an "or" relationship: a busy group is fed by count, a quiet group
by time, so that nothing has to wait a whole day.
Per session **at most 3** unread blocks are attached: feeding more only blows up the
input of the next mention round, so the return is negative. The blocks that stay buffered
are not lost; as soon as one of them is read, the next one is fed right away.
### 2. daily digest
Every day at 04:10 (local time), once for every session that has content:
```
读当天原文 → 压成一段 300~500 字的中文摘要(一次便宜模型调用)
→ 所有会话的摘要先写进
/digest/<日期>.md
→ 写成功了,才回头把会话开新的(= 清掉,下次消息重建)
```
**A session whose summary failed must not have a single byte cleared.** Rather skip the
tidying and keep it one more day than end up with "the session is gone and the summary is
gone too". This discipline matters more than saving money.
Two small rules, both derived from the same discipline:
- A session with **fewer than 40 characters** (just a greeting or two) does not spend
summary money; it is started fresh directly — there was nothing worth keeping anyway.
- When the **sink is not attached yet** (not a single session is visible), this daily
digest is skipped entirely, without even recording the date: recording it would mean
giving a day away for nothing, and by the time your channel plugin comes up, today's
run already counts as "done".
## How a channel plugin hooks in
This plugin **knows nothing about any chat protocol**. It only knows a "session key" (a
string such as `group:demo`). Whether your side is QQ, Discord or Telegram makes no
difference to it.
### In: feeding messages in
```js
// 收到一条群里**非 @** 的消息(@ 的那些照旧走你自己的流程,别喂进来)
const feed = ctx.get('groupFeed')
feed.note(`group:${chatId}`, senderName, text)
```
`note()` is a pure in-memory operation: no network, no money, no model wake-up, call it
as often as you like.
### Out: implement a feed sink
Register it as a cordis service (default name `groupFeedSink`), or attach it a bit later
with `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),
})
```
Load order does not matter: the sink is **looked up fresh on every use**. If your channel
plugin loads later than this one, it just attaches a bit later with
`ctx.get('groupFeed').attach(sink)`.
### Service interface (the exposed `groupFeed`)
| Method | What it does |
| --- | --- |
| `note(key, who, text, at?)` | buffer one non-mention message. Returns how many are buffered in this session now |
| `flush(key?, at?)` | feed immediately (skipping the count and wait checks, the "3 blocks attached" gate still applies). Call it when you know it has just finished reading |
| `digest(at?)` | run the daily digest immediately (ignoring the clock). Once done, it will not run a second time that day |
| `tick(at?)` | push one heartbeat by hand (for self-tests / when embedded in someone else's timer) |
| `status()` | how much is buffered, how many blocks were fed, when the digest last ran, whether the sink is attached |
| `attach(sink)` | attach the feed sink at runtime (pass `null` to detach it) |
## Where the data lands
```
/ 默认 <启动目录>/.group-feed
├── state.json 只记一件事:日结今天做过没有(重启不重复做)
└── digest/2026-09-28.md 当天每个会话一段摘要 + 状态(谁清了、谁没清、为什么)
```
`state.json` is written atomically (write `.tmp` first, then rename): a half-written file
leaves a broken JSON behind, and although self-healing covers that, the "already done
today" information is lost along with it — so a restart summarizes everything again and
spends the money again.
## Three disciplines (read these before changing the code)
1. **A failed summary does not clear the session.** `summarize` throws, returns empty, the
transcript cannot be read, there is no API key — all of these mean `kept`, and not a
single character of that session is touched. The cost is "one day was not tidied up",
the gain is "words are not lost".
2. **Write to disk first, clear the session after.** The summary is in memory; the file is
the only durable copy. Clear first and write after, and you drop the only record into
memory exactly when the disk is full.
3. **The daily digest is done only once a day.** The date is written to disk before any
work starts. If the run dies, tomorrow will do it — retrying the same batch of sessions
once a minute only burns summary money over and over (the content is not lost, tomorrow
can still summarize it).
## Tests
```bash
node test/selftest.mjs # 纯逻辑:阈值、截断、日结编排、状态自愈(假时钟 + 假注入 + 假摘要)
node test/plugin-selftest.mjs # 接线:假 ctx 把 apply() 跑一遍,验服务与配置
```
Neither of them touches the network, calls a model, or really waits 40 minutes; they
finish in seconds, and **a failure means exit 1**. `selftest.mjs` covers the failure paths
of those three disciplines: the summary throws, returns an empty string, the transcript
cannot be read, the file write throws, reset returns false — each one asserts that "that
session was not cleared".
## Known limitations
- **Batching lives in memory only.** If the process restarts, the blocks that were not fed
yet are gone (the raw chat log is still there; what is lost is only the "background"
block). Persisting it would mean the channel storing what it has not fed, and that is
the channel's business.
- **The daily digest needs the transcript from the channel.** The plugin does not read
anybody else's session trajectory format — that is each channel's own business. Without
a `transcript` it never clears a session (the safe side).
- **If the process is not running between 04:10 and 08:00, that day is not done.** Rather
skip one run than clear sessions people are using in the middle of the day. To catch up,
call `digest()` yourself.
- **Every fed block itself still costs money** (the next mention carries those 1200 extra
characters). So do not raise `textMax`: feeding is "background", not an "archive".
- **There is no tool outlet**: its input is what other people said, not a question the
model asked; its output (the daily digest) belongs in the next round's context anyway.
One tool less is one permanent prompt-cost less.
## License
MIT © 2026 JackZo400