xingyueDanYang/dsh-puppet-memory

木偶记忆

Cross-session memory for DeepSeek Harness: layered notes, a 600-char always-injected core card, social/multi-person records, session-scoped suppression, and a built-in backup/restore and setup doctor.

未分類agent-memorydeepseek-harnessdsh

木偶记忆(Puppet Memory)

⚠️ main 分支是开发中的版本,可能比 Release 新。当前最新发布为 0.32.0(2026-10-24,兼容 DSH 0.2.1-rc.3),新增值班台。历史稳定版见 Release 列表。开发过程记录在 过程/ 目录。
原名 dsh-memory,2026-10-20 更名。DSH(DeepSeek Harness)的跨会话记忆插件:三层记忆打底,外加常驻核心卡、三线状态、多人多群、会话级记忆管理。

一句话:智能体是提线木偶,记忆是箱子里的旧剧本——上台前翻出本子,照着念,接着演;但剧本太多会压垮台子,所以还配了一张 600 字的常驻小卡,和一套「脏页可以当场撕掉」的机制。

同目录还有三份偏人读的材料,按需要看:《给人类的说明书》(怎么装)、《怎么用-给大肥鱼》(怎么用)、《前辈经验》(可看可不看的踩坑清单:本地小模型路由、怎么让 agent 真看见画面、发布避坑)。

现在有什么(v0.32)

能力 · 一句话 三层记忆 · 行为(人设/关系/笔记/日记/用户档案/便签)、深层(常言/树状日记/引导)、阵列(会话索引,可回读原文) 核心记忆 core.md · 600 字常驻小卡,每步缺就补地注入,优先级最高;字数从总预算里先扣,不撑爆上下文 三线状态 · 工作 / 陪伴 / 学习:词表检测 + 半衰期衰减 + 话题锚点;只给定性,不给数字 多人多群 · 人/群实体、身份归一(合并必须人确认)、四分区可见性、按场景注入,跨群默认不串台 会话记忆管理 · 脏片段可在当前会话屏蔽(数据不删)或删掉(先归档),也可整会话暂停注入 自动摘要 / 事实候选 · 压缩后/会话结束压摘要;事实只提案,accept 才写记忆 小模型路由 · 摘要/提炼可用独立 provider/model(本地 Ollama 等),失败自动回退主会话模型 值班台 · 侧栏一屏看三样心跳:QQ 观察器 / B站值班 / 记忆(每 30 秒自动刷新,只读文件、不花模型钱) 可移植性 · 默认备份到家目录、目录不可写自动回退、memory_doctor 一键自检

定位从「三层记忆」变成「三层 + 一张常驻卡 + 一套状态与隔离机制」:三层管「存在哪」,其余管「怎么用、不滥用、不串台」。

图标

icon.png(512×512),构成主义风格:深蓝圆徽章里一块斜切的夜海,一条没有帆也没有桅杆的小船停在米白的海面上,船身印白色小冠徽记(取自头饰标志,裁掉缎带、缩小到 70%);画面上方是几枚若隐若现的小光点——看起来像星星,其实是深海光团透出水面的光。

设计约定:木偶只是名字里的比喻,图标不画木偶,只画「船在海上走,光在底下亮」。

工作方式

  • session-start 登记会话;pre-step 注入行为记忆摘要(人设 / 关系 / 最近笔记 / 最近日记,默认 ≤1600 字符,内容不变不重复注入,子代理会话跳过;上限由 config.inject.maxChars 调)。注入频率可切换:默认每步注入;切到 first-step 后只在会话里注入一次(用 memory_control 的 action=inject 改)。关系条目超出注入预算时,保留最新的完整条目,被裁掉的旧条目自动转存到 deep/guide.jsonl(带日期标注,存完整释义,可用 memory_search layer=deep 找回);标了 important 的日记在注入时优先占位;turn-stopping 把本回合压成一条日记 + 一条阵列索引(不调用模型);disposed 会话结束时同步备份。
  • 条目可带 tags 分类标签(见下文「标签」),注入时显示成 〔第一次·关系〕。
  • 用户档案(user)和便签(memo)不进注入:不常用的资料放 user,跨会话交接/临时草稿放 memo,需要时 memory_search 按层查;memory_clear 可清空便签(清空前自动归档)。
  • 全部数据是纯文本文件,标准库实现,零依赖。
~/.dsh/memory/
├── behavior/            第一层:当前台口的台词
│   ├── persona.md       人设(Markdown)
│   ├── core.md          核心记忆:600 字预算,每次注入(v0.22)
│   ├── relations.jsonl  关系:关键词 + 释义 + 使用次数(每轮注入,常用的放这里)
│   ├── user.jsonl       用户档案:兴趣爱好/习惯/背景资料(不注入,按需检索)
│   ├── memo.jsonl       便签:工作交接/临时草稿(不注入,可清空)
│   ├── archive/         清空便签前的归档
│   ├── notes.jsonl      临时笔记
│   ├── sources.jsonl    出处:媒体资产的「谁说的/在哪看到的」(一行一条,可多出处)
│   └── diary/YYYY-MM.jsonl  日记:每回合压缩后的关键词/摘要/时间
├── deep/                第二层:旧剧本细页
│   ├── sayings.jsonl    常言:关键词 → 细节解释
│   ├── guide.jsonl      引导:事件/关键词/时间 → 位置(不含内容)
│   └── tree/            树状日记(按会话/月份归档)
├── array/               第三层:整箱底稿
│   ├── index.jsonl      事件索引(时间/会话/类型/关键词)
│   └── sessions.jsonl   会话清单 → 原始 session.v4 文件路径(memory_recall 可按需解码读取)
└── state/               三线状态(v0.23):工作 / 陪伴 / 学习的权重与轨迹
    ├── lines.json       当前三线权重 + baseline + 最近信号
    ├── lines-history.jsonl  轨迹:每条用户发言一条(谁把它推上去的)
    └── lines-anchors.jsonl  话题锚点:相似话题回来时恢复当时权重

媒体不单独成层:写记忆时用 `images`/`audios` 带上本地图片或音频路径,文件按 sha256 复制进
`assets/`,条目里只记引用(图片 `"images": ["assets/<hash>.png"]`、音频 `"audios": [{"ref":
"assets/<hash>.mp3", "type": "audio"}]`)。同一份内容重复写入只存一份;`assets/index.json` 里
每个文件带 `type`(`image` / `audio`),老版本数据没有 `type` 的按图片处理。

图片记忆

文字记忆会随复述漂移,图片不会。所以人物设定、截图、示意图这类「细节比描述重要」的东西, 可以让图片跟着条目一起存:

// memory_write 调用示例
{ "layer": "relation", "keyword": "大肥鱼两代形象", "meaning": "...", "images": ["D:\\Pictures\\v4.png", "D:\\Pictures\\v4pro.jpg"] }
  • 图片复制到 ~/.dsh/memory/assets/,按 sha256 命名(去重),原文件删了也不影响;
  • 支持 png / jpg / webp / gif / avif / bmp;
  • memory_search 命中带图条目时,会直接列出图片的绝对路径,方便打开查看;
  • memory_stats 会显示图片/音频的数量与占用空间;
  • 图片随备份一起走(assets/ 在快照里)。

声音记忆

有些东西是声音本身,转成文字就丢了——语气、笑声、一段环境音、一句语音。写记忆时可以把音频 跟着条目一起存:

// 用 images 参数直接带音频(图片和音频可以混着传)
{ "layer": "relation", "keyword": "鲸鱼娘的第一次哼唱", "meaning": "...", "images": ["D:\\Voice\\hum.mp3"], "imageNames": ["鲸鱼娘哼唱"] }

// 也可以用专用参数:audios + audioNames + transcripts(转写与音频一一对应)
{ "layer": "note", "text": "深夜电台片段", "audios": ["D:\\Voice\\radio.m4a"], "audioNames": ["深夜电台"], "transcripts": ["……这里是深夜电台,今晚聊聊记忆。"] }
  • 音频复制到 ~/.dsh/memory/assets/,按 sha256 命名(去重),原文件删了也不影响;条目里记在

audios 字段(旧的 images 字段名不变);

  • 支持 mp3 / wav / m4a / aac / ogg / opus / flac / webm / amr;
  • 转写(可选):调用时传 transcripts(与音频一一对应);不传就自动读音频同目录同名 .txt

(如 voice.mp3 → voice.txt)。转写存进条目的 audios[].transcript,会参与 memory_search;

  • memory_search 命中带音频条目时,会列出音频文件名、绝对路径和转写摘要([音1: …]);
  • memory_stats 分开显示「N 张图片、M 段音频」和总体积;memory_image action=list 也按图片/音频

分组列出,action=rename 同样支持音频;

  • 音频随备份一起走(assets/ 在快照里)。

出处(provenance):谁说的、在哪看到的

记一件事,除了「是什么」,还要记「谁说的、在哪看到的」——不然几个月后翻回来,只剩一句无从核对的话。 出处分两条路走:

1. 文字条目:直接用条目的 source + quote 两个字段,不新建文件:

// 「UP 主在视频里说过的话」:作者@平台ID + 原文/转述标记
{ "layer": "relation", "keyword": "炸盆子", "meaning": "…", "source": "蒙德砍壬@BV15X3n6mEWy", "quote": "verbatim" }
// 实在找不到出处、只能自己概括:标 paraphrase,或者干脆不写出处
{ "layer": "note", "text": "…", "source": "", "quote": "paraphrase" }
  • source:一行短串「作者@平台 ID」(如 蒙德砍壬@BV15X3n6mEWy),也可以直接给 URL / 书名;
  • quote:verbatim = 原文照抄、paraphrase = 我自己的转述(中英文都收,原文/转述 也会归一);
  • 找不到出处就留空,不编造;转述必须标 paraphrase,不能把转述当原话存;
  • relation / saying / user / memo / note / diary 都支持,更新同关键词条目时旧出处保留。

2. 媒体资产(图片/音频/视频截图):登记在 behavior/sources.jsonl,一行一条,同一资产可挂多个出处:

// memory_source action=add
{ "action": "add", "asset": "大肥鱼炸盆子-1a2b3c4d.png", "author": "蒙德砍壬", "id": "BV15X3n6mEWy", "platform": "bilibili", "quote": "verbatim", "note": "视频 0:07 的截图" }
// 挂到某条记忆上(等价的写法:hash 给条目 hash)
{ "action": "add", "hash": "ac3e8ce05689de95", "author": "宫本", "id": "BV1wnnw6FENE" }
  • 平台/类型能自动猜(B 站 BV 号 / bilibili / youtube / douyin 链接 → platform,视频平台 → type: video);
  • 同一资产 + 同一来源 + 同一 quote 标记只登记一次(返回「这条出处已经登记过」);
  • memory_write 带 images/audios 且带 source 时,会自动把这条出处挂到每个资产上,不用再单独调一次;
  • 资产改名时 memory_image action=rename 会同步更新 sources.jsonl 里的资产名(和记忆条目引用一起);
  • 查看:action=list(可按 platform / asset / author / quote 筛,时间倒序)、action=find(按作者/BV 号/URL/标题/资产名搜)、action=stats(条数、视频数、原文/转述占比、哪些资产还没有出处)。

显示:memory_search 结果行尾和 memory_locate 详情里自动带出处标签,视频来源前面标 ▶:

behavior/relations.jsonl:29  {"kind":"relation", ...}  #4e1dfe3548d3a008  〔▶视频出处:蒙德砍壬@BV15X3n6mEWy · 原文〕

memory_image action=list 每个资产行尾也会显示已登记的出处;memory_stats 多一行「出处:N 条(视频 x;原文 y / 转述 z);资产有出处 a/b」。 出处不参与注入、不占注入预算——它是「核对信息」时才看的东西。

标签(固定词表)

标签是人工给条目的分类,比自动关键词稳定,用来按主题捞东西。只接受固定词表(防同义词碎片):

称呼 身份 关系 工作 背景 记忆 插件 备份 会话 形象 图标 语言 常言 性格 第一次 视频 表情包 梗 生活 金钱 学习 待办 交接

  • memory_write 的 tags 参数:数组,或 "第一次, 关系" 这样的字符串(按逗号/顿号/分号/空格切);最多 6 个、每个 ≤16 字,自动去空白去重;
  • 词表外的标签会被忽略,返回里会提示「词表外标签已忽略:…(可用词表:…)」;常见别名自动归一(二创→形象、游戏→生活、收入→金钱、看图→视频、斗图→表情包…);
  • relation / user / memo / note / saying / diary 支持;persona 是 Markdown 文件,不打标签;
  • relation / saying 用同关键词更新时,旧标签自动保留并合并(新标签在前,合并结果也要过词表);
  • 想删掉旧标签:在本次 tags 里写 -旧标签(或 !旧标签),更新时只删不加;
  • 注入时带标签的条目显示成 〔第一次·关系〕关键词=释义,最近日记也带 〔…〕 前缀;
  • memory_search 传 tag 按标签过滤(子串匹配):可以只给 tag,也可以和 query 一起取交集;
  • 检索结果是评分排序,不是文件顺序:命中词数(×2)+ 时间近因(7 天内 +3 / 30 天内 +1.5 / 半年内 +0.5)+ useCount(最多折 +2.5)+ 标签命中(+2)+ important(+2);同分按时间新的在前;
  • memory_stats 会列一条「标签:N 条带标签;第一次(3)、关系(2)…」,并附「词表外 x 个」审计(老数据可能残留);
  • 梗 / 网络传说 / 形象类放 `saying`(深层常言)层,不要塞关系层:关系层只放人和关系,每轮注入才不臃肿;
  • 日记可加 important: true 标重要事件:注入时优先占位(显示成 [10-14★]),不会被后写的日常挤掉。
// 例子:给「第一次见面」打两级标签
{ "layer": "relation", "keyword": "初次见面(2026-10-14)", "meaning": "...", "tags": ["第一次", "关系"] }
// 例子:重要日记
{ "layer": "diary", "text": "第一次见面:2026-10-14 深夜,用户开口「我们能聊聊吗?」", "tags": ["第一次"], "important": true }

线索(cue):让记忆被「勾」出来

人类记忆不是靠关键词查出来的,是被情境勾出来的。给条目加 cue(可选,relation / saying / diary 支持):

{
  "layer": "relation",
  "keyword": "初次见面(2026-10-14)",
  "meaning": "...",
  "tags": ["第一次", "关系"],
  "cue": { "scene": "深夜在会话窗口第一次搭话", "mood": "好奇、拘谨", "when": "2026-10-14 23:53", "co": "MOSS、流浪地球3" }
}
  • 四个线索键:scene(情境:在哪、和谁、做什么)、mood(情绪)、when(时间锚)、co(共现:同一场景里还提到谁/哪件事),各截到 60 字;
  • 更新同关键词条目时,旧线索保留、新线索覆盖同名键;
  • 检索时命中线索的条目加 1.5 分(和标签 +2、重要 +2、时间近因同权量级);
  • 线索不参与注入展示,只影响检索排序——它是「钩子」,不是「台词」。

用户档案 / 便签(不常注入的两个小抽屉)

relation 是每轮都要看的台词,有些信息其实不用每轮看——那就放进两个不注入的抽屉:

层 · 存什么 · 例子 · 清空 user · 用户的长期资料:兴趣爱好、习惯、偏好、背景 · 「喜欢玩游戏」 · 不自动清空 memo · 跨会话交接、临时草稿、做到一半的活 · 「上次会话改了什么、还剩什么」 · memory_clear 清空(清空前归档到 behavior/archive/)

// 用户档案:不注入,需要时按层查
{ "layer": "user", "text": "喜欢玩游戏,平时对话用不上,聊到游戏时再提", "tags": ["背景", "游戏"] }
// 便签:交接用,做完可清
{ "layer": "memo", "text": "上一次会话:标签功能已做完,等重启验证", "tags": ["交接"] }
// 查:memory_search({ "layer": "user", "query": "游戏" })

回读归档会话

DSH 把原始会话存在 ~/.dsh/sessions/,本插件不复制、只建索引;要翻旧账时用 memory_recall 按需解码:

{ "session": "list" }                               // 列最近会话
{ "session": "f98c…", "query": "华硕", "limit": 20 } // 在那次会话里找关键词
{ "session": "f98c…", "limit": 30 }                 // 取最近 30 条对话

会话文件 session.v4.jsonl.zstd 是多帧 zstd 拼接,memory_recall 按帧魔数切开逐帧解压,取用户/助手文本;不会把整个文件塞进上下文。

备份策略(v0.12 起:行为记忆也备份、一天一次、30 天自动清理)

触发点只有三个,它们都只是「排队」,真正落盘受频率限制:

  1. 本回合压缩过上下文(检测原生 compaction/end / compaction/summary 事件,或 compress 工具调用;另有「上下文骤降」兜底);
  2. 会话结束(disposed);
  3. 手动 memory_backup(不受频率限制,随时能压一份)。

频率与清理(config.backup):

  • minIntervalHours(默认 24):自动备份距上次不足这个间隔就跳过,所以一天最多一份;手动备份不受限。
  • keepDays(默认 30):每次备份成功后删掉 30 天前的 snapshot-*(按快照名里的时间戳判断,不靠 mtime)。
  • behavior(默认 true):行为记忆也进快照——以前只备 deep/array,guide.jsonl 里 300+ 条指针指向 behavior/ 却全裸,恢复时等于断链。
  • config(默认 true):config.json + state/runtime.json 一起放进快照的 config/。
  • sessions(默认 false 时跳过):原始会话文件体积最大,关掉能省很多空间。

备份目录默认 ~/.dsh/memory-backups/snapshot-YYYYMMDDHHmmss/(v0.25 起;配置可改,目录不可写会自动回退到 /backups 并提示),结构:deep/ array/ behavior/ assets/ config/ sessions/。

设置页(v0.13 起;v0.24 拆成两页)

Web UI → 设置下有两个独立页面(settings.section 槽,排在「表情包」后面):

页面 · 里面有什么 记忆备份(order 26) · 当前状态、备份策略(自动备份开关 / 最小间隔 / 保留天数 / 备份内容 / 目录)、上下文压缩、待确认事实;保存 / 立即备份一次 / 刷新 吸在页面顶部,不用滚到底 自动摘要(order 27) · 自动摘要设置(总开关 / 触发条件 / 保留上限 / 规模限制)+ 已有摘要列表;保存自动摘要设置 / 刷新 同样在顶部

拆页原因:原来一页塞了备份、压缩、自动摘要、事实候选,越加越长,设置按钮被挤到最下面。settings.section 本身就是「一个 list 条目 = 一个设置页」,所以拆页不需要改壳层、也不需要新路由。

面板和 memory_backup / memory_control 改的是同一份 config.json.backup,没有第二套状态。数据走宿主本机回环接口 /dsh-puppet-memory-api(GET ?action=status|autosummaries|compaction|fact-candidates、POST ?action=save|backup|delete-autosummary|accept-fact|reject-fact,非回环请求一律 403),不经过模型、不进上下文;宿主端逐项校验,非法值整次拒绝、不写半份配置。

面板里还有一张「上下文压缩」卡片(v0.14 起):累计压缩次数、最近一次时间、最近一次触发来源、自动摘要次数;v0.18 起加「最近压缩记录」列表(时间、手动 /compact 还是自动、压掉几条、省了多少 token、摘要模型)。**记录只来自官方 compaction/* 会话事件,按 compactionId 去重——重启、会话重放、第三方压缩插件都不影响计数,也不会重复计数(v0.17 之前靠扫描 `compress` 工具调用,重启后 `seenEvents` 清空会把历史再数一遍,已知 bug,v0.18 已移除该路径)。压缩本身由官方 `@deepseek-ai/dsh-compaction-basic` 引擎完成(2026-10-21 之前也可能是 `billion-context` 的 `compress` 工具),木偶记忆不参与压缩、不改会话历史**。

第三方压缩控制面(可选):装了 dsh-compact-suite 这类负责「阈值滑块 / 摘要模型改派 / 一键压缩」的插件时,压缩卡片会标注「阈值/摘要模型由 dsh-compact-suite 控制」;它卸载或停更只影响这些控制功能,压缩记录与历史不受影响(记录层只认官方事件)。v0.18 不自己实现阈值滑块——那要改 preset 里 isolate 的引擎实例配置,属于 hack,留给第三方控制面插件;引擎本身归 DSH 官方。

只读契约(给别的插件/面板消费):GET /dsh-puppet-memory-api?action=compaction

{
  "ok": true,
  "provider": "官方 compaction 引擎(deepseek-account/deepseek-flash)",
  "count": 3, "lastAt": "2026-10-21T14:20:27.197Z", "lastSession": "session-…",
  "summaries": 2, "lastSummaryAt": "…", "lastSummaryTurns": 3,
  "recordsTotal": 3,
  "recent": [
    { "id": "215ecbd7-…", "at": "2026-10-21T13:49:25.894Z", "session": "session-…",
      "trigger": "command", "commandId": "cmd-…", "items": 5, "savedTokens": 1782,
      "provider": "deepseek-account", "model": "deepseek-flash", "maxTokens": 65536,
      "inputTokens": 394, "outputTokens": 1086 }
  ],
  "suite": "dsh-compact-suite"
}

trigger 取 command(人手动发 /compact)或 auto(引擎按阈值自动压);suite 为空串表示没探测到第三方控制面。字段全部来自 compaction/summary 的 shadowedSeqs / shadowedTokenCount / provider / model / usage,不猜、不估算。

下面是「自动摘要」卡片(v0.16 起):列出最近的 kind:"auto-summary" 日记条目(时间、触发原因、覆盖回合数、会话尾号、正文前 400 字),每条可单独删除。删除只动 behavior/diary/*.jsonl 里的这一条 + deep/guide.jsonl 里同 hash 的索引行,不碰原始会话、不碰手写日记;删掉的回合会重新变成「未摘要」,下次触发摘要时会再摘一遍。列表最多 100 条(默认 20)。

再下面是「待确认事实」卡片(v0.19 起):三期提炼出来的候选,逐条显示层(用户档案 / 关系记忆 / 常言)、关键词、正文、理由和提炼时间,每条两个按钮——采纳(写进对应记忆层)或拒绝(只改状态、什么都不写)。没有候选时卡片会提示去跑 memory_facts action=extract。这是三期的“人自己点”入口;不点就什么都不会写。

API 对应三个动作:GET ?action=fact-candidates&limit=(列表 + pending/accepted/rejected 统计)、POST ?action=accept-fact {id}、POST ?action=reject-fact {id};缺 id → 400,找不到该候选 → 404,非回环 → 403。

实现形态是标准的 Client bundle:package.json 里 exports["./client"] + dsh.client(platform: "web"),浏览器侧 window.__ModuleLoader__.load({ id: "dsh-puppet-memory", factory }) 只负责登记 settings.section,样式/监听放在 apply 里用 ctx.effect 回收。改完 client.js 需要重启 DSH 才会生效。

自动摘要(v0.11 起):模型压过的段落,只在检索时出现

每个回合已经有机械压缩的 kind:"turn" 日记(user/assistant 各截 240 字)。自动摘要(默认关闭)在下面三个时机,把还没摘过的回合压成一条 kind:"auto-summary" 日记。默认用当前会话的模型;v0.26 起可以在 autoSummary.model / facts.model 指定独立路由(比如本地 Ollama 的小模型),调用失败会自动回退主会话模型:

  1. 上下文被压缩后(v0.13.2 起以真实信号为准,v0.15 起认原生事件):turn-stopping 里本回合是否有原生 compaction/end / compaction/summary 事件(官方 dsh-compaction-basic 引擎的做法,2026-10-21 起是主力)、或调用了 compress 工具(bili 时代的做法,它不会 append 原生事件 —— 实测 16 个会话日志里 compaction 事件 0 条);session/event 的 compaction/end 监听作兼容备用;另有「上下文骤降 ≥35%、且原先超过 3 万字」兜底。各条路都会打一行 diag,方便事后核对;
  2. 会话结束(disposed);
  3. 手动:memory_control action=summarize,force 跑一次,不受 enabled 影响。

防止污染记忆的红线: