Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3E63DD?style=flat-square" alt="license"></a>
<a href="https://github.com/slow-stack/mneme/actions"><img src="https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml?style=flat-square&label=CI" alt="CI"></a>
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-22%2B-3E63DD?style=flat-square&logo=nodedotjs&logoColor=white" alt="node"></a>
<a href="https://github.com/slow-stack/mneme"><img src="https://img.shields.io/badge/tests-2654%20passed-3E63DD?style=flat-square" alt="tests"></a>
<a href="https://github.com/slow-stack/mneme"><img src="https://img.shields.io/badge/tests-2660%20passed-3E63DD?style=flat-square" alt="tests"></a>
<a href="https://codecov.io/gh/slow-stack/mneme"><img src="https://img.shields.io/codecov/c/github/slow-stack/mneme/main?style=flat-square" alt="coverage"></a>
<a href="https://github.com/awesome-dsh-plugin/awesome-dsh-plugin"><img src="https://awesome-dsh-plugin.com/badge.svg" alt="Awesome"></a>
</p>
Expand Down Expand Up @@ -270,7 +270,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790

```bash
cd dsh-mneme && npm install
npm test # 2654 个测试
npm test # 2660 个测试
npm run stress # 三轴线压测
npm run sync # src → lib 同步
```
Expand Down Expand Up @@ -550,7 +550,7 @@ Community projects that wire mneme into more platforms — not part of this repo

```bash
cd dsh-mneme && npm install
npm test # 2654 tests
npm test # 2660 tests
npm run stress # three-axis stress test
npm run sync # src → lib sync
```
Expand Down
8 changes: 8 additions & 0 deletions dsh-mneme/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@

- **sleep 的冲突相位在输入侧没有任何预算:全部候选对一次塞进一个 prompt、判 `keep` 不留痕,单请求实测 163,054 token(整池 163,840),失败后按冷却重排同一输入(#529,报告与实测由 @chengxinshengglj-png 提供)**:报告者的现场是一台 `sleepActionSet` 默认档的库——`sleep_conflict` 审计 131 行(46 success / 85 error),`input_tokens` 峰值 163,054,而服务端 `--ctx-size` 是 163,840(**占 99.5%**),`pairs` 443–449 而单轮 `applied` 只有 3–7;10-26 12:55 到 10-29 11:11 连续 **23 轮 llm failed**,`conflict_pending` 堆到 841 行。三件事互相咬合,缺一条都治不好:**①** 输入侧根本没有预算——`samePairs` 是**全部**同 scope 相似对,`listText` 一次拼完,模型只能看到前几对就撞上下文;**②** 判 `keep` 是彻底的 no-op(`applyDecisions` 只往进程内 `committed` 里 push 一下,不写任何东西),于是下一轮候选池逐字节相同、同一对每个冷却周期被重新问一遍;**③** 失败轮不带 `pairs`——131 行审计里 85 条 error 全是「送了个寂寞」,事后连送了多少对都查不到。四处一起补:**A 候选预算**:新增 `sleepConflictMaxPairs`(每批上限,默认 12)与 `sleepConflictMaxPairsPerRun`(每轮总量上限,默认 60),两者都 0 = 不限、回退旧行为;分批之后每批独立一次 LLM 往返 + 独立 `validateDecisions`/`applyDecisions`,**坏批只让这一批失败并计入 `degraded`,其余批照常落地**,只有一批都没成功才整轮 `failed`——此前任何一处理由(撞上下文、输出截断、JSON 坏掉)都是整轮失败。**B 裁决留痕**:新增表 `conflict_reviewed`(`memory_a`/`memory_b`/`judged_at`/`outcome`/`run_id`,`PRIMARY KEY (memory_a, memory_b)`)记「这段输入被看过」,`pair` 按字典序归一化 + `ON CONFLICT DO UPDATE` 幂等;预筛按「两侧 `updated_at` 都不晚于 `judged_at`」剔出候选池,任一字段更新即重新入池(与 `conflict_pending` 复核项 4 同一条宽松代理)。记账位置在每批 `validateDecisions` 之后,对本批**全部送审对**统一记、不只记被裁决成冲突的那几对——判 `keep` 的正是下一轮重复送审的元凶。**刻意不复用 `conflict_pending`**:那张表是人工待裁决队列,面板徽标数就是 `countConflictPending()`(`resolved_at IS NULL` 的行数),机器每判一对写一行会把这个数字淹掉,人就再也看不出「有哪些待我裁决」;台账零界面,不进 `listConflictPending`。**记账也不写进 `applyDecisions`**:那个函数 dream 与 sleep 共用,写进去会污染日间路径。**C 续排与退避**:新增 opt-in 的 `sleepFailureBackoff`(默认关),有效间隔 = `sleepMinIntervalHours` × 2^连败数、成功清零、封顶 `max(sleepMinIntervalHours, 24h)`(不是硬 24h:基数允许配到 168 小时,退避只放大间隔、不压缩用户自己设的基数)。**原提案的 `snapshot_hash` 判据用不了**:`runSleep` 里那个 `snapshotHash` 是 `createHash("sha256").update(JSON.stringify(phases))`——跑完之后对**结果**算的哈希,不是输入快照,拿它判「同一份输入又失败」会把不同的结果认成不同的输入;改用连续失败计数,与 `autoDream` 的失败退避(#292)同构。判据看 `status === "failed"` 而不是 `!ok`:`noop`(LLM 通路正常、只是没活干)与用户活动打断都给 `ok:false`,当失败会让退避在自己工作良好的库里越退越长。`armNextWindow()` 排下一次闹钟必须与 `shouldRun()` 用同一个函数——否则退避期闹钟会在基数到点响、被否、再重排,白转一轮。**D 审计补字段**:失败轮同样返回 `pairs`/`batches`(另加 `ledgerSkipped`/`perRunSkipped` 两个读数),失败批的 stream 层原文(`describeStreamFailure` 的 abort/error)进 `logger.warn`——只写 `llm failed` 的话运维分不清是上下文超限、模型拒收还是用户活动打断。**顺带修掉一个同类的谎**:分批之后「批间被用户活动打断」原本会落进 `noop`,而 `noop` 的语义是"看过、没冲突"——被打断的那几批根本没送出去。相位层改用 `aborted`(与入口处"一开始就 aborted"同一个词),`deriveStatus` 也补认它,否则整轮层会把相位层的 `aborted` 静默折回 `noop`,审计行照样记成"跑过、没什么事"。**报告者本地打了三键之后的效果**:单请求 806–2,011 token、连续失败 23 轮 → 0 轮、每轮台账跳过 32–53 对。**刻意不加 `sleepMinIntervalMinutes`**:`sleepMinIntervalHours` 早就在(`z.natural().min(1).max(168).default(8)`),再做一个「传 0 则回退小时键」的分钟键,面板上会出现两个都叫「最小间隔」的调节项。**不在本批**:报告者那 79 条 `input_tokens = 0` 的审计行属 #104 / #250 的传输族(流以错误结束、根本没有用量),与本条的输入预算无关,另账。回归测试 +16(`test/sleep.test.js` 6 条:分批上限、坏批只 degraded、判 `keep` 留痕且不重送 + 改一侧后重送、每轮总上限、**批间被用户活动打断时报 `aborted` 而不是 `noop`**(`noop` 的语义是「看过、没冲突」,而被打断的批根本没送出去)、失败轮仍带 `pairs`/`batches`;`test/conflict-reviewed.test.js` 4 条:键归一化、重复记账是 upsert、**记账不进人工队列**、按 `judged_at` 倒序;`test/sleep-failure-backoff.test.js` 6 条:默认关不翻倍、连败指数推迟、成功清零、封顶 max(基数, 24h)、**noop 不算失败**、抛错也计入),每条都做过变异自证。**评审补三处(CodeRabbit)**:① **台账预筛原先排在去重之后**——先按相似度占位、再把已审对摘掉,于是一条已审的记忆会把自己的配对名额占住,而它真正的对手(另一条没审过的记忆)因此进不了这一轮;现在预筛前移到去重之前,跨 scope 对不筛(它们要停车进人工队列)、`conflictFreezeEnabled` 档不筛(那一档只停车不送 LLM),`ledgerSkipped` 一次数清不再二次过滤。② **`reflectionUpdateMaxPerRun` 分批后退化成每批上限**——校验器不跨批记忆,N 批就是 N 倍上限;改为额度由分批循环持有、每批只领剩余额度、按本批**通过校验的** `update` 数扣减(`validateDecisions` 已把非法条目就地 splice 掉,数到的都真会落地),额度耗尽的那批若还含 `update` 被整单拒绝记 `failed`,其余批继续。③ **台账会把没落地的裁决记成已审**——原实现对整批送审对无条件记账且 `judged_at` 取落地之后,于是 `failures`/`conflicts`(CAS)/`skipped` 里那些没生效的对、以及 LLM 往返期间被外部改动的对,下一轮都会被 `alreadyJudged` 判成已审而永久压制;现在 `judged_at` 取**发送本批之前**的时刻,且本批 `failures`/`conflicts`/`skipped` 里出现过的 id,其所在对不记账。回归测试 +5(去重前预筛、跨批 update 额度、未落地对不记账、`judged_at` 的本批时钟域、单批抛错不带走整轮),五条都做过变异自证。**④ `judged_at` 还必须落在 store 的时钟域里**:`updated_at` 出自 `src/store.js` 的单调 `nowIso`(同一毫秒内连续写入逐次 +1ms),可能领先墙钟几毫秒;直接取墙钟会记出一个早于被测行自身版本的 `judged_at`,`alreadyJudged` 于是把「刚判过」读成「判过之后又被改过」,判 `keep` 的死循环原样复活(CI 的 `test-host-legacy` 腿抓到过这次真 flake)。改为取 `max(墙钟, 本批两侧最新 updated_at)`——两列同源才可比。**⑤ 一批抛异常会带走整轮**:`adjudicateBatch` 里 LLM 通路自己会把失败变成返回值,但 `applyDecisions` 之外的环节(台账写入的 `saveConflictReviewed` 之外,还有 `maintainIndexAfterDream`、以及未来任何新加的读写)一旦抛,循环就直接退出,前面的批次汇总全丢、后面还没送的批次一个不送,整轮被 `runSleep` 覆盖成失败相位。现在分批循环里加 `try/catch`,只放行 `AbortError`(与 `src/dream.js` 的 `if (error?.name === "AbortError") throw error;` 同款中断语义),其余折成这一批的 `failed` 结果继续下一批。**⑤ 的变异自证踩过一个坑,值得记**:最初把桩打在 `service.update` 上,用例是绿的但删掉 `try/catch` 也还是绿的——`applyDecisions`(`src/dream/decisions.js:532-540`)本来就把 `service` 抛出的任何非 CAS 异常吞进 `failures` 返回,根本打不到循环那一层。真正的注入点必须在 `applyDecisions` 的上游:改成让 `ctx.llm.stream` 在第一次 `sleep-conflict` 调用时直接 `throw`,删掉 `try/catch` 后用例立刻红。**教训:给「异常被兜住」的守卫写回归,桩必须打在兜底之外的那一层**,否则用例是自证循环——它证明的只是「被兜住的异常被兜住了」。

- **#493 的两条补充:失败计数落库(带读侧失效)、围栏断链抽成一份共享实现(#493 的报告者 `@strange2000` 指出的两个洞,均无配置开关)**:主链路(围栏 + 角色声明 + 失败留证 + 失败护栏)在 0.9.3/0.9.4 已经修完,这两条是他复核后追加的两处「治了症状但没治守卫」的遗留。

**① 失败计数只在进程里**:`summarizeParseFailureMaxRetries` 的判据 `parseFailureStreak` 原来是 `createSummarizer` 闭包里的一个 `Map`——重启即清零,而这道护栏要防的恰恰是「确定性失败被反复重试」,重启一次就绕过去了(两个宿主共用一个库时也各算各的)。新增表 `distill_parse_failures (session_id PRIMARY KEY, streak, updated_at)`,`store` 三个函数 `getDistillParseFailureStreak` / `bumpDistillParseFailureStreak` / `clearDistillParseFailureStreak`,`service` 三个 bookkeeping passthrough。**刻意不并进 `distill_cursors`**,三条理由:写 streak 不必先有游标行(窗口可能还没成功消费过任何 seq);「成功即归零」是 `DELETE`,而游标那句 UPSERT 是 `last_seq = MAX(...)`(单调),两种语义塞进同一句会把「归零」写成「保留最大值」,护栏永远不解除;失效判据要读时间戳,就地一行更省事。**失效在读数一侧判**(`staleAfterMs`,默认 7 天,常量 `PARSE_FAILURE_STREAK_STALE_MS`):报告者点名的反例是「游标停很久后再开跑,拿几周前攒下的 streak 直接熔断」——那样护栏本身就成了新的误伤路径。劣化方向特意选成 fail-open:时间戳解析不出来也按失效算,最坏是多花一次 LLM 调用。**这里的首版实现有个真 bug,被回归用例抓出来**:`bump` 最初在 SQL 里写 `streak = streak + 1`,于是「一条 30 天前的旧行」会把「新的第一次失败」直接加成 2,失效窗口在这条路径上完全不生效;改成「先按 `staleAfterMs` 读一遍再覆写」,失效判断只保留一份实现。`service` 缺这三个方法时(旧装配 / 测试替身)退回进程内 `Map`,行为与历史版本逐字节一致。**不新增配置键**:护栏本身默认关(`summarizeParseFailureMaxRetries: 0`),一个只为它服务的失效窗口不该再占一档用户可见配置(也要跟着动 `test/api.test.js` 的旗标计数锁)。

**② 断链只长在一处,另两处围栏没有**:`<<<` / `>>>` 的零宽空格断链原先只写在 `src/dream.js` 的 `fenceListBody` 里,于是同一件事有三条命——巩固/睡眠的清单有断链,**蒸馏转录(`src/summarize.js`)与画像条目(`src/profile-extract.js`)都没有**。前者的后果很具体:一条写着「`<<<会话转录结束>>>`」的用户消息能提前闭合围栏,把后面的文字升格成指令,而记忆正文本来就是模型的可影响输入。新增 `src/fence.js` 只导出纯函数 `breakFenceMarkers(text)`,三处共用;`fenceListBody` 内部改为调它(原 inline replace 删掉,静态锁盯着「不许再长出第二份」)。三个刻意的性质写进注释:**只中和标记不删内容**(去掉零宽空格后与原文逐字节相同)、**纯函数不带 nonce**(同一批记忆重试时提示词必须逐字节相同,否则「同输入同错」这条诊断依据就没了)、**不认围栏名字**(三对围栏都靠 `<<<` 前缀与 `>>>` 后缀,认名字反而会在新增围栏时漏一处)。

回归测试 +6(`test/dream-fence.test.js` +3:`breakFenceMarkers` 的形状与幂等、三处共用的源码静态锁;`test/summarize.test.js` +2:换一个 summarizer 实例后计数接得上、停了 30 天的旧 streak 失效后不误伤;`test/store.test.js` +1:落库 / 读侧失效 / 清零是真 `DELETE`)。变异自证三例:把 `staleAfterMs` 全改成 0 → 失效那条红;把 `persistentStreak()` 固定成 `false`(退回进程内 Map)→ 重启接续与失效两条都红;把清零改成写 0 → store 那条红。

## 🔒 安全

- **`SECURITY.md` 的平价守卫只读第一条清单行,于是「同一条目出现两份」对它完全隐形(#523)**:`test/security-doc.test.js` 的 `bullet(prefix)` 一直用 `doc.split(/\r?\n/).find((l) => l.startsWith(prefix))` 取那一行,而 `.find()` 只回第一命中——「文档是代码 `policy` 声明的投影」这条契约的全部信心都建立在它上面,可只要有人把清单多写一份,多出来的那份对守卫零存在感。这不是假想的形状:#509 的 merge 提交 `b5c0b09` 就把两份 `Open reads` / `Gated routes` 一起解了进来,第二份 `Gated routes` 是严格子集(少 `/memory/provenance`),而 CI 四条腿全绿。`b1491ff` 删掉了重复副本,但那是治症状——`.find()` 还在,下一次 merge 照样能合进来一份没人校验的清单。**修法是把「唯一」变成断言而不是假设**:`bullet()` 先把所有命中收进 `hits`(带行号),空了照旧报「应有以…开头的清单行」,多于一条直接红并把行号列出来——报行号是刻意的,否则第二份被抓住之后,「哪一份才算数」会变成下一个要人来回翻文件才能回答的问题。四个 prefix 全走这条路(`- **Gated routes` / `- **受门控的路由` / `- **Open reads` / `- **开放读`),内容侧那两条 `missing` / `stale` 反向断言一字未动:这次补的是「有没有第二份」,不是「这一份对不对」。**为什么不改成去重后校验合并结果**:把两份并起来再比,等于默许拆分清单(一份写一半),而这张表是给人读的一份东西,拆开之后没有任何一处能读出全貌。回归测试 +1(`test("重复的清单行会被守卫抓住(#523)")`):逐字相同的两份、以及第二份把 `/memory/provenance` 换成陈旧值的两份,都必须报出「必须恰好一条,实际 2 条」并带行号;另加「只有一份时不能误伤」。两条都做过变异自证:把 `assert.equal(hits.length, 1, …)` 换成 `assert.equal(hits.length, hits.length, …)`,新用例立刻红(6 pass / 1 fail),恢复后 7/7 绿。`bullet()` 多出的 `source` 参数只为可测——喂一份合成文档就能验唯一性,不必真去改 `SECURITY.md`。
Expand Down
6 changes: 3 additions & 3 deletions dsh-mneme/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
[![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
[![tests](https://img.shields.io/badge/tests-2654%20passed-success)](https://github.com/slow-stack/mneme)
[![tests](https://img.shields.io/badge/tests-2660%20passed-success)](https://github.com/slow-stack/mneme)
[![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions)
[![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org)
[![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme)
Expand Down Expand Up @@ -720,7 +720,7 @@ src/
├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列)
└── index.js # 插件接线
lib/ # src 的同步分发产物(npm run sync,prepack 钩子在发布时自动跑;一致性由 scripts/check-sync.js 在 CI 与 release.yml 校验——它只查不同步;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖)
test/ # 2654 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
test/ # 2660 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验)
scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单
```

Expand All @@ -729,7 +729,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压
```bash
cd dsh-mneme
npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试)
npm test # 运行 2654 个测试
npm test # 运行 2660 个测试
npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM)
npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行)
```
Expand Down
Loading
Loading