dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
dsh 插件:会话交接——开新会话前先把上一段摘要成笔记,笔记真的落盘了才允许删旧轨迹 · Session handover for dsh: summarize first; delete the transcript only after the note is read back.
简体中文 | English
A session handover plugin for DeepSeek Harness (dsh).
In one sentence: before you open a new session, write down what the previous one talked about; only once that note has really landed on disk are you allowed to delete the old session transcript.
旧会话轨迹 ──摘要──▶ 笔记(memory/carryover/<通道>.md)──▶ 新会话自动带上
│
└─ 摘要失败 / 写盘失败 / 读不出来 ──▶ 什么都不删,下次再来
dsh's context fills up. Once it does, the usual move is "just open a new session", and then:
This plugin turns that into a process with a strict order, and treats the order as a hard rule:
Summarize first, purge second. If the summary did not succeed, not one thing may be deleted.
Its trade-off is explicit: it would rather keep a stale transcript it failed to clear than lose the words. So you will see a lot of "looks redundant" checks: a note has to be read back and verified after it is written, directory paths have to pass guardrails, a transcript that fails to unpack may not be deleted, an unrecognized transcript filename may not be deleted either. They all block the same kind of mistake — the irreversible kind.
For a given "channel" (a group, a private chat; the key is defined by the host, e.g.
group:demo):
session.v*.jsonl.zstd, and pick out
"what people said to each other" (injected system frames, reasoning traces and tool
calls are all filtered out);KEEP line to the log.The first time a new session assembles its system prompt, the plugin looks up which channel the current session id belongs to and hangs that channel's note into the context. The full text always stays on disk; the injection carries only the most recent part (4000 characters by default).
dsh plugin --profile web add github:JackZo400/dsh-carryover
After installing, confirm three things (details in "Configuration" and "The session map" below):
sessionsRoot points at the right place — the default is ~/.dsh/sessions;summary.apiKey has a value — prefer the environment variable DSH_CARRYOVER_API_KEY,
do not write the key into a config file;- insert:
- id: carryover
name: dsh-carryover
config:
enabled: true
sessionsRoot: ~/.dsh/sessions # 轨迹根
projCacheRoot: ~/.dsh/storages/session_projcache/sessions # 投影缓存
transcript: auto # auto = 自己挑 session.v<N>.jsonl.zstd 里版本最大的那个
workspace: . # 笔记/账本/日志默认都在这下面
# notesDir: '' # 默认 <workspace>/memory/carryover
# sessionMapPath: '' # 默认 <workspace>/tmp/session-map.json
# logPath: '' # 默认 <workspace>/memory/carryover.log
inject: true # 新会话自动带上笔记
injectOrder: 40 # 挂进上下文的顺序(越小越靠前)
maxInjectChars: 4000
summary:
baseUrl: https://api.deepseek.com # 任何 OpenAI 兼容接口,给根或 /v1 都行
apiKey: '' # 强烈建议走 DSH_CARRYOVER_API_KEY
model: deepseek-chat
maxTokens: 900
prompt: '' # 留空 = 内置提示词(写便条,不写公文)
extraPrompt: '' # 追加一句额外要求
minDialogueChars: 40 # 正文太薄就不值得花一次模型调用
purgeThinSessions: true # true = 太薄的直接清;false = 再短也走一遍摘要
minSummaryChars: 10 # 摘要短于这个长度 = 没摘到,不删
maxSummaryChars: 1200 # 摘要超长就截断(头尾各留一半)
maxInputChars: 60000 # 喂给摘要的正文上限
Every option has a default, and configuration is shallow-merged (summary and
labels are merged one level deeper, so configuring summary.model does not wipe out the
whole summary table).
Default <workspace>/tmp/session-map.json, format:
{
"group:demo": { "sessionId": "3f2a...c1", "carried": "1a9b...7e" },
"c2c:alice": { "sessionId": "88d0...4b" }
}
sessionId: this channel's current session id (the host updates it every time it
opens a new session);carried: the session id that has already been handed over (written by the plugin, so
it does not summarize — and pay — twice).The keys are yours to choose — a channel name, a group number, a user id, a working
directory are all fine; they are only used to name the note file (with filename
sanitizing, so things like ../.. cannot climb out).
Who writes this file? Both ways work:
Without this file the plugin neither knows who should be handed over nor which channel a new session belongs to — it quietly does nothing, rather than going off to guess.
const carryover = ctx.get('carryover')
// 建新会话之前
const r = await carryover.carryOver({ key: 'group:demo' })
if (r.status === 'kept') log('这次没交接成,旧轨迹留着:', r.reason)
status has three states:
| status | meaning |
|---|---|
none | nothing to hand over (no previous session / transcript already gone / already handed over) |
done | summary + write + read-back all succeeded, the old transcript is deleted (reason: too-thin means the body was too thin, purged without spending on a model) |
kept | the handover did not happen, not one old transcript was deleted. reason says where it got stuck |
The reason values can be looked up directly: summary-failed (model/network),
note-write-failed (disk/permissions), note-not-verified (written but read back wrong),
transcript-unreadable (the transcript cannot be unpacked), sessions-root-missing
(sessionsRoot is misconfigured, so nothing can be found — in that case nothing is
recorded in the ledger, so fixing the config back still saves you), no-summarizer (no
key configured), summary-empty, too-thin, already-carried, transcript-gone.
| tool | what it does | does it cost money |
|---|---|---|
carryover_status | read the ledger: each channel's previous session, whether it was handed over, where the note is, how big it is | no |
carryover_note | read this channel's note (for when you cannot recall what was discussed before) | no |
carryover_run | really do a handover. mode=peek only reports "how it would decide given the current state", dry really summarizes but neither writes nor deletes | only run/dry call the model |
const note = ctx.get('carryover').readNote('group:demo') // 超长只带最近一段
src/carryover.js contains not a single line of dsh code; every external dependency (fs,
clock, summarizer function, transcript reader) is injected:
import { createCarryover } from 'dsh-carryover/carryover'
const engine = createCarryover({
workspace: '/srv/app',
sessionsRoot: '/srv/app/.dsh-sessions',
summarize: async (dialogue) => myOwnSummarizer(dialogue), // 换掉默认的 HTTP 摘要
now: () => new Date(),
})
await engine.carryOver({ key: 'group:demo' })
It deletes exactly two things, and both must belong to "the session id being handed over this time":
<sessionsRoot>/<projectKey>/<sessionId>/ — this session's own transcript directory;<projCacheRoot>/<sessionId>.json — its projection cache.Guardrails (a wrong path means no delete, only a ledger entry): the path must be under
sessionsRoot, one or two levels deep at most, the directory name must be exactly this
session id, and the path must not contain ...
Never touched: other session directories, sessionsRoot itself, the project's
projectKey directory, your workspace. Notes are written by appending (one
## time · channel block after another), historical notes are never overwritten.
node test/selftest.mjs # 纯逻辑:决策表、截断、路径护栏、真文件系统的写/删
node test/plugin-selftest.mjs # 插件接线:假 ctx + 假会话 + 假摘要器,跑完一整条流程
npm test # 两个都跑
Both self-tests need no dsh, no network, no API key, and make zero model calls, yet the delete/no-delete outcome really happens in a temporary directory. Key coverage:
maxSummaryChars, half from the head and half from
the tail;EACCES permission denial, a real
ENOSPC error, and the sneakiest one: a silent write loss that reports no error but
reads back empty);sessionsRoot is misconfigured, the session may not be recorded as "handed over"
(otherwise it would never be summarized again even after the config is fixed back).projectKey algorithm (/x/y → --x-y--), because it did not want
to import the host's internal modules. A copy goes stale, so there is one more fallback
when locating the transcript: if it cannot be guessed, scan one level under
sessionsRoot by session id; if that finds nothing either, no handover and no delete.session.v4.jsonl.zstd, and dsh
itself keeps moving forward), so the default transcript: auto picks the highest
version instead of hard-coding v4. When no transcript file is recognized, nothing is
deleted.zstd command line (dsh transcripts are multi-frame
concatenations, and node:zlib eats only the first frame — unpacking just the first
frame would silently feed barely half the conversation). If the CLI is not there, it
falls back to a single frame.enabled: false or simply do not install it;
purgeThinSessions: true skips the summary and purges directly when the body is too
thin.MIT © 2026 JackZo400