dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
File-based long-term memory plugin for DeepSeek Harness: fixed read budget, BM25 search, confirmation gate, always-on injection, auto-settle. Zero runtime dependencies.
给 DeepSeek Harness(DSH / Cordis)用的文件式长上下文记忆插件。
它是一次「整合升级」:把若干记忆插件里已经被验证过的机制——固定阅读预算、BM25 检索、 确认闸门、索引重建、原子写入、显式删除——收进一个零硬依赖的包里,而不是捆绑或依赖它们。
dependencies 为 {};不捆绑 dsh-persist、dsh-memory-pyramid、@max-null/dsh-memory、dsh-df-memory、@libai168/dsh-tool-memory,也不依赖任何其他记忆插件。
/.agent/memory/),暴露 MemoryBackend 接口供后续替换。async (backend, input) => output。所以 test/run-fixtures.mjs可以直接 import('lib/tools/*.js') 传临时 backend 做断言,不需要启动 DSH。
npm i -g dsh-memory-agent # 或装进某个 DSH profilepackage.json 里声明了 bundle patch:
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }cordis.patch.yml 顶层是数组,用 insert 增加插件行,name 写 npm 包名(不写源码路径):
- insert:
- id: memory-agent
name: dsh-memory-agent
inject: [tools, systemPrompt, webServer, sessions]
config:
root: null
requireConfirmation: false
defaultBudget: 1200
maxTopK: 200
lifecycle:
enabled: false
audit: true插件入口只导出三样:
export const name = 'dsh-memory-agent' // 包名
export const inject = ['tools'] // 让 Cordis 在 apply 前备好工具注册表
export function apply(ctx, config) // 只做接线apply() 只做三件事:创建 backend、把 handler 包成 defineTool(...) 注册到 ctx.tools、 用 ctx.effect 注册清理(卸载时逐个调用注册器返回的 disposer,不留悬空状态)。
defineTool来自宿主@deepseek-ai/dsh-tools。若该模块解析不到(三方插件常常解析不到,
它装在宿主自己的 node_modules 里),插件会打印一次警告,并改用内置的DSL → JSON Schema 编译器把同一份作者 DSL 编成注册表要求的原始 JSON Schema。
两条路径产出同构定义,不会因为一条 import 解析失败而整体不可用;verify 的接线烟测
就是跑降级路径,顺带断言parameters.type === "object"、required已经是数组。
装好后在 设置 → 记忆 里有一块面板:
能做什么 · 说明 看记忆库路径 · 只读展示 /.agent/memory 的绝对路径 看统计 · 全部 / 可检索 / 待确认 / 冲突 / 待清理 / 孤儿 六个计数 改六项行为 · 「写入需要确认」开关、默认阅读预算(token)、检索条数上限、写入前脱敏开关、常驻注入开关(含字符预算 200–8000)、自动沉淀开关(勾完下一轮生效) 搜索 · 输入框 + 「搜索」,走的是和模型同一套 BM25 + 别名;别名推断的命中标「别名」,零命中直接列出常用标签供点选重搜 写一条记忆 · 文本框 + 标签,手写的直接生效(不过确认闸门),不用等模型动手 看详情 · 每行「详情」展开:id / 路径 / 状态 / 时间 / 置信度 / 来源 / 标签 / 冲突现场 / 原文预览 确认 · 待确认的条目一键转「可检索」 冲突收尾 · 冲突行给「保留这条」/「丢弃」:留下的转 active,同槽位其它说法转 superseded 并退出检索 软删 / 恢复 · 软删只是标记(文件保留、退出检索),随时能点「恢复」撤回 重建索引 · 等价 rebuildIndex(),索引坏了点一下就好 清理已软删 · 物理删掉已软删的 episodes/、raw/ 文件;遇到已软删的 topics/ 会跳过并回显「跳过 N 条」 彻底删除主题记忆 · 只在有待清理的 topics/ 时出现,两步确认后连主题文件一起物理删除 按会话忘却 · 列出会话及条数,两步确认后整批忘却(共享 topics 只解绑来源) 导出备份 · 一键把索引 + 所有记忆文件打成 JSON bundle 下载走(≤8MB,超了会标记截断)
改完点「保存设置」立即生效(不用重启宿主):面板写的是 /config.json, 启动时 createRuntimeConfig() 用它覆盖 cordis 配置里的同名字段。 想回到配置文件为准,删掉 config.json 即可。
实现方式(两个半身,都不引入新依赖):
src/host/api.ts:一个路由 POST /memory-agent/api,按 op 分发(status / config.set / rebuild / confirm / resolve / softDelete / restore / purgeDeleted / search / detail / create / forgetSession / export)。 purgeDeleted 默认只清 episodes/、raw/(并返回 skipped_topics 告诉你跳过了几条); 传 { "includeTopics": true } 才连 topics/ 一起删,返回值里带 removed_topic_count。 三重门槛:只接受 POST + `content-type: application/json`、只放行本机回环、 并且要么 Origin 与 Host 一致、要么带面板自带的 x-dsh-memory-agent: panel 头。 所以 curl -X POST 这种没有 Origin 也没有面板头的请求一律 403; op 白名单、请求体上限 64KB;配置只允许写白名单里的四个字段并做范围收敛 (maxTopK 超 200 会被夹回 200)。宿主没提供 webServer 时安静跳过,工具照常可用。
client/client.js:手写的 window.__ModuleLoader__.load({ id, factory })产物,不需要打包器,require 只用平台种子表里的 react;通过 ctx.slots.inject("settings.section", () => ctx.slots.register({…, id: "memory-agent"}, MemorySection)) 挂到设置页,ctx.effect 兜住卸载。
面板本身没有任何自动化测试靠肉眼——verify.sh 第 5 步会用一个迷你 React + 假 fetch 把这块面板渲染出来、把按钮点一遍(test/run-client-check.mjs), 再拿假 req/res 把宿主路由打一遍(test/run-api-check.mjs)。
九个工具,返回值就是契约本身:
工具 · 入参 · 返回 memory_nav · { path?, limit?, offset?, sort? } · { entries: { id, path, type, title, summary, time }[], total, offset, limit, has_more, facets?, readme? } memory_search_hybrid · { query, tags?, time_range?, entities?, top_k? } · { hits: { id, path, type, title, summary, score, reason }[], guidance } memory_assemble · { task, hits?, budget? } · { context, used_tokens, sources, omitted } write_memory · { summary, raw?, tags?, entities?, source, session_id, confidence?, requireConfirmation?, ... } · { path, id, status, dedupe?, similarity? } memory_confirm · { id } · { id, status: "active" } forget_memories_by_session · { session_id, scope?, mode? } · 见删除联动 memory_resolve · { id, action?: "keep" \ · "drop" } · { id, status, superseded: string[] } memory_sweep · { days?, limit? } · { at, scanned, thresholds, longUnused[], supersededBacklog[] }(只读体检,见体检与使用信号) memory_digest · { key?, refresh? } · 有 key:{ slot, path, text, updated_at, status: "active" \ · "conflict", rebuilt };无 key:{ slots: { slot, path, updated_at, stale, entries, conflict, history, text? }[] },见当前认知
handler 的 TS 形状(lib/tools/*.js 里就是这些函数,纯函数、不依赖 ctx):
navHandler(backend, input): Promise<NavOutput>
searchHandler(backend, input): Promise<Hit[]>
assembleHandler(backend, input): Promise<AssembleOutput>
writeHandler(backend, input): Promise<WriteOutput>
confirmHandler(backend, input): Promise<ConfirmOutput>
forgetHandler(backend, input): Promise<ForgetOutput>
resolveHandler(backend, input): Promise<ResolveOutput>
sweepHandler(backend, input): Promise<SweepOutput>
digestHandler(backend, input): Promise<DigestOutput>memory_nav 的默认行为是只读摘要层 + 分页:entries 只带 id/path/type/title/summary, 任何正文(尤其 raw/ 全文)都不会被带出来;默认一次只给 50 条(上限 500), 用 offset 翻页、has_more 判断还有没有——库再大也不会一次糊满上下文。 要读全文,必须把 path 显式指到那个文件:
nav({}) → 第一页摘要层元数据 + 根 README.mdnav({ offset: 50 }) → 第二页nav({ path: "topics/" }) → 该目录下的条目 + 该目录 _index.mdnav({ path: "raw/mem_x.md" }) 或 nav({ path: "mem_x" }) → 那一条 + 该文件全文(不分页)默认根目录 /.agent/memory/(fixture 用独立临时目录):
.agent/memory/
README.md
index.jsonl # 索引,一行一条
ledger.jsonl # 只追加的审计事件
episodes/ # 会话情节(带 session_id 来源标记)
raw/ # 原文(只能通过 raw_ref 下钻)
topics/ # 共享/主题记忆(可多来源)
_index.md # 每个目录一份,随写入自动刷新
config.json # 面板写入的运行期配置(可删,删了回落 YAML)
aliases.json # 可选:自定义别名词表,和内置表合并index.jsonl 一行一个条目:
{
"id": "mem_xxx", "path": "topics/xxx.md", "type": "topic",
"title": "...", "summary": "...", "tags": [], "entities": [],
"time": "2026-01-01T00:00:00.000Z",
"sources": [{ "session_id": "s1", "kind": "session" }],
"session_id": "s1", "confidence": 0.6,
"raw_ref": "raw/mem_xxx.md", "vector_id": null,
"status": "active"
}type:summary | episode | raw | topicstatus:active | suggested | superseded | conflict | deleted_pending | orphanedsources 是数组,元素 { session_id, kind: "session" | "plugin" | "user" }。所有写入记录全部来源;顶层 session_id 只是主来源的快捷字段, forget 逻辑只读 `sources` 数组。
schema_version(当前 2):旧数据缺这个字段会被当成 1,不会报错;点一次「重建索引」就整体升到当前版本(rebuildIndex 写出的条目一律是新版本)。
index.jsonl 只是索引。状态变更(suggested → active、conflict、deleted_pending、orphaned)会同步写回记忆文件的 front-matter, 所以 rebuildIndex() 重建出来的状态和索引里的一致。
记忆文件长这样(front-matter 由本插件自解析,不引入 YAML 依赖):
---
id: mem_xxx
type: topic
title: PostgreSQL 索引选择
time: 2026-01-02T10:00:00.000Z
status: active
session_id: sess-a
confidence: 0.9
tags: database, postgresql
entities: PostgreSQL, B-tree
sources: sess-a:session
---
# PostgreSQL 索引选择
B-tree 适合等值与范围查询;GIN 适合数组和全文。原子写入:所有落盘都走「写临时文件 → rename」,Windows 上 rename 覆盖失败时先 unlink 再 rename;ledger.jsonl 只 append,从不重写。
并发写入:索引是「读整个 index.jsonl → 改 → 整体重写」,所以每次改索引都在 /.index.lock(open(path, 'wx') 互斥 + 15s 陈旧锁清理 + 8s 超时报错)里做。 两个 session / 两个 subagent 同时写不会互相吃掉对方的条目——宁可报「锁超时」, 也不静默丢数据。
token 口径是固定的,不调用任何 tokenizer:
tokens = Math.ceil(text.length / 1.6)为什么不是 /3:中文在 DeepSeek tokenizer 里 1 字约 0.6~0.7 token,按 /3 会低估近一倍, budget: 1200 实际可能吃掉 2000+ token,预算等于形同虚设。/1.6 对中文基本贴合、 对英文偏保守(英文 1 token ≈ 4 字符)——偏保守的方向是「少放一点」,比悄悄超支安全。
memory_assemble.used_tokens、charsForBudget() 全部用这一个口径。 要换精确口径,改 src/types.ts 的 estimateTokens / CHARS_PER_TOKEN 一处, README 与 fixture 断言一起改。
src/retrieve/keyword.ts 是纯函数 BM25,无外部依赖、无 LLM 调用、确定性可缓存。
切词
ies→y、ing/ed/es/s 去尾)的/是/和…、the/of/and…),含停用字的 2-gram 也丢掉,免得「的兜」「是互」这种语法胶水把 BM25 分数拉平
精确标签加成:查询词正好等于某条记忆的某个 tag/entity 时,分数 ×1.5—— 「tag 里写了 postgresql」比「正文里提了一嘴 postgresql」更可信。
缓存:索引解析与切词结果按「索引文本指纹」缓存在进程内,写入后指纹变化自动失效。 不为每次检索重新切全库。
先用真实口语问题量过一轮,BM25-only 的失败模式不是排序差,而是词汇不重合:
怎么问 · 库里怎么记 · 裸 BM25 包管理器 · pnpm 管理依赖 · ✅ 图片存哪 · 图片上传走对象存储 · ✅ 数据库连的哪个 · sqlite / postgres · ❌ 零命中 上线前要做什么检查 · 提交前跑 verify.sh · ❌ 零命中
于是加了一层别名词表(src/retrieve/aliases.ts,零依赖):
部署→deploy/release/发布…),查询命中组内任一词,就把同组其它词作为低权重补充项 (权重 0.5)参与打分,并允许它们构成命中;
reason: "alias",渲染成「(别名推断)」——让你一眼看出「这是按同义词猜的」而不是字面命中;
/aliases.json 追加:{ "组名": ["词1","词2"] },和内置表合并;文件写坏了直接忽略,不会因为配置错误就搜不了。
同一组 query 的复测结果:4/6 → 6/6 命中,两个原本零命中的都靠别名兜住。