dsh-malleable-memory
Verifieddsh-malleable-memory · v0.1.1 · MIT · Web UI
可塑性记忆(Malleable Memory)— DeepSeek-Harness 的三层记忆与人格插件:永久层/加强层每轮全文注入,卸载层只给目录、正文按需取回;记忆随对话演化,人格与心情随经历改变。
Install
dsh plugin add dsh-malleable-memory Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
可塑性记忆 · Malleable Memory
给 DeepSeek-Harness 的三层记忆与人格插件。 解决 AI 跨会话「不知道你是谁」的问题——记忆按重要度分层注入,用得多的升温、不用的退到后台但不消失。人格随经历改变,心情可以被哄好。
30 秒上手
dsh plugin install dsh-malleable-memory
装完不需要任何配置就会开始工作。你会看到:
- 侧栏多一个 🐟 图标 → 点开是「可塑性记忆」面板(五个 Tab)
- 模型多出 5 个工具:
memory_search/memory_recall/memory_pin/memory_forget/memory_debug - 下一轮对话起,它会开始记住你说过的事
⚠️ 默认会自动从对话中提取记忆并存在本地 SQLite (
$DSH_HOME/memory/malleable-memory.db)。 想关掉自动提取:配置capture: false(已有记忆与面板仍可用)。 想彻底清空:删除该文件即可。
它解决什么问题
长期对话里,AI 会忘。不是"记不住细节",而是根本不知道你是谁—— 上次你说的部署方式、你讨厌的代码风格、你不喜欢被怎么称呼,换个会话就没了。
市面上的长期记忆方案多数是向量检索堆砌:把对话切片存起来,每次按相似度捞几条。问题有两个:
- 贵:存得越多、检索越慢、上下文塞得越满
- 没有优先级:三个月前随口一句话和昨天反复强调的偏好,权重一样
可塑性记忆的做法:按重要度分层,按预算注入,按使用升温。
可塑性记忆把记忆分成三层,按重要度而不是时间决定谁留在上下文里:
| 层 | 注入方式 | 预算 | 放什么 |
|---|---|---|---|
| 永久层 perm | 每轮全文 | 700 tokens | 你明确说"记住"的事、被反复强化的习惯 |
| 加强层 hot | 每轮全文 | 900 tokens | 近期重要的事,会随时间与实际使用自然下沉 |
| 卸载层 cold | 只给目录 | 400 tokens | 久远的事——索引行在上下文里,正文用 memory_recall 按需取回 |
关键设计:卸载层不删除记忆,只是不占预算。 4 条 cold 记忆在上下文里只占
4 行索引,模型看到目录就知道"我好像记得这个",需要时再用 memory_recall 取回正文。
这是与"向量库无限堆积"方案的核心差异——我们控制预算,而不是任由上下文膨胀。
除了记忆,还带一套人格与心情: 你给它起名字、写性格、它被夸会高兴、被冒犯会不高兴、会随时间平复、 你哄它也会好起来。而且这些都写进系统提示词,不是装饰。
快速开始
# 装到当前 DSH profile
dsh plugin install dsh-malleable-memory
或在你的 bundle 的 cordis.patch.yml 里加一行:
- insert:
- id: malleable-memory
name: dsh-malleable-memory
装好后:
- 记忆自动开始工作,无需配置
- 侧栏多一个 🐟 图标 → 点开是「记忆与人格」面板(五个 Tab)
- 模型多了 5 个工具:
memory_search/memory_recall/memory_pin/memory_forget/memory_debug
数据存在 $DSH_HOME/memory/memory.db(SQLite,WAL 模式),
这是插件自己的库文件(首次运行自动创建 memory 目录)。它不会读写其他插件的数据库;只有当你有意把 dbPath 指到同一个文件时才会共用。
面板
| Tab | 看什么 |
|---|---|
| 三层明细 | 每层有哪些条目、重要度 S 值、命中次数、取代关系;已失效的也列出来 |
| 注入文本 | 模型真正看到的那段文本 + token 用量 + 被跳过/降级的条目 |
| 当前心情 | 12 类情绪标签 + 强度 + PAD 三维(愉悦/唤醒/掌控)条形图 |
| 心情历史 | 最近 30 次变化,含触发事件(区分"因为被夸而开心"和"因为任务完成而开心") |
| 人格设定 | 预设选择 + 名字/定位/背景/说话风格 + 4 个性格权重 + 3 个基线 + 实时预览 |
「注入文本」这个 Tab 是排查神器:当记忆"好像没生效"时,这里能直接看到 模型到底收到了什么。
配置
全部可选。写在 bundle 的 cordis.patch.yml 对应条目下:
- insert:
- id: malleable-memory
name: dsh-malleable-memory
config:
# ---- 记忆 ----
dbPath: "C:/Users/you/.dsh/memory/memory.db" # 默认 $DSH_HOME/memory/memory.db
capture: true # 自动捕获记忆,默认 true
budgets: # 三层 token 预算
perm: 700
hot: 900
cold: 400
hotCapacity: 200 # 加强层最多条数,超出挤到卸载层
migration: true # 自动迁移(下沉/升温),默认 true
migrateIntervalMs: 1800000 # 迁移节流,默认 30 分钟
# ---- 人格 ----
persona: true # 总开关
personaMode: "full" # full / light / off(见下)
personaPreset: "dafeiyu" # 初始预设:dafeiyu / assistant-warm / assistant-sharp
personaShowMood: true # 是否把心情注入提示词
# ---- 心情事件判定 ----
moodJudge: "rule" # rule(默认,零成本)/ "llm" / false
moodJudgeTimeoutMs: 8000
# ---- 记忆打分(可选 LLM)----
llmScoring: false # 默认关
llmProvider: "" # 例 "deepseek"
llmModel: "" # 例 "deepseek-chat"
llmTimeoutMs: 12000
# ---- 面板 ----
panel: true # 是否注册面板路由(仅本机回环可访问)
# ---- 向量检索(进阶,默认不需要动)----
embedding: true # 是否启用向量检索(默认 hash 版)
embeddingDim: 256 # hash 向量维度
embeddingExternal: "" # 外部向量提供方(留空 = 用 hash)
embeddingUseHashFallback: true # 外部提供方失败时回落 hash
personaMode 三档
| 档位 | 内容 | 篇幅 |
|---|---|---|
full |
完整人设 + 心情 + 说话风格逐条 | ~225 字符 |
light |
名字 + 一句定位 + 一条口吻约束,不含心情 | ~73 字符 |
off |
不注入 | 0 |
写代码时建议 light:保留角色口吻,但不会让情绪干扰技术输出。
设计要点
为什么按重要度分层,而不是按时间
时间衰减是必要的,但不能只看时间。一条三个月前的、 你反复强化的偏好,比昨天随口说的一句话重要得多。
所以每条记忆有一个重要度 S:
S = 0.35 × 明确度 + 0.25 × 重复度 + 0.20 × 情绪强度 + 0.20 × 结构完整度
并且被用到会升温:memory_recall 会记录命中,一段时间内命中多次的
cold 条目会升回 hot。这就是"可塑性"——用得多的记忆变强。
心情为什么是连续量
如果只有"开心/生气"两个状态,表达不了"有点不高兴但还没到生气"。 所以主状态是 PAD 三维连续量(愉悦度、唤醒度、掌控感,各 -1~1), 12 类情绪标签是它的投影,只用来展示和喂给模型。
这样"被哄好"才能自然发生:安慰事件把愉悦度推正、唤醒度推低, 加上时间回摆,状态会连续地从"难过"→"低落"→"平静"→"放松"。
「生气」和「难过」在 PAD 里指向不同区域: 愤怒是低愉悦 + 高唤醒 + 高掌控(反击姿态), 难过是低愉悦 + 中唤醒 + 低掌控(受害者姿态)。 所以"被冒犯"和"被激怒"是两个不同事件,不会混为一谈。
学术依据:PAD 模型(Mehrabian 1996)定义了情绪的三维空间; MECoT(ACL 2025 Findings)通过马尔可夫转移 + 人格加权矩阵验证了 12-16 类是最优情绪粒度;MATE(2026)实现了纯函数转移与 opponent-process 回摆。
⚠️ 不确定性声明:PAD 各原型的方位在多篇文献中一致,但具体坐标数值不统一, 本项目未获得 Mehrabian 原始论文的精确坐标表。代码里的 12 类质心坐标是 "方位正确、量级合理"的构造值,不可引用为原始数据。
为什么默认不用向量模型
我们认真评估过外部向量模型(bge-small-zh-v1.5,int8 ONNX 22.9 MB),
在本项目的评测集上:
- BM25(中文按二元组切分)的 MRR 是 0.540
- 向量方案的 MRR 是 0.552
- 基本持平,但向量方案会带来 472.6 MB 的安装体积(onnxruntime-node 287.1 + onnxruntime-web 138.7)
结论:默认不用向量,保留外部 provider 作为逃生口。这是"工程判断优于跟风"的证据—— 不是所有场景都需要向量,BM25 在命中准确度可接受时更轻量。
安全边界
- 面板路由只接受本机回环:校验 socket 地址 + Host 头,并拒绝非 JSON 的 Content-Type、校验 Sec-Fetch-Site 与 Origin,用来挡住浏览器发起的跨站请求
- ⚠ 这不是身份认证:本机上以你权限运行的其他程序仍可直接调用这些路由。本插件不做进程级鉴权(那需要 OS 能力)
- 只接受 POST,请求体上限 64 KB
- 只允许访问已经见过的工作区,不能越权读别处
- 面板的写操作只到人格与心情两张表,不碰记忆正文与状态
- 默认不联网。只有显式开启以下功能时才发网络请求:
- llmScoring: true → 把待入库候选文本发给 llmProvider 指定的模型
- moodJudge: "llm" → 把每轮用户消息发给该模型判定事件
- embeddingExternal 配了外部 provider → 把记忆文本发给它做向量化 以上都走你自己配置的服务,不经过本项目作者的任何服务器
已知限制
诚实说明,这些是实测确认的:
- 进程被强杀时,收尾迁移不执行。正常关闭会执行;强杀靠 WAL 保证数据 一致性,但"该下沉的没下沉"要等下次启动。
agent/disposed拿不到工作区路径。多工作区同时使用时这个钩子会跳过 收尾(不猜,猜错会把 A 的迁移跑到 B 上)。- 面板的视觉效果只做了结构验证。功能与数据链路有 893 条断言覆盖, 但"看起来对不对"需要人眼确认。
- memory_forget 没有撤销工具。它只把状态改成 vetoed,行仍在库里,但插件不提供恢复功能;要恢复得手工改数据库,且不保证后续版本仍可行 —— 动手前请先备份。
- embedding 只支持 hash 版(默认)。外部向量模型是可选项,但实测在本项目 的评测集上没有超过 BM25,所以默认不用。
开发
技术栈:
- 运行时:Node ≥22.19 或 ≥24(依赖
node:sqlite) - 数据库:SQLite,WAL 模式,schema 版本 4
- 依赖:零运行时 npm 依赖(用 Node 内置模块)
- 包体积:19 个文件,解压约 316 KB
- 测试:893 条断言,29 个测试文件(在项目仓库,不在 npm 包里)
# 源码结构
lib/
index.js 插件入口:hook、工具、prompt section
memory-core.js 记忆引擎:SQLite schema、迁移、BM25 检索、分层装配
persona-core.js 人格内核:PAD 模型、情绪转移、纯函数、零依赖
event-judge.js 事件判定:规则版 + 可选 LLM
panel-routes.js 面板 Host 侧路由(回环鉴权)
client.js 面板 Client bundle(手写,无构建链)
llm-scorer.js 可选的 LLM 记忆打分
embeddings.js 向量提供方
vector.js 向量计算
设计上有一条硬边界:memory-core.js 与 persona-core.js 不依赖宿主。
它们可以在 Node 里直接 import 并单测,不需要 DSH 运行时。
这是为什么测试能跑到 893 条断言。
测试在项目仓库的 05_验证与测试/,不在 npm 包里。
许可
MIT。见 LICENSE。