dsh-memento-tab
Session-scoped memory tab for DeepSeek Harness, rendered beside Chat / Trajectory / Context, covering the whole /memory surface (list, query, add, rewrite, remove, merge, proposals, budgets, audit, adapters, export, import). A companion to dsh-memento: it consumes the public ctx.memory seam and registers its own routes, so it never forks upstream. Reading the tab never increments the store's recall counters. Every route call carries a deadline, so a wedged request reports a failure instead of locking the tab. Removes address entries by primary key, so two memories with identical text stay individually deletable.
安装
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:inxups/dsh-memento-tab说明文档
阅读完整 README ↗dsh-memento-tab
给 DeepSeek Harness 的 会话级记忆 tab —— 挂在「对话 / 轨迹 / 上下文」旁边。
dsh-memento 的伴随插件,不是它的 fork。
它只消费上游公开的 ctx.memory 接缝,自己注册路由、自己注册 tab,所以上游发版永远不会
产生合并冲突 —— 上游只能通过改动已发布的接缝来影响它,不能通过重构内部实现。
┌─ 对话 ─┬─ 轨迹 ─┬─ 上下文 ─┬─ 记忆 ─┐
↑ 这个插件
它做什么
一个覆盖整个库的管理面,不是当前会话的只读视图。/memory 的全部 11 个动词在这里都有
对应操作,另外多两个上游没有的:内联改写、导出下载。
- 四层预算卡
user/agent×user-global/workspace,每层一条用量进度条 (≥80% 转黄,写满转红)——和/memory budgets是同一个口径。 - 子串搜索(防抖 220ms)+ 轨道 / 层筛选 + 「仅本会话」开关。
- 内联改写与删除:改写就地变文本框;删除用条目全文作为唯一子串定位, 因为接缝只接受唯一子串。
- 合并(consolidate):勾选 ≥2 条 → 预填原文 → 删减成一条新文本提交。跨层选中会被禁用
(上游一次只整合同一层),上限读取宿主报的
maxMergeMatches,不自己写死。 - 待批提案:批准 / 驳回。批准不是翻个标志位就完事 —— 它会带着提案文本重新走一遍
审批门,和模型调用
memory工具走的是同一个门。 - 数据卡:导出(memento 信封或任一适配器格式)+ 复制 / 下载;导入(选文件或粘贴)。
- 适配器卡:当前构建注册了哪些适配器、各自吃什么格式、导出成什么。
- 审计尾:
snapshot/recalled/ 每次写入,带时间戳与结果。 - 看 tab 不计入召回:读路径绕开
ctx.memory.query(),详情见下一节。
样式全部走宿主自己的设计令牌(--dsw-alias-*),所以自动跟随亮/暗主题,不硬编码配色。
读路径:看 tab 不算召回
ctx.memory.query() 是接缝上唯一的读方法,但上游把它实现在 store.queryEntries() 之上,
而后者会给返回的每一行 recall_count += 1 并刷新 last_recalled。recall_count 又是
存储的排序主键。所以一个"打开时读、切筛选读、搜索框每敲一下也读"的界面,会把自己的浏览
流量灌进召回计数——进而改掉它正在展示的那个列表的顺序,对模型的检索路径也一样。
上游命令面有同样的副作用(/memory list、query 都会 bump,还会多写一行 recalled 审计),
但那是人的一次显式动作;220ms 防抖的搜索框不是同一个量级。
因此本插件走 store.listEntries()(和导出同一个访问器,永不 bump),在宿主侧复现
queryEntries 的语义:track / scope 精确匹配、text 大小写不敏感子串、total 在截断前
计数。排序取 created_at 降序——管理视图里最新的那条应该在眼睛落下的地方。
listEntries 缺失的构建会退回 query()(有读路径总比没有强),并在日志里警告一次。
/state 也不传 sessionId,所以刷新永远不会写 recalled 审计行。
数据:导出与导入
导出走 GET /api/memento-tab/export,两种格式:
- 不带
adapterId→dsh-memento信封(memory-export-v1),和/memory export出的文件 是同一格式,/memory import直接吃得下; - 带
adapterId→ 该适配器自己的产出(内置三个:mem0、hermes-memory-md、claude-code-memory-md;注意mem0适配器的 id 是mem0,mem0-facts是它的导出格式名)。
拿到文本后可以一键复制或下载成文件(浏览器侧 Blob,不需要额外服务)。
导入走 POST /api/memento-tab/import,走的是 memory.seed():一次审批批准整批 +
全量预算预检 + 单事务原子落盘。任何一条超预算就整批拒绝,不会写进去一半。超预算的
used/limit/needed 会原样回到界面,提示先合并再试。单次上限 1000 条(上游协议常量)。
- 默认保留文件里的层键(
workspaceKey/agentKey),所以导回原工作区的条目还在原工作区; 勾「按当前会话重写层键」才会整批重新归属。 - 提交前会弹一次确认。信封格式能本地数出条目数与目标层;适配器格式只有宿主能解码,所以 确认框照实说、不编造条数。
- 没有适配器注册表的构建里,导入仍可用(走信封),只是没有适配器可选。
写入:门是组合出来的,不是拆掉的
这个插件没有特权写入路径。每一次写都会向 approval/request 提问,所以
dsh-memento 前置注册的 answerer 永远先拿到请求,硬开关一律有效:
| 配置 | 结果 |
|---|---|
writePolicy: off | 拒绝,并且拒绝也进审计。tab 也绕不过 |
writePolicies: {'track/scope': 'off'} | 同上,在 tab 之前就裁决完 |
writePolicy: auto | 放行,仍然写 approval/asked + approval/decided 审计对 |
会话级 never | 在进入瀑布之前就被拦下,本插件同样绕不过 |
writePolicy: ask | tab 发起的写不再弹审批。 见下 |
ask 是唯一被改写的分支,改的是最后那一步「问人」:从 tab 点「保存」的是人,人就是审批人,
再让他审批自己刚点的那一下是双重确认,不是安全属性。实现方式是自己注册一个非 prepend 的
answerer,只认自己打上的 tab 标记:
- 上游 answerer(prepend)先跑 →
off/ 细粒度策略在这里就已经rejected,轮不到我 - 落到「要问人」这一步 → 我的 answerer 对 tab 标记的请求回
allowed-once - 请求不带 tab 标记(模型调
memory工具、/memory命令)→ 原样落到人工审批 UI,行为不变 - 瀑布兜底是
unavailable(fail closed),没人认领的请求一律拒绝
标记不能放在 reason 里——上游按字节解析那个字符串——所以它走审批请求对象上的一个私有字段。
一次点击换来的取舍
ask 下 tab 的写变成免确认(导入也走同一条,因为点「导入」的同样是本人),意味着
本机上的任何进程只要伪造那个请求头,也能不弹窗写入。路由和其它插件路由一样不做鉴权,
我用一个自定义请求头(x-memento-tab: 1)挡住网页发起的跨站请求(跨域带自定义头需要
CORS 预检,这个服务不满足),但挡不住本机进程。
导出虽然只读,也要求同一个请求头 —— 它一次带走整个库,比单条写入更值得挡。
想彻底关掉从 tab 写入:把 writePolicy 设成 off,或对该 track/scope 单独设 off。
安装
# 先确认 dsh-memento 已装
dsh plugin --profile web add dsh-memento
# 再装本插件
dsh plugin --profile web add github:inxups/dsh-memento-tab
# 本地开发用 link:(改完 client/client.js 热更,host 改动需重启)
dsh plugin --profile web add link:/path/to/dsh-memento-tab
装完重启 dsh,会话顶部会出现第 4 个 tab。
依赖与边界
- 硬依赖
ctx.memory与approval:inject: ['memory', 'approval']。上游不在时本插件 不激活,不会半死不活。 - 路由:本插件自己注册
/api/memento-tab/{state,write,decide}三条,加上只读的export(GET)与import(POST),都是精确路径路由,和上游的/api/memento/*不冲突。 webServer故意不写进inject:没有 web server 的组合里本插件仍然激活,只是没有数据 路由;服务出现得晚也会被接上。注意 Cordis 对未声明的服务做属性访问会直接抛错 ——if (ctx.webServer === undefined)这种写法根本执行不到,属性访问本身就是那次抛出。 可选服务一律走ctx.get()(webServer与memoryAdapters都是这么拿的)。- 只读降级:
auditList/proposalList/proposalDecide/listEntries在上游是 provider 账本而非类型化接缝,memoryAdapters也是独立服务,所以这里全部特性探测调用 —— 上游改名只会让审计尾、提案区、适配器卡显示「此版本未暴露」,导出退化成「无法导出」, 条目列表退回会 bump 召回计数的query()并警告一次,不会让整个 tab 挂掉。 - 几处刻意耦合:审批
reason的格式(writeReason())、导出信封的plugin/schema(EXPORT_PLUGIN/EXPORT_SCHEMA)、以及两个协议上限 (MAX_IMPORT_ENTRIES/MAX_MERGE_MATCHES)都是从上游镜像来的常量。冒烟测试会拿装好的 上游逐个断言:常量直接比对,导出的信封再喂给上游自己的validateExportEnvelope过一遍, 所以「镜像错了」会在测试里响,而不是在用户导文件时响。除此之外没有复制任何上游内部知识。 - 不认证,但有请求头闸门:DSH 的 webserver 对自定义
/api/*路由不做鉴权(上游面板路由 同理),服务只监听回环地址。本插件的write/decide/import/export四条路由额外 要求x-memento-tab: 1这个请求头,用来挡跨站网页(跨域带自定义头要过 CORS 预检,本服务 不满足);挡不住本机进程。state是唯一不要头的路由,这样 tab 出问题时curl还能直接看。 apply()永不抛出:见下一节。插件内部的任何异常都只打日志,不让 dsh 起不来。
启动安全(这是踩过坑的地方)
插件的 apply() 一旦抛错,失败的是整个 loader 树 —— dsh 完全起不来,不是这个 tab 消失。
所以这里有两条硬约束:
apply()整体包了 try/catch:任何异常只打日志并退化成「无数据路由」,绝不阻断启动。test/load.mjs在真实 Cordis 上下文里加载本插件,覆盖三种情形:有 web server(注册 五条路由)、没有 web server(必须仍能激活)、web server 后到(从internal/service接上)。第一条写错的版本就是死在第二种情形上。同一份测试还驱动了 export / import 两条 新路由,包括「没有条目账本」「没有适配器注册表」这两种降级路径。
npm test # 两个测试都跑
npm run test:load # 只跑加载契约测试(默认在 /Users/inxups/project/deepseek-harness 找 cordis,
# 用 DSH_SOURCE 覆盖;找不到就 skip 而非失败)
# 冒烟测试里的上游对照默认找 /Users/inxups/.dsh/profiles/web/node_modules/dsh-memento,
# 用 DSH_MEMENTO_SOURCE 覆盖;同样 skip 而非失败
许可
MIT。本仓库不含 dsh-memento 的任何代码。