Skip to content
dsh.fish
Bundle

dsh-jev-memory

Typed, auditable long-term memory for the DeepSeek Harness: a turn-end write hook, a judgement layer (Jev or a deterministic fallback), a local store, and per-session recall injection.

Source
lrqiisrom
License
MIT
Updated
Updated 10 days ago

Readme

# dsh-jev-memory

**类型化、可审计的 DSH 长期记忆插件。** 一句话定位:

> `AGENTS.md` 是人手写的记忆;这个插件是**自己长出来、按需召回、逐条可审计**的记忆。

DSH 官方有会话持久化(JSONL)、会话检索(SQLite FTS)、compaction(压缩丢弃),但**跨会话的长期记忆**是空白:没有任何官方包回答"什么值得记住"和"这一轮该想起什么"。本插件补的就是这一层判断。

---

## 它做什么(当前已实现并实测)

| 环节 | 实现 | 位置 |
|---|---|---|
| **写入** | 挂在 `agent/turn-stopping`(serial + awaited,回合收尾前)抽取候选 → 判定 → 过确定性闸门 → 落盘 | `dsh/index.ts` |
| **判定** | 端口式:**Jev**(TypeSafe 判定模型)为首选,**确定性信号**为离线兜底。判定层只出标签与概率,**不生成文本** | `dsh/lib/judge.ts`、`dsh/lib/jev.ts` |
| **存储** | 本地 `memory.json` + 只追加的 `ledger.jsonl`,原子写、内存索引(注入回调必须同步) | `dsh/lib/store.ts` |
| **召回** | `systemPrompt.context` 每步注入当前工作区的 Top-K,带类型/id/日期标签 | `dsh/lib/recall.ts` |
| **工具** | `memory_search` / `memory_write` / `memory_forget`,模型可主动查、主动记、主动撤 | `dsh/index.ts` |

三条纪律写进了代码,不是写在文档里:

1. **模型不写**——记忆正文只能是用户原话(逐字裁剪)或工具失败的确定性签名,判定层没有可以塞进新句子的字段;
2. **概率只排序,阈值归代码**——写入阈值是 `minImportance`,冲突判断用插件自己的 `conflictThreshold`,从不把 Noul 上调好的阈值搬到 Choice 上(官方 jaggedness 文档:P(q)+P(¬q) 未必等于 1);
3. **fail-open**——判定失败降级到启发式,钩子超时直接放弃,写入路径的任何异常都被 catch,绝不让记忆成为会话失败的原因。

---

## 快速开始

### 方式一:开发期直接挂工作区(无需安装)

在 profile 的用户 patch 层 `~/.dsh/profiles/web/cordis.patch.yml` 里加一行:

```yaml
- insert:
    - id: jev-memory
      name: 'file:///absolute/path/to/dsh-jev-memory/dsh/index.ts'
```

行名由加载器用 `new URL(name, baseUrl)` 解析,所以 `file:` URL 与裸包名都可用。删掉这一行即完全卸载。

### 方式二:作为一个包安装进 profile

```sh
dsh plugin --profile web add /absolute/path/to/dsh-jev-memory
# 再把包名加进 profile package.json 的 dsh.profile.bundles
```

包根自带 `cordis.patch.yml`,`dsh.bundle.patch` 指向它。

### 配置

```yaml
- insert:
    - id: jev-memory
      name: 'file:///absolute/path/to/dsh-jev-memory/dsh/index.ts'
      config:
        types: [constraint, pitfall, decision]   # 只记这三类
        judge: auto                              # auto | jev | heuristic | off
        minImportance: 0.6                       # 确定性写入阈值
        minRemember: 0.6                         # Jev 的"值得记吗"阈值
        askOnConflict: true                      # 发现矛盾时问你一句(HITL)
        askOnConflictTimeoutMs: 600000           # 等你回答的预算(10 分钟)
        askOnConflictMaxAttempts: 3              # 超时后在下一次对话开始前重问,最多几次
        repeatFailuresToWrite: 2                 # 同一个错重复几次才算"坑"
        writeSkipSubagents: true                 # 见下方"实测发现"
        writeTimeoutMs: 2500                     # 回合收尾的写入预算,超时放弃
        recall:
          maxTokens: 600
          quota: { constraint: 4, pitfall: 3, decision: 2 }
        jev:
          apiKeyEnv: TYPESAFE_API_KEY   # 从下面三处按序解析,密钥不写在配置里
          model: jev-latest             # 台账会记录实际应答的版本号,便于发现别名漂移
```

**密钥放哪**:按序解析 **①** 宿主 `ctx.credentials` 服务的 `apiKeyEnv` 引用(存 `~/.dsh/.credentials.yaml`,权限 0600)→ **②** 直接读同一个凭据文档(服务在开机那一瞬可能还没就绪,这条兜底让"时机"不再决定判定方式)→ **③** 配置里的 `apiKey` / 进程环境变量。解析发生在**每次判定时**而不是挂载时,所以运行中新增或轮换 key,下一轮就生效、不必重启。台账的 `start` 行写明实际用了哪条路径(`service:file` / `file` / `config` / `env` / `none`)。没有 key 时 `judge: auto` 自动退回启发式,**插件无需网络即可工作**。

---

## 记忆互相矛盾时:问你一句(人机协作)

"这条值不值得记"是一回事,"它是不是和你之前说的冲突"是另一回事——后者机器不该自己拍板:

1. Jev 若判**存在冲突**(概率 ≥ 0.7)**且这条本身值得记**,先以"待确认"状态落盘(不注入、也不丢);
2. 回合结束时**问你一句**——一条问题、三个选项:**用新的覆盖旧的 / 保留旧的那条 / 两条都留着**;
3. 你的选择决定结果:覆盖 → 旧的标成"已被取代"(留档,不再注入);保留旧 → 新的丢弃;都留 → 两条都生效;
4. **没人应答 / 超时(默认 10 分钟)/ 出错** → 保持"待确认":**宁可少记一条,也不猜你的意思**。超时不是终点:记录留在"待确认",**在你下一次发消息、模型开始干活之前再问一次**(那时的你一定在场),最多重问 3 次——这就是"超时敢设长"之所以安全的原因。

**为什么这和 DSH 原有的 HITL 不冲突**:DSH 有两种跟人打交道的通道——**审批**(沙箱/权限策略发起,"这个危险操作放不放行")和**提问**(模型发起,"我缺一个信息")。这里**复用第二种**,只是换成插件的判定规则来发起:不新增 UI、不新增权限模型、不新增打断渠道,问题会出现在你熟悉的那个提问卡片里。唯一的差别是"谁决定要问"。

台账会记 `conflict-ask`(问了什么、和哪条冲突、配对依据的共享词)与 `conflict-resolved`(你选了什么)——**这就是"自动判断靠不靠谱"的实测数据**。

**一次性失败不记**:工具报错要**同签名重复出现**(默认 2 次)才算"坑"。一次性的环境失败(沙箱拒绝、重定向被拦)不是教训;重复发生的才是。次数从台账里数,所以重启不丢。

---

## 实测发现:写入精确率 1/4(已修)

第一次在真实 GUI 里跑,台账 `ledger.jsonl` 立刻暴露了问题:

```json
{"kind":"write","type":"constraint","by":"heuristic","importance":0.96,
 "source":{"sessionId":"5883ea6a-…","seq":8,
 "quote":"调用 `memory_write`,参数:{\"text\": \"语言不要选 Java\", …} 3."},
 "signals":["/不要/u"]}
```

这些是**我写给 subagent 的任务指令**,被当成"硬约束"记了下来。根因不是阈值调错,而是:

> **在委派子会话里,`source.kind === 'user'` 的消息不是人类的话,是父代理的指令。**

核实方式是从磁盘读那条会话的 header:`{"delegationDepth":1,"agentPreset":"cordis"}` —— 确认是子会话。

修复分两层,都带回归测试(测试用例直接用上面这些真实句子):

1. `writeSkipSubagents: true`(默认开)——深度 >0 的会话不再参与学习。子会话的回合对父会话的钩子同样可见,所以不丢信息;
2. `screenSentence()`——拒收**任务指令**(`按下面步骤`、`第 N 步`、`把…贴出来`、`不要改写` …)与**载荷**(`{"key":`、代码块)。刻意做窄:`不要改动 data/ 目录` 这类真约束必须活下来。

这条记录本身就是这个插件的价值主张:**误记是可以被逐条追责的**,因为每条记忆都带着来源会话、seq、原句和命中的信号。

---

## 指标(都能从台账算出来,不是估计)

| 指标 | 怎么算 | 当前 |
|---|---|---|
| 写入精确率 / 误记率 | `eval/report.ts` 在**人工标注的真实语料**上算(当前 38 句已标,逐句判断见 `eval/labels/README.md`);`eval/write-precision.ts` 是 20 条的冒烟集 | **只过筛子 43% / 100%;离线规则判定 50% / 89%(F1 0.64);Jev 50% / 11%(F1 0.18)**(精度 / 召回,实测于 2026-09-29) |
| **Hit@K** | 对每条标注查询 Q:前 K 条里**只要有 1 条**标注正例即记 1,最终 = 命中查询数 ÷ 查询数 | 待测 |
| **Recall@K** | 对每条 Q:前 K 条里的正例数 ÷ 该 Q 的**全部正例数**,再取平均 | 待测 |
| 注入 token 数 | 台账 `kind:"recall"` 的 `tokens` 字段 | 1 条约 60 tokens(历史上 4 条时 289–360) |
| 被拦下的候选 | 台账 `kind:"skip"` 的 `reason` | `veto:secret` / `veto:task-instruction` / `veto:payload` / `duplicate` / `subagent-session` / `below-min-remember` / `below-min-importance` / `type-disabled:*` |
| 判定降级 | 台账 `kind:"degraded"` | `jev-missing-row`(候选超出单次请求上限时静默降级,现在会记账并告警) |

### 写入精确率是怎么量的

`eval/write-precision.ts` 是一套**带标注的候选集**(20 条 = 8 条该记 + 12 条不该记),其中 8 条"不该记"取自真实会话里插件**确实写过**的原句(子代理任务指令、一次性环境失败、用户提问、API key 本身)。

**它只有 20 条,只能证明"没有明显坏掉",不能当结论用。** 真实分布的量法在 `eval/labels/`:从历史会话里抽出候选句、由人逐句标注,再对照三种判定(只过筛子 / 规则判定 / Jev)。那份数据的抽样口径和批次说明写在 [`eval/labels/README.md`](eval/labels/README.md),逐句结果在 `eval/labels/report.md`(`node eval/report.ts` 生成)。

### 真实语料上的结果:结论取决于那 8 行标注

在 38 句人工标注的真实句子上(编码类 9 句已定),三种判定是这样:

| 判定 | 精确率 | 召回率 | F1 |
|---|---|---|---|
| 只过筛子(当前规则) | 43% | 100% | 0.60 |
| 离线规则判定 | 50% | 89% | 0.64 |
| Jev 判定(`remember ≥ 0.6`) | 50% | **0–11%** | ≤0.18 |

Jev 在 9 条正例里只写了 0–1 条(重复跑会在 0% 和 11% 之间跳),而它把这些句子的**类型全判对了**(`constraint` / `decision`),只是 `remember` 只给 0.07–0.59 分。**但先别下结论**:这 8 条被漏记的句子**全是祈使句**(`请你按照这个格式去写简历`、`改成"一条消息 → 一串任务"`、`明天晚上有一个淘天的面试…帮我复习`),而标注标准里写着"一次性任务指令 = 0"。标注者本人也提出"有些内容确实不需要沉淀"。

**所以结论对这几行标注高度敏感**(`V0`–`V3` 是逐步把它们改标 0):

| 场景 | 正例数 | Jev 精确/召回 | 离线规则判定 精确/召回 |
|---|---|---|---|
| 按当前标注 | 9 | 50% / 0–11% | 50% / 89% |
| 3 条明显的一次性指令改标 0 | 6 | 50% / 0% | 31% / 83% |
| 5 条可疑的改标 0 | 4 | 50% / 0% | 19% / 75% |
| 只留最明确的一条(语言不要选 java) | 1 | 50% / 0% | **6% / 100%** |

也就是说:**若那 8 条本来就该标 0,那么有问题的是离线规则判定**——它会把一次性任务指令全部写进记忆,精确率掉到 6%;Jev 的拒绝反而是对的。在这批标注重判之前,两个判定谁更好**没有结论**。

### 冲突解决不再真删旧记忆(2026-09-29)

`replace` 那条分支调用的是 `store.remove(existing)` —— **旧记忆被真删**,而它上方三行的注释写着"用 `superseded` 而不是删除:旧记录保留可审计",**给用户的选项说明也写着"保留记录,供以后查证"**。也就是代码在否定自己的注释,并且对用户说了假话。数据模型其实早就支持 `superseded`(`store.ts` 的类型里有,`normalizeStatus` 也认),只是没人用它。

现在:旧记录保留原文、置 `status='superseded'` + `supersededBy`,新记录写 `supersedes`;召回与 `memory_search` 都跳过 superseded(历史不是当前知识,但不许悄悄消失)。**`memory_forget` 仍然真删** —— 那是用户明确要求删除,留副本会是另一种假话。

### 片段筛子:只上量过的那几条(2026-09-29)

我原本打算把"以 `\end{itemize}` 开头""含 `/var/folders/...` 临时路径"的句子直接筛掉。**先量代价,结果否掉了这个方案**:这两类句子在你标注里都是**该记(标 1)** —— 它们是"内容该留、前缀该脏"的 keeper,筛掉它们等于毁掉插件存在的理由。所以最终只上线了逐条测过、**零误杀**的四条:

| 规则 | 命中已标注行 | 其中标"该记" |
|---|---|---|
| 行首是代码注释 `//…` | 3 | 0 |
| 行尾分号 `;` | 3 | 0 |
| 行首是代码关键字(`if`/`class`/`public`…) | 1 | 0 |
| 行首是 markdown 结构残留(`**`/`---`/`#`/`|`) | 1 | 0 |

那两类 keeper 的正确修法是**清洗前缀**而不是丢弃 —— 已经做了,关键在于**身份与展示分离**:

- **`id`(句子的身份证)永远按"你写的原句"算**,所以清洗**不会改变任何已标注行的 id**,你的标注照旧有效;
- **`text`(存下来、注入进上下文的那份)是清洗后的**:去掉行首的 `\end{itemize}`/`\item`/`\textbf{...}`,把临时路径(`/var/folders/`、`/tmp/`、粘贴暂存目录)换成签名里已在用的 `<path>` 占位符。**通用路径不动**(配置文件路径可能就是一条记忆的全部意义)。
- 筛子改为跑在**清洗后**的文本上,顺带修掉一个隐藏 bug:**前缀曾经能改变判定** —— `请你按照这个格式去写简历…` 本来就会被"一次性任务指令"筛子挡下,是前面的 `\end{itemize}` 把它"救"成了候选。

这个"身份取自原句、其余取自清洗文本"的错位很容易写反,我第一版就写反了(注释说原句、代码传了清洗后的),**是测试抓出来的**,现在有测试钉住。

### 两层记忆:原句是证据,规范形式只用于注入(2026-09-29)

之前存的是**你原封不动的那句话**。好处是"绝不编造"——记忆永远能追回到一句你真的说过的话;坏处也真实:错别字、口水话、断句会一起进上下文。别家都改写(memsearch 把每回合变成 2–10 条第三人称 bullet,OpenViking 抽进带 `as of` 日期的类型化文件),**照搬原句的才是异类**。

所以现在是两层,分开的理由就是这张表:

| | 内容 | 谁写 | 用途 |
|---|---|---|---|
| **证据层** | 你原句,**永不被模型改写** | 你 | 台账、`source.quote`、逐条核对 |
| **规范层**(新增字段 `canonical`) | 模型整理过的第三人称陈述 | 模型,受硬约束 | **注入**(默认不启用) |

三道闸门防止它变成编造通道:

1. **不许添加事实** —— prompt 明说,并且有**确定性闸门**兜底:规范形式的词元里必须有足够比例来自原句。阈值不是拍的,是量出来的:把「嗯那个啥 就是 端口别乱改啊 定了 8000」整理成「服务端口固定为 8000,不要修改。」共享 **0.375** 的词元,而一句**编造了理由和后续动作**的扩写只共享 **0.19**,阈值取 **0.25**。判错的方向是刻意选的:拒了只损失可读性,收了就把编造的内容送进上下文。
2. **不许静默接受** —— 过不了闸门**不修补、不裁剪**,直接丢掉、保留原句。fail-open 在这里的含义是"保留原话",不是"保留模型猜的"。
3. **没有路由就不做** —— Jev 根本做不了这件事(它只能回答 `noul/choice/score`,**输出不了文本**),所以走**宿主自己的 `llm` 服务**,路由取自 `agentDefaultModel.currentSelection()`,也可以配置覆盖。没有路由就什么都不做,并且**只在挂载时报告一次**(`start` 台账行的 `normalize` 字段),不会每写一条就留一行失败噪音。

配置与默认值:

```yaml
config:
  normalize:
    enabled: true    # 生成规范形式(默认开)
    inject: false    # 注入时用它替换原句(默认关 —— 这会改变模型看到的内容,先看样例)
    # provider / model 留空 = 用宿主的默认模型
```

**默认不注入**,因为这一步改变的是**模型看到什么**。想看样例再决定的话,台账里的 `kind:"normalize"` 行就带着 `from` / `to`(各 160 字)—— 打开 `enabled` 跑几天,读那些行,满意了再把 `inject` 打开。

### 冲突窗口的排序:默认 BM25,可选 embedding(2026-09-29)

冲突确认那一步**不会把全部记忆丢给模型**——只给 `knownForConflict` 条(默认 20)。原来这 20 条是"存储顺序的前 20 条",于是排在第二十一条的矛盾**永远不会被看到**,模型回答"不冲突",台账里什么都不留。现在改成**按相关性排序**,打分函数是可替换的接口:

| `conflictRanking` | 实现 | 依赖 | 代价 |
|---|---|---|---|
| `lexical`(默认) | 对记忆做 BM25 | 无 | 无,且**没有任何内容离开本机** |
| `embedding` | 余弦相似度(智谱 `embedding-3`) | 一个 key | 记忆正文会发给该服务商;派生向量缓存在 `embeddings.json` |

**BM25 用在这里合理吗?合理,而且中文分词的问题已经解决了 —— 不需要任何依赖。** 我原来写"中文没有分词器",这个说法是错的:`Intl.Segmenter` 是 V8 自带的,Node 又是完整 ICU,**中文词典分词本来就在机器上**。

实测(查询 `端口不要用 9000 了`,正确结果应是"服务端口用 8000"排在"不要用 yarn"前面):

| 分词方案 | 不要用 yarn | 服务端口用 8000 | 结论 |
|---|---|---|---|
| 全量字符 bigram(原来) | 2.733 | 2.188 | ❌ 排错 |
| **`Intl.Segmenter` 词元(现在)** | 1.814 | **4.959** | ✅ 正确 |
| 分词器词元 + 词内 bigram | 1.814 | 4.959 | ✅ 正确(与上等价,取更简单的) |

bigram 输的原因是 `不要用` 被切成 `不要` + `要用`,而 `要用` 成了罕见词、凭空制造 IDF;分词器在 `不要|用` 处切开,这个假词就不存在了。代码里还加了**运行时自检**:精简 ICU 的构建会把中文切成单字(比 bigram 更差),所以加载时用一句话探测,不通过就退回 bigram。

BM25 真正修不了的是**改写**(`data 下的文件别碰` ↔ `不要改动 data/ 目录` 只共享一个词),那是下面 `embedding` 开关存在的理由。

**embedding 修的是另一种情况:改写。** 实测(真实 API 调用,2026-09-29):

| 对比 | 余弦 |
|---|---|
| `不要改动 data/ 目录下的任何文件。` ↔ `data 下的文件别碰`(改写) | **0.769** |
| `不要改动 data/ 目录下的任何文件。` ↔ `服务端口固定 8000`(无关) | 0.532 |

无关句也有 0.53 —— 所以**它只能用来排序候选,不能当绝对阈值**:真正区分的是相对顺序。另一方面 3 条文本一次请求 305ms、0.5 元/百万 token(全库约 3 万 token ≈ 0.015 元),缓存命中后不再请求。BM25 对这一对改写的得分接近 0,这也是为什么这个开关值得存在。

失败一律回退到 BM25(无 key、超时、报错、维度不一致),因为**排序是优化,写入不能依赖它**。

### 问法改了一版,A/B 量过(2026-09-29)

原来那句判定说明里写着"是用户对项目的说法或偏好(**不是这一次任务的操作指令**)"——它让模型去判**句子的形式**,于是祈使句一律被否,连"我要求你说设计…就说流程就行了"这种长期偏好也被当成一次性指令(0.14 分)。现在改成判**内容的时效**:"以后新的会话里是否仍然适用或成立……句子是以指令的形式说的并不影响判断"。

同一批 38 条标注、各跑 3 次:

| 问法 | Jev 精确率 | Jev 召回率 | 三次跑的结果 | 最明确那条约束 |
|---|---|---|---|---|
| 旧(判句子形式) | 0% | 0% | 0%/0%、100%/0%、0%/0% | **漏记**(0.59) |
| **新(判内容时效)** | 33% | 11% | 33%/11% 三次一致 | **记下了** |

改动是真实的,但很小:召回 0% → 11%,代价是多放 1 条(一段粘贴文本里的"这个一定要记住"被当成了你的要求)。剩下没救回来的仍是"祈使式的项目偏好"(`是一个风格和精简程度…`、`改成"一条消息 → 一串任务"`)。

新问法下阈值扫描也好看得多(0.20 时 42% / 56%,旧问法同阈值只有 50% / 33%),但**没有动默认阈值 0.6**:30 行已定标注不够定阈值,而且分数排序并不单调(0.4 那档比 0.5 还差)。

**没有结论的部分**:如上面的敏感度表,两个判定谁更好仍取决于那几行标注;但"祈使式项目偏好会被 `remember` 拒掉"这件事,在两种问法下都成立。

```sh
TYPESAFE_API_KEY=... node eval/write-precision.ts              # 无 key 时只跑确定性筛查 + 启发式
TYPESAFE_API_KEY=... WP_REPEAT=10 node eval/write-precision.ts # 模型判定跑 10 次,看分数波动
```

实测于 2026-09-29(`jev-1.13.0`,20 条里确定性筛掉 4 条、16 条进判定,单次 1.3–3.4s):

```
确定性筛查:拦掉 4 条(2 条任务指令 + 1 条提问 + 1 条密钥),正例 0 损失
启发式     precision=0.86 recall=0.75 F1=0.80
Jev @0.3   precision=0.89 recall=1.00 F1=0.94
Jev @0.5   precision=0.88 recall=0.88 F1=0.88
Jev @0.6   precision=1.00 recall=0.75 F1=0.86    ← 默认阈值(同一次跑的另一次运行给 0.86,见下)
Jev @0.7   precision=1.00 recall=0.63 F1=0.77
```

**两个必须一起读的事实**(否则上面这张表会误导):

1. **模型判定不是确定性的。** 同一批输入重复跑 10 次,16 条候选里 **15 条的分数都在变**(最大波动 0.07),其中 **1 条的分数在 0.54–0.60 之间反复越过阈值**——单次运行会让精确率在 **0.86 和 1.00** 之间跳。所以 Jev 的数字必须报重复跑的区间,单次结果不能当结论。
2. **默认阈值 0.6 会稳定地漏掉中文项目约束。** `不要改动 data/ 目录下的任何文件`(硬约束,标注为"该记")10 次跑下来是 **0.50–0.53**,`sqlite 写入失败:EDQUOT…`(可复现的坑)是 **0.34–0.41**——两条都稳定低于阈值。这不是噪声,是召回损失:@0.6 的召回 0.75 就是漏了这两条。

写进台账的判定分(`remember`)现在也可以直接从台账复核:`kind:"remember"` 记录里带 `by` 和 `model`,能看出某条记忆是 Jev 判的还是启发式判的。

默认取 `0.6` 而不是 F1 更高的 `0.3`:**假阳性比假阴性贵**——一条错记会进入此后每一个会话的提示。两个已知漏判也写在这里:`不要改动 data/ 目录下的任何文件。`(Jev 给 0.51,恰好压线)与 `sqlite 写入失败:EDQUOT...`(给 0.38——只看到一次,无从判断会不会复现;这条已由确定性规则补上:同签名重复 ≥ `repeatFailuresToWrite` 次才落盘)。

> **为什么分开写 Hit@K 和 Recall@K**:多正例下两者会分叉(Hit@5=1 时 Recall@5 可能只有 1/3),只写"Top-3 命中"是不可复现的。这个教训来自 `zilliztech/memsearch` 公开的中英检索评测——它同时发布了两套定义,参见 `docs/memsearch-notes.md`。
>
> 指标分工也定了:**Jev 只用在写入判定,不用做召回重排**。memsearch 的公开评测里 Jev 作为 reranker 输给 Voyage rerank-3(Recall@5 0.7941 vs 0.8187、MRR@10 0.6884 vs 0.7754),作者据此拒绝把 Jev 设为默认——那是"给已有候选排序"这个位置上的负结果,和我们"写入判定"不是同一个任务,但**任何对外表述都必须承认这条公开数据**。

---

- **两种投递方式**:正常情况下走系统提示里的运行时上下文(便宜、稳定);检测到 preset 关掉了运行时上下文时,改走一条**带插件来源的消息**(`form: "recall"`),并在台账记 `context-suppressed`。这条兜底是必需的:那种情况下宿主的装配器**根本不会调用我们的回调**,插件既不报错也不留痕——完全静默。

## 明确不做(避免和生态重复)

- **不做 UI / 面板**、不做向量库(类型化 + 少量记录不需要);
- **不做**"从反复失败里长规则"(那是 `dsh-jev-forge` 的题目)、**不做**全量写入的人工分诊——只在你和旧记忆真的矛盾时才打扰你(见上一节);
- **不碰 `dsh-jev-tools` 已覆盖的四个节点**:工具输出精简、注入筛查、技能推荐、交付闸门;
- 不与 `AGENTS.md` 争优先级:**人工手写 > 自动记忆**;冲突条目先落 `needs-review` 且默认不注入,再由你决定归属。

---

## 测试与验证

```sh
node --test test/*.test.ts      # 80 条,全离线,不联网、不启动 harness
pnpm run typecheck              # tsc --noEmit,零依赖包也能有真类型检查
```

包含一条完整的端到端闭环(用假 ctx 驱动真实代码):写入 → 下一会话召回 → `memory_search` → `memory_forget`。

**为什么是 TypeScript 而不用构建**:Node 22.23 默认开启类型擦除(`process.features.typescript === 'strip'`),所以 `import('./dsh/index.ts')` 与 `node --test test/*.test.ts` 都能直接跑,**没有构建步骤,也就保住了"零运行时依赖 + 可被 `file:` URL 直接挂载"这个性质**。代价是只能用可擦除语法(无 enum / namespace / 参数属性 / 装饰器),相对导入必须写显式 `.ts` 扩展名。类型检查靠 devDependency 里的 `typescript`(与运行时无关)。

真实 harness 验证已做过一次(记录在 `docs/DESIGN.md` 的"实测记录"一节):插件热挂进正在运行的 web profile,台账出现 `start`,一次子会话回合触发了 4 条写入,下一轮模型请求的系统提示里出现了 `## 长期记忆` 区块。

---

## 现状与未验证项(不编造)

- 离线全绿、真实进程内闭环已验证;
- **Jev 线上路径已用真实 key 跑通**:`POST https://api.typesafe.ai/v1/systemone` → `jev-1.13.0`,`docs/jev-api.md` 里的请求/响应结构逐字段对上(含 `answers.<id>.noul / .choice / .score / .confidence`)。**延迟量级 0.56–0.68s,但那是一次判 16 条、把上限临时调到 50 测出来的;默认上限是 6 条**(第 7 条起退回启发式,现在会记 `kind:"degraded"` 并告警);
- **live 判定已确认走 Jev**(2026-09-28 重启后核实):台账 `start` 行写着 `"version":"0.6.0"`、`"jev":{"ready":true,"source":"service:file"}`,且此后的写入/跳过记录都带 `"by":"jev"`、`"model":"jev-1.13.0"`。凭据走两条路(宿主凭据服务优先、直读凭据文档兜底),所以"服务在开机那一瞬还没就绪"不再决定判定方式;
- 多进程同时写同一个 `memory.json` 是 last-write-wins,单进程是受支持场景(TODO:锁文件);
- **代码改动必须重启 harness**:Cordis 的 HMR 能重挂载插件行、重读存储,但 ESM 缓存不会重新求值 `lib/*.ts`。这条是**实测**的,不是推测:一次语义变更触发的重挂载后,`start` 台账仍写着旧 `version`、也没有新字段。`start.version` 就是用来判断"跑的是哪份代码"的;换一个**文件路径**(如 `.js` → `.ts`)会强制换一套模块 URL,那是唯一不用重启的刷新方式;
- 同机还有一个同类插件 `@zilliz/memsearch-dsh`(见 `docs/memsearch-notes.md`):它与本插件**正交**,但挂在同一 patch 层,**同 profile 共存未实测**(可能双份注入)。

## 目录

```
dsh/index.ts          插件入口:钩子、注入点、三个工具,以及本地结构类型
dsh/lib/signals.ts    确定性信号词表 + 候选筛查(含密钥/任务指令/载荷否决)
dsh/lib/extract.ts    从回合事件里抽候选
dsh/lib/judge.ts      判定端口(Jev / 启发式)+ 确定性写入闸门
dsh/lib/jev.ts        Jev HTTP 客户端(凭据解析、重试、超时、解析)
dsh/lib/store.ts      memory.json + ledger.jsonl
dsh/lib/recall.ts     选哪些记忆、怎么渲染
dsh/lib/conflict.ts   冲突配对(词重叠)+ 三选项卡片 + 答案映射
dsh/lib/credentials.ts  直读凭据文档的兜底路径(极小的 refs 段解析器)
dsh/lib/text.ts       文本工具(分句、估算 token、哈希)
test/                 80 条离线测试(plugin.test.ts 用假宿主驱动完整闭环)
eval/write-precision.ts   带标注的写入精确率评测(可对标、可复现)
tsconfig.json         noEmit + allowImportingTsExtensions(Node 擦除模式可直接跑)
docs/jev-api.md       Jev 调用契约调研(带出处链接)
docs/memsearch-notes.md  同类项目 memsearch 的源码级调研与逐项对比
docs/DESIGN.md        设计决策与实测记录
```

Install

dsh plugin --profile web add github:lrqiisrom/dsh-jev-memory

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source