全局记忆 · dsh-global-memory
给 DeepSeek Harness(DSH)用的一条全局性记忆:一段跨会话、跨任务的常驻要求,会被自动注入每一个会话的 system prompt,所以「每一次任务都必须遵循」。
仓库:https://github.com/07vvvv/dsh-global-memory · License: MIT · Node.js >=22
它解决什么问题
- 每次开新会话都要重新叮嘱同一件事(「用简体中文」「改完必须跑测试」「不确定就说不确定」),重复输入、容易漏、换个人接手就丢。
- 本插件把这类要求固化成本地的一个 JSON 文件,由宿主在每轮对话组装 system prompt 时无条件注入——新开会话、切换任务、换 agent 都照样生效。
- 一句话:写一次,之后每一次会话、每一项任务都遵循。
三条链路
| 链路 | 触发方式 | 行为 |
|---|
| 每轮注入 | 自动 | ctx.systemPrompt.section({ name: 'global-memory', order: 555, text });正文或开关变化后立刻重建,下一次请求(下一条消息)即生效,无需重启宿主 |
| Agent 工具 | 模型自行调用 global_memory | 7 个 action:get / set / append / clear / reset / enable / disable |
| 设置页面板 | 设置 → 全局记忆 | 可视化编辑正文、字符数/更新时间、保存、启用/禁用、恢复默认、重新加载;也可 Ctrl/⌘ + S 保存 |
三条链路读写的是同一个真源(存储文件),因此工具改完,设置页刷新即可看到;设置页保存后,工具立刻读到。
与 memory-eternal 的区别
功能对标 memory-eternal 的「全局总记忆」,但完全独立,可同时安装、互不干扰:
| 维度 | dsh-global-memory | memory-eternal |
|---|
| 存储 | 自己的 JSON 文件 $DSH_HOME/global-memory.json | 自己的 SQLite / 数据库 |
| prompt 段 | 段名 global-memory,order 555 | 独立段,order 600(排在本插件之后) |
| 设置页 | 自己的「全局记忆」section | 自己的设置页 |
| 工具 | global_memory | 它自己的工具集 |
| 耦合 | 不读不写、不 import 对方任何文件/模块 | 同左 |
安装
前提:
- 宿主(DSH)版本落在
>=0.1.5-alpha.2 <0.2.0,目标 profile 为 web 或 desktop(详见 兼容性)。
- Node.js
>=22。
- profile 目录当前是
~/.dsh/profiles/<profile>/(Windows:%USERPROFILE%\.dsh\profiles\<profile>\)。
方式 A:手工接入 profile(通用,不依赖任何插件管理 UI)
1. 取得代码
git clone https://github.com/07vvvv/dsh-global-memory.git "<你 clone 的路径>"
2. 构建客户端面板(通常可跳过)
lib/client.js 是设置页面板的构建产物,已随仓库提交。只有你改了 src/client/** 才需要重建:
cd "<你 clone 的路径>"
npm install # 只为装 devDependencies 里的 esbuild
npm run build:client # esbuild 打包 src/client/index.tsx → lib/client.js
3. 把包登记进 profile 的 package.json
编辑 ~/.dsh/profiles/<profile>/package.json(Windows:%USERPROFILE%\.dsh\profiles\<profile>\package.json):
{
"dependencies": {
// …… 你原有的依赖保持不动 ……
"dsh-global-memory": "link:<你 clone 的路径>"
},
"dsh": {
"profile": {
"bundles": [
// …… 你原有的 bundles 保持不动,本项是「追加」 ……
"dsh-global-memory"
]
}
}
}
要点:
dependencies 里用 link: 前缀指向你 clone 的目录;路径建议一律用正斜杠 /,可以绕开 Windows 的反斜杠转义问题。
dsh.profile.bundles 是数组追加,不要整段替换——替换会让其它插件一起消失。
- 这两处改动都在 profile 自己的
package.json 里,不需要动插件仓库里的任何文件。
4. 在 profile 的 node_modules 里建立链接
若你的包管理器支持 link: 协议(npm / pnpm / yarn 都支持),在 profile 目录执行一次安装即可自动建链;若不确定或想手工来,直接建目录链接:
Windows(cmd;mklink /J 建 junction,不需要管理员权限):
cd /d "%USERPROFILE%\.dsh\profiles\<profile>"
if not exist node_modules mkdir node_modules
mklink /J "node_modules\dsh-global-memory" "<你 clone 的路径>"
Windows(PowerShell 里调用 cmd 内建命令):
$profileDir = Join-Path $env:USERPROFILE ".dsh\profiles\<profile>"
New-Item -ItemType Directory -Force (Join-Path $profileDir "node_modules") | Out-Null
cmd /c mklink /J "$profileDir\node_modules\dsh-global-memory" "<你 clone 的路径>"
类 Unix(macOS / Linux):
cd ~/.dsh/profiles/<profile>
mkdir -p node_modules
ln -s "<你 clone 的路径>" node_modules/dsh-global-memory
5. 重启宿主
dependencies 与 dsh.profile.bundles 在宿主启动阶段解析装配,因此新增 bundles 项后需要重启一次宿主进程。重启后由 bundles 完成正常装配,之后每次启动都自动加载,无需重复操作。
6. 验证装上了
- 宿主日志出现一行:
[global-memory] 已加载:注入中,<N> 字,存储 <路径>
- 设置页出现「全局记忆」section
- 同源请求能拿到状态:
curl -s http://127.0.0.1:<宿主端口>/global-memory/api/state
# → {"ok":true,"enabled":true,"text":"…","chars":123,"updatedAt":"…","defaults":{…},"path":"…"}
<宿主端口> 就是你打开 GUI 用的那个端口。
方式 B:用插件管理 UI / 运行时注入器(若你的环境已装)
如果你的 DSH 里已经有带安装能力的插件(例如 dsh-super-injector 的注入工具,或插件市场类插件),可以把它当普通本地插件包安装:
-
一次性装配(写 profile 的 dependencies + dsh.profile.bundles + 建链接 + 运行时加载,等价于方式 A 的 3~5 步,无需手工编辑):把插件目录交给安装入口,例如
dev_install_package(dir = "<你 clone 的路径>")
-
只想临时试用、不落盘到 profile(改注入器的运行时注册表,不动 package.json,宿主重启后失效):
dev_inject_plugin(dir = "<你 clone 的路径>")
-
卸载对应地用该插件提供的卸载入口(见 卸载)。
前提:上述工具由对应插件提供,装了才有;命令的参数形态以该插件自己的文档为准。若都没有,走方式 A。
配置
配置层:config.enabled
cordis.patch.yml 里声明的装配开关:
- insert:
- id: global-memory
name: dsh-global-memory
config:
enabled: true # false → 整插件不加载(apply 立刻返回)
config.enabled: false 时:注入、global_memory 工具、/global-memory/api/* 路由全部不存在,就像没装这个插件;依赖与 bundles 项仍在。后层 patch(profile 自己的 cordis.patch.yml 或 --patch)可以按 id global-memory 覆盖/禁用它。
存储文件:$DSH_HOME/global-memory.json
DSH_HOME 未设置时回退为 ~/.dsh(Windows:%USERPROFILE%\.dsh),所以默认完整路径是:
- Windows:
%USERPROFILE%\.dsh\global-memory.json(或你设的 %DSH_HOME%\global-memory.json)
- 类 Unix:
$DSH_HOME/global-memory.json(默认 ~/.dsh/global-memory.json)
字段表:
| 字段 | 类型 | 说明 |
|---|
version | number | 存储结构版本,当前为 1;读取时按当前版本归一化 |
enabled | boolean | 运行期开关。true 注入;false 不注入任何内容(正文仍保留在文件里) |
text | string | 全局记忆正文。空字符串 → 不注入(保持原文,不自动补默认) |
updatedAt | string | 最后一次写入的 ISO 8601 时间;从未写入过为空串 |
文件不存在时:
{
"version": 1,
"enabled": true,
"text": "……内置默认正文……",
"updatedAt": ""
}
行为约定(据此排错):
- 读取永不抛异常:文件缺失 / JSON 损坏 / 字段类型不对 → 回退默认值(默认即启用),一个坏文件不会把宿主启动打挂。
- 损坏时不覆盖你的文件:只在内存里用默认值,原文件原样留着,交给你修。
- 写入原子:先写同目录临时文件再
rename 覆盖,进程中断不会留下半截 JSON。
- 只有显式
false 才算关闭:enabled 缺失或类型不对,按启用处理。
运行期开关语义(enabled 字段)
| 层级 | 手段 | 效果 |
|---|
| 配置层 | config.enabled: false | 整插件不加载:注入 + 工具 + API 全消失 |
| 运行期 | enabled: false(工具 disable / 设置页开关 / POST /enabled) | 只停止注入:不卸载工具、不删正文、不删路由;enable 立刻恢复 |
也就是说「暂时不想让它影响会话」用运行期开关即可,正文不会丢。
启用 / 禁用 / 卸载
三个层级由轻到重:
1. 运行期停注入(最快、可逆)
- 模型侧:
global_memory 工具 action=disable / action=enable
- 设置页:关闭/打开「已启用」开关
- HTTP:
POST /global-memory/api/enabled,body { "enabled": false }
正文保留,随时恢复。
2. 配置层停用整插件
在 profile 自己的 patch 里按 id 写覆盖(示例):
- insert:
- id: global-memory
name: dsh-global-memory
config:
enabled: false
此后注入、工具、HTTP 路由全部不注册。若你的 profile 设了 dsh.profile.patchReload: "live",patch 改动可热生效;否则重启宿主。此法不删除依赖与 bundles 项。
3. 整包卸载
手工路线(与方式 A 对称):
- 从 profile
package.json 的 dependencies 删除 "dsh-global-memory";
- 从
dsh.profile.bundles 数组删除 "dsh-global-memory";
- 删除链接:
%USERPROFILE%\.dsh\profiles\<profile>\node_modules\dsh-global-memory(Windows)或 ~/.dsh/profiles/<profile>/node_modules/dsh-global-memory(类 Unix);
- 重启宿主。
用注入器路线(方式 B 装的):调它自己的卸载入口,例如
dev_uninject_plugin(match = "dsh-global-memory")
它会卸掉运行时注册、删除 profile 里的链接,并写一条 disabled patch 防止刷新时被自动加回;宿主重启后即完全消失。
数据保留与删除:
- 卸载不动
$DSH_HOME/global-memory.json:用户数据保留,日后重装即恢复原正文。
- 想删数据就删该文件。⚠️ 注意:删文件 ≠ 停用——文件缺失时插件回退为「内置默认正文 + 已启用」,下次启动会重新注入默认内容。若目标是「空且不注入」,请先把正文清空、开关关掉(写
{"version":1,"enabled":false,"text":"","updatedAt":"…"}),再删文件或保留文件。
Agent 工具:global_memory
模型可见的工具,描述里已写明「当用户要求你『记住一条全局要求』『以后每次都…』『改掉/清空全局记忆』,或你需要确认当前全局要求时调用」。参数:{ action: string, text?: string },action 必填。
| action | 要不要 text | 语义 | 返回(中文纯文本) |
|---|
get | 否 | 读取当前状态与正文 | 已启用 → 状态 + 字符数 + 更新时间 + 正文;已禁用 → 提示禁用与保留字数;空正文 → 提示不注入 |
set | 是 | 整体替换正文(不动 enabled) | 已保存 + 字数 + 「已对后续所有会话生效」;未给 text → 提示未修改 |
append | 是 | 在正文末尾追加一条(并自动启用) | 追加后的总字数 |
clear | 否 | 正文置空(不注入,enabled 不变) | 已清空 + 可用 reset 恢复默认 |
reset | 否 | 写回内置默认正文并启用 | 已恢复默认 + 字数 |
enable | 否 | 打开注入 | 已启用 |
disable | 否 | 关闭注入(正文保留) | 已禁用,随时可 enable |
补充:
- 任何写入成功后立刻重建 prompt 段,下一次请求即带上新内容,无需重启宿主。
- 写入失败不抛给宿主,返回「全局记忆操作失败:<原因>」。
output 声明为 { schema: { type: 'string' }, render },即模型看到的是纯文本结果。
HTTP API
前缀 /global-memory/api,同源(设置页面板用相对路径 fetch)。响应一律 application/json; charset=utf-8,错误也是 JSON(不用 HTML),并带 cache-control: no-store。
| 方法 | 路径 | 请求体 | 成功响应(200) |
|---|
| GET | /global-memory/api/state | — | { ok:true, enabled, text, chars, updatedAt, defaults:{ text, chars }, path } |
| POST | /global-memory/api/state | { enabled?: boolean, text?: string }(至少给一个) | { ok:true, enabled, text, chars, updatedAt } |
| POST | /global-memory/api/text | { text: string } | { ok:true, text, chars, updatedAt } |
| POST | /global-memory/api/reset | — | { ok:true, text, chars, updatedAt }(恢复默认正文并启用) |
| POST | /global-memory/api/enabled | { enabled: boolean } | { ok:true, enabled, updatedAt } |
错误:
| 状态 | 场景 | 响应 |
|---|
| 400 | body 不是合法 JSON / 字段类型不对 / POST /state 两者都没给 | { ok:false, error:"中文原因" } |
| 404 | 未知端点 | { ok:false, error:"未知端点 …(可用:/state /text /reset /enabled)" } |
| 405 | 非 GET/POST | { ok:false, error:"不支持的方法 …(仅 GET / POST)" } |
| 500 | 写入抛错 | { ok:false, error:"…" } |
前缀根路径(/global-memory/api 或 /global-memory/api/)等同 GET /state;enabled / text 类型不对会被显式拒绝,不会静默写成默认值。
鉴权与风险:没有鉴权,也不做 CSRF 校验——它假定宿主只监听本机回环地址(127.0.0.1)。任何能在本机访问该端口的程序/页面都能读取和改写你的全局记忆。不要把宿主端口暴露到公网/局域网;确需远程访问,请自己在前面加一层带鉴权的反向代理。
与宿主 API 的对接点
本插件只用三个宿主服务,全程字符串服务名,host 侧不 import 任何 @deepseek-ai/* 包(因此不会因 profile 依赖解析失败而加载不了)。
| 服务 | 关系 | 调用形态 | 用途 |
|---|
systemPrompt | 必需(export const inject = ['systemPrompt'],缺它插件不激活) | ctx.systemPrompt.section({ name, order, text }) → 返回 disposer | 每轮注入正文 |
tools | 可选(ctx.inject(['tools'], …)) | tools.register({ name, description, parameters, output, execute }) → 返回 disposer | 注册 global_memory |
webServer | 可选(ctx.inject(['webServer'], …)) | webServer.register({ kind: 'prefix', path, handler }) → 返回 disposer | 注册 /global-memory/api/* |
tools.register 接受裸对象:只需 output.schema + output.render 与 parameters 符合形状(与 defineTool() 产物一致),无需 import 宿主包。
最小可运行片段
把下面这段存成 index.js 就是一个能跑的 DSH 插件骨架(注入 + 工具 + 路由三条链路齐全):
export const name = 'my-plugin'
// 必需服务:缺 systemPrompt 时插件不激活(例如纯 TUI profile)
export const inject = ['systemPrompt']
export function apply(ctx, config) {
if (config?.enabled === false) return // 配置层总开关
// ① 常驻注入:name 需全局唯一(不同插件同名会互相覆盖),order 决定在 prompt 中的位置
ctx.effect(
() => ctx.systemPrompt.section({ name: 'my-plugin', order: 555, text: '## 我的要求\n\n……' }),
'my-plugin: system prompt section',
)
// ② Agent 工具:用 inject 等服务就绪;服务晚到也不会丢
ctx.inject(['tools'], (child) => {
const tools = child.get('tools')
if (tools === undefined) return
child.effect(
() =>
tools.register({
name: 'my_tool',
description: '这个工具做什么、什么时候该调用。',
parameters: {
type: 'object',
properties: { action: { type: 'string', enum: ['get', 'set'] } },
required: ['action'],
additionalProperties: false,
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: String(value ?? '') }],
},
async execute(args) {
return 'ok'
},
}),
'my-plugin: my_tool',
)
})
// ③ HTTP 路由(设置页 UI 的数据源)
ctx.inject(['webServer'], (child) => {
const webServer = child.get('webServer')
if (webServer === undefined) return
child.effect(() => {
const dispose = webServer.register({
kind: 'prefix',
path: '/my-plugin/api',
handler: (req, res) => {
res.statusCode = 200
res.setHeader('content-type', 'application/json; charset=utf-8')
res.setHeader('cache-control', 'no-store')
res.end('{"ok":true}')
},
})
return () => { try { dispose?.() } catch { /* 卸载时清理失败不阻塞 */ } }
}, 'my-plugin: api routes')
})
}
三个必须知道的坑(本插件实际踩过)
- 可选服务用
inject,不要用同步 get。 webServer 常常在本插件 apply() 时还没挂载,此时同步 ctx.get('webServer') 拿到 undefined,而你再也等不到第二次机会——表现是路由注册不上、请求恒 404。正确做法是 ctx.inject(['webServer'], child => …),等服务就绪后回调里再 child.get('webServer');依旧要判 undefined(服务可能始终不存在,这时插件必须照常可用,只是没有 HTTP 链路)。本插件在缺 tools / webServer 时,注入功能照常工作。
- 注册即返回 disposer,必须交给
ctx.effect。 ctx.systemPrompt.section(...)、tools.register(...)、webServer.register(...) 都返回撤销函数。用 ctx.effect(() => register(...), '标签') 把「注册」和「清理」配套:插件卸载或热重载时 ctx.effect 会调用清理,否则段落/工具/路由会残留到宿主重启。
- 服务名用字符串,别 import 宿主包。 用
'tools' / 'webServer' / 'systemPrompt' 这类字符串服务名,host 侧就可以做到零 @deepseek-ai/* 依赖(tools.register 接受裸对象)。好处是包在任意 profile 里都不会因为依赖解析失败而加载不了;代价是没有类型检查,字段形状要自己对齐。
服务可用性与依赖声明
-
host 侧不声明 peerDependencies:因为不 import 任何宿主包,只按字符串名取服务。必需/可选关系由 export const inject = ['systemPrompt'](必需)与 ctx.inject([...])(可选、可缺失)表达。
-
client 侧(设置页面板)通过 package.json 的 dsh.client.inject 声明它需要的客户端服务:
"dsh": {
"client": {
"platform": "web",
"inject": ["@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-locale"]
}
}
缺这两个客户端服务时,只是设置页不出现,host 的注入/工具/API 不受影响。
-
目录里没有任何 @deepseek-ai/* 运行时依赖,dependencies 为空;唯一 devDependency 是打包用的 esbuild。
兼容性
| 项 | 要求 | 依据 |
|---|
| DSH | >=0.1.5-alpha.2 <0.2.0 | package.json 的 dsh.compatibility.dsh(声明,宿主不一定强制校验) |
| Node.js | >=22 | package.json 的 engines.node |
| profile | web、desktop | dsh.compatibility.profiles / dsh.marketplace.profiles |
| 宿主服务 | 必需 systemPrompt;可选 tools、webServer | index.js 的 inject 与 ctx.inject |
| 客户端服务 | @deepseek-ai/dsh-client-ui-settings、@deepseek-ai/dsh-client-locale | dsh.client.inject |
| 运行时依赖 | 无 | host 是纯 ESM 源码,零 @deepseek-ai/* import |
| 构建 | 仅客户端面板需要 esbuild | npm run build:client |
目录结构
dsh-global-memory/
├── index.js # host 入口:注入 + 工具 + HTTP 路由(三条链路)
├── lib/
│ ├── store.js # 存储层:读取永不抛、写入原子、损坏回退默认
│ ├── api.js # HTTP API 纯函数层(不依赖宿主,可单测)
│ ├── defaults.js # 内置默认全局记忆正文
│ └── client.js # 客户端面板构建产物(由 build.mjs 生成,随仓库提交)
├── src/client/index.tsx # 设置页面板源码(TypeScript + JSX)
├── build.mjs # esbuild 打包:src/client → lib/client.js(含 __ModuleLoader__ 包装与产物自查)
├── cordis.patch.yml # bundle 补丁层:装配本插件(config.enabled 开关在此)
├── package.json # 包元数据 + dsh 元数据(client/bundle/marketplace/permissions/compatibility)
├── CONTRACT.md # 工程内部接口契约(开发用)
├── docs/
│ └── INTEGRATION.md # 怎么照着本插件写一个 host+client 双端插件
├── tests/ # 回归测试
├── README.md
└── LICENSE # MIT
已知限制
- 只有一条记忆:单个纯文本字段,没有多条目、标签、分类、检索、版本历史。要分类就用换行分条自己排。
- 常驻 = 每轮都占上下文预算:正文越长,system prompt 越长,开销与注意力稀释越明显。建议控制在几百字以内,只写「能判断做到/没做到」的行为要求。
- 生效时机是「下一条消息」:写入后立刻重建 prompt 段,下一次请求即带上;已经发出的当前这一轮不变。
- 不做内容校验:写什么就注入什么,没有敏感词过滤、没有权限校验、没有长度上限(只受宿主提示词上限约束)。
- HTTP API 无鉴权:依赖宿主只监听回环地址。不要把端口暴露出去(见 HTTP API 的鉴权说明)。
- 删文件 ≠ 停用:存储文件缺失会回退为「默认正文 + 已启用」。要「空且不注入」,先清空正文并关闭开关。
- 损坏文件不自动修复:JSON 坏掉时只在内存里用默认值,原文件保留不动;插件日志会标注「文件损坏,暂用默认值」,需要你手工修复或删除。
- 注入顺序是约定:
order: 555 的本意是排在 memory-eternal 的 600 之前;如果对方改了 order,相对顺序会变。
- 未适配纯 TUI profile:缺
systemPrompt 服务时不激活(会在日志里看不到已加载行),已声明支持的 profile 为 web / desktop。
- Windows 建链接:目录链接用
mklink /J(junction,无需管理员)或 mklink /D(符号链接,需要管理员/开发者模式)。
开发
npm install # 装 esbuild(唯一的 devDependency)
npm run build:client # 重新打包客户端面板 → lib/client.js(构建产物已提交)
npm test # 跑回归测试
改了 src/client/** 之后必须重跑 npm run build:client,否则设置页用的还是旧产物;build.mjs 会在打包后自检(__ModuleLoader__ 包装、包 id、/global-memory/api 字面量、settings.section 注册、React 外部化、产物体积),失败即 exit 1。
想照着本插件写一个自己的 host+client 双端插件,见 docs/INTEGRATION.md。
License
MIT