mllun/dsh-memory-layers

dsh-memory-layers

给 DeepSeek Harness 的分层长期记忆:热层约 600 字、超限自动腾挪,冷层按月归档、可按关键词跨月召回并按使用度排序,日/周/月三级抽取式摘要控制注入体量。

Uncategorizedagent-toolsdeepseek-harnessdsh

dsh-memory-layers

npm:https://www.npmjs.com/package/dsh-memory-layers · 安装:npm i dsh-memory-layers

给 DeepSeek Harness 的分层长期记忆:一个约 600 字、每次会话必读的热层,一份按月归档、可按关键词跨月召回的冷层全文,以及日 → 周 → 月三级抽取式摘要。目标是在不撑大上下文的前提下,让很久以前的细节仍然找得回来。

Layered long-term memory for DeepSeek Harness: a ~600-character hot layer, a month-archived cold layer with keyword recall, and extractive day/week/month summaries that keep injected context small.

v1.1 起,冷层还能按最近 N 天、标签、日期区间只读查询,热层多了一个不占字数的主题索引(主题|年月),用来先定位月份再取数。

v1.2 起,热层和候选区从“只存不管”变成自己管自己:超限自动腾挪、候选过期下沉、召回留痕并按使用度排序、摘要逐级自动压缩。全部是增量改动——工具名、既有返回字段、hot.md 与 cold/ 结构都没变。

  • 纯 Host 插件,没有前端资源,没有网络请求,没有第三方运行时依赖。
  • 数据全部是 UTF-8 Markdown(外加一份 usage.json 账本),可读、可手改、可备份。
  • 摘要只抽取原文并截断,不调用模型、不新增原文没有的句子。
  • 四个查询(mem_recall / mem_recent / mem_by_tag / mem_by_date)不改冷层原文;它们只会在 usage.json 里记一笔命中,用来给后续召回排序、并保护常用的记忆不被腾挪。

为什么不是一个文件

一个 notes.md 迟早会长到几千条。要么每次会话把它全塞进上下文,要么模型永远想不起来细节。

这里把记忆拆成三层,每层只承担一件事:

层 · 体量 · 何时读 · 何时写 热层 hot.md · 约 600 字 · 每次会话开始 · 精简后全量覆盖(超限自动腾挪) 冷层 cold/YYYY-MM/YYYY-MM-DD.md · 不限(单条 ≤ 4000 字) · 需要细节时 mem_recall · 随手追加(也会接收腾挪/下沉的块) 摘要 summaries/day · week · month/ · 日 1500 / 周 1000 / 月 700 字 · 回顾某段时间时 · 按天/周/月生成

热层放身份、长期约定、正在推进的事;细节写进冷层;想不起细节时按关键词召回;回顾一段时间时看摘要。热层永远不会因为冷层变长而变长。

数据布局

<DSH_HOME>/dsh-memory-layers/       ← 默认数据目录($DSH_HOME 未设时为 ~/.dsh)
  hot.md                          热层正文 + 候选区 + 主题索引区
  cold/2026-10/2026-10-21.md      冷层:一天一个文件,按月分目录
  summaries/day/2026-10-21.md     日摘要
  summaries/week/2026-W43.md      周摘要(ISO-8601 周)
  summaries/month/2026-10.md      月摘要
  usage.json                      召回留痕与条目权重(v1.2)

数据目录默认跟着 DSH_HOME 走。想放到别的盘(DSH 重装不丢),在插件配置里写 dataRoot: D:\某目录,数据就落在 D:\某目录\memory——插件不读任何环境变量。所有条目共用一个行格式:

- [2026-10-21T03:10:00.000Z] [tag] 正文
- [2026-10-21T03:10:00.000Z] [tag] 正文 〔src:private:1234567890#977〕

时间是 ISO-8601(UTC)写入时间戳;文件按本地日期分区;tag 缺省为 -。v1.2 起行尾可以带一个可选的来源标记 〔src:会话key#序号/消息id〕,content 本身不含这段后缀。

热层里另有两个保留区,都不占正文的 600 字:

- [2026-10-21T03:10:00.000Z] [计划] 周末想去看海



翻唱|2026-10
旅行|2026-09

权限与边界 / Permissions and boundaries

DSH STORE 的源码扫描会读这一段,所以逐项写明插件实际碰到的东西:

能力 · 实际情况 文件 / files · 读写,但只在数据目录内:hot.md、cold/YYYY-MM/*.md、summaries/{day,week,month}/*.md、usage.json。不读数据目录以外的文件;工具不接受调用方传入的路径,无法被引导去读写别处。写入一律「临时文件 + rename」原子替换,崩溃不会截断已有记忆。 网络 / network · 无。不 import http/https/net/tls/dgram,不发起任何请求,不上传任何内容。 命令 / commands · 无。不 import child_process,不执行外部程序。 凭据 / credentials · 无。不读 keychain / oauth / token,不读环境变量(唯一例外是 DSH_HOME,用于定位数据目录),不接触任何 API key。 外部服务 · 无。摘要由本地抽取生成,不调用模型、不连第三方服务。 运行时依赖 · 零。只用 Node 内置模块(node:fs / node:path / node:os / node:crypto),没有 dependencies。 副作用时机 · 只在工具被调用时读写磁盘;插件激活时不创建、不修改任何文件。 失败边界 · 单个工具调用失败只返回可读错误,不写半截文件;usage.json 缺失或损坏时静默重建,检索不因此失败;摘要生成失败只记日志。

package.json 里对应声明了 dsh.permissions(files: write、network/commands: none、credentials: none)与 engines.dsh 兼容范围。

安装

要求:Node.js ≥ 20,DeepSeek Harness 0.2.x(本插件是 Host 插件,依赖 ctx.get('tools') 注册工具)。

1. 从 npm 安装(推荐)

npm i dsh-memory-layers          # 装进当前 profile 的 node_modules
npm i -g dsh-memory-layers       # 或全局安装
  • npm 页面:https://www.npmjs.com/package/dsh-memory-layers
  • 最新版本:npm view dsh-memory-layers version
  • 零运行时依赖,装完即可用;在 profile 的 cordis.patch.yml 里写 name: dsh-memory-layers 就能激活。

2. 通过插件管理器安装仓库

先把仓库克隆到本地任意目录:

git clone https://github.com/mllun/dsh-memory-layers.git

然后在 DSH 里让插件管理器安装这个包目录:

plugin_manager({ action: "install_bundle", target: "<本仓库的绝对路径>" })

安装会把包链接进当前 profile(dependencies + dsh.profile.bundles + node_modules),并立即尝试激活。看到 application: applied 后,工具列表里会出现 mem_hot_get 等九个工具。

3. 手动注册

在 profile 的 cordis.patch.yml 里加:

- insert:
    - id: dsh-memory-layers
      name: dsh-memory-layers
      config:
        hotLimit: 600
        autoEvict: true

前提是该包已能被 Node 解析(放在 profile 的 node_modules 下,或从仓库路径 file: / link: 安装)。改完需要重启 DSH。

卸载

在插件管理器里移除 bundle,或从 cordis.patch.yml / package.json 的 bundles 列表里删掉。卸载不会删除记忆数据;要彻底清空,手动删除数据目录。

工具

九个工具,全部经由 ctx.get('tools') 注册。其中 mem_recall、mem_recent、mem_by_tag、mem_by_date 是只读查询,不修改冷层原文。

mem_hot_get()

读热层。返回:

字段 · 说明 content · 热层正文(不含候选区与主题索引) chars / limit · 正文字数与上限(按 Unicode 码点计) overLimit / hint · 是否超限,以及精简提示 candidates / candidateCount · 待提升的候选条目 topics / topicCount · 主题索引(topic + month) pruned · 本次读取时自动下沉的候选:{ expired, overflow, kept }

没有 hot.md 时返回空正文,不报错、不创建文件。

读取时顺带做一次候选体检:超过 candidateTtlDays(默认 30 天)的候选、以及超过 candidateMax(默认 12 条)后最旧的几条,会被下沉到当天冷层(tag=candidate-expired,正文保留原 tag 前缀,形如 [原tag] 正文),下沉条数写在 pruned 里。

mem_hot_set(content)

用 content 全量覆盖热层正文(不是追加)。返回写入后的字数、是否超限、精简提示。

  • 传空字符串可清空正文(候选区与主题索引保留)。
  • 正文超过上限时(autoEvict 默认开)会自动腾挪:按空行切块,把最不活跃的块整块搬到当天冷层(tag=hot-evict),直到字数降到 evictTarget(默认 floor(limit*0.85))左右。返回新增的 evicted(每块含 content / chars / reason)、charsAfterEvict、evictHint。
  • 只有同时满足这些条件的块才会被腾挪:≥ 20 字、不含 `` / `` 注释块,则用块内条目替换候选区;自带 `` / `` 块则用块内 `主题|年月` 行替换**主题索引。

mem_write(text, tag?, important?, source?)

向当天冷层文件追加一条记忆。返回写入的日期、时间戳、tag、候选序号、来源。

  • text 单行;换行和连续空白会压成空格;单条上限 4000 字。
  • tag 可选,[ ] 会被去掉,避免破坏行格式。
  • important: true 时同时把这条放进热层候选区(候选不计入 600 字),candidateIndex 返回从 1 开始的序号;不重要时为 0。
  • source: { conv, seq, msgId? } 可选,会以 〔src:conv#seq〕(有 msgId 时 〔src:conv#seq/msgId〕)追加在行尾,之后所有查询都会把它解析回 source。
  • 当天已有完全相同的(tag + 内容)条目时返回 added: false,不重复写;若这次带了不同的 source,只更新那一行的来源标记(sourceUpdated: true),不新增重复行。
  • 当天条目数每跨过一次 20 的倍数,会自动 force 重生成当天的日摘要(summaryCreated: true);生成失败只记日志,不影响写入。

mem_recall(keyword, limit?, mode?)

跨全部月份召回冷层条目。

  • 大小写不敏感的子串匹配;内容或 tag 命中都算。
  • 单个中文关键词(3 字以上、不含空格)会被切成二元组,所以「翻唱权重」也能找到只写了「翻唱…权重」的条目。
  • keyword 里多个词用空格分隔时,默认要求同时包含全部检索项(mode: "and");传 mode: "or" 或 "any" 则任一命中即可。
  • 结果按 score = matched*2 + weight/5 + 新近加成 排序(新近加成:30 天内 +3、90 天内 +1),同分按时间倒序,默认 10 条,最多 50 条。
  • 每条返回 score / matched / source;整体返回 mode / tokens / probes / scannedFiles / scannedEntries / matched / returned,便于判断是否需要缩小关键词。
  • 返回的每条都会在 usage.json 里记一次命中(hits +1、weight +6、刷新 lastHitAt),所以常用的记忆下次排得更前、也更不容易被腾挪。

mem_recent(days?, limit?)

取最近 N 天(含今天)的冷层条目,按时间倒序,只读。

参数 · 默认 · 范围 days · 3 · 1–365 limit · 50 · 1–200

返回 days / from / to / scannedFiles / scannedEntries / matched / returned / items。适合“刚才 / 这两天发生了什么”。

mem_by_tag(tag, limit?)

按标签取冷层条目:不区分大小写的精确匹配,按时间倒序,只读,默认 20 条(最多 200)。tag: "-" 取没有标签的条目。

mem_by_date(from, to, limit?)

按本地日期区间取冷层条目:from / to 都是 YYYY-MM-DD(闭区间),按时间倒序,只读,默认 50 条(最多 200)。

  • from 晚于 to、日期非法或区间超过 3660 天都会返回可读错误。
  • 单日查询直接令 from === to。

检索回退顺序

想不起某件事时,按这个顺序找:

  1. `mem_recent(3)` —— 最近三天有没有直接相关的内容;
  2. 没有就看热层主题索引——索引里每个 主题|年月 给出该主题涉及哪几个月;
  3. 用 `mem_by_date(from, to)` 取那个月(或那几个月)的条目;
  4. 仍不确定时,再用 mem_recall 全文搜。

mem_summarize(period?, date?, force?)

生成(或返回已存在的)某一周期的摘要。

参数 · 取值 period · day(默认)/ week / month date · YYYY-MM-DD,默认今天;用于补生成历史周期 force · 默认 false;已存在时直接返回,true 才重新生成

来源:日摘要读当天冷层;周摘要读该周的日摘要(某天缺失则回退那天的冷层);月摘要读该月的周摘要(缺失的周逐级回退到日摘要、冷层)。

生成后会把该期最高频的 3 个 tag 补进热层主题索引(tag|YYYY-MM),已存在的行不重复、不覆盖。

mem_stats()

返回各层体量:热层字数/上限/候选数/主题索引数、冷层文件与条目数(按月列出、含最新日期)、三级摘要的文件数与字数。用来判断该不该精简热层、归档冷层或补生成摘要。

v1.2 起还会给出使用度(正文只取前 40 字):

字段 · 说明 topRecalled · 最常被召回的 5 条:hits / lastHitAt weightTop · 权重最高的 5 条 weightBottom · 权重最低的 5 条(最可能被腾挪)

热层:候选与主题索引

热层正文默认上限 600 字。hot.md 里固定保留两个区,都不计入 600 字:

候选区