Repository navigation
v0.9.0
🆕 新增
-
M.O. 给图留一个出口:绕行、候选分层与「跳过 ≠ 绕开」(beta):一个只会被照着走的图会让 agent 失去自主性——它要么沉默地服从(照着走,做错了也不知道错在哪),要么沉默地偏离(觉得不适用就跳了,不记账,没人知道这次执行了几成)。一条规矩如果没留出口,走不通的人不会遵守它,会自己凿一个而且不告诉你。所以图上必须有一扇合法的逃生门:agent 判断「这条路这次可能不对」时,能按风险档决定记账先走还是停下问人。分界是一句话:护栏,不是轨道——允许偏离,但偏离要响。分档(2026-10-16 拍板 B 档):自报 R0/R1 且不跨闸门 且兜底清单没命中 →
decidedBy: "model",记账先走;其余 →"human",停下等点头。一句话:自主性住在 R0/R1,刚性只住在它必须住的地方。 三条各自独立、都要过:① 风险档是自报的,单靠它不够;② 兜底清单(IRREVERSIBLE_PATTERNS)机械匹配、不看自报——自报说 R0 但命令里有git push照样拦,这是「模型在自己上下文里给自己打分不可靠」的直接应用,不是不信任而是不给单点判断(清单可审计可 diff 可追责,让模型再判一次只是把同一个错误问两遍);③ 闸门保护——这条绕行如果走下去、图上某个gate会永远不被经过就拦住,与风险档无关(闸门本来就放在「错了很难撤」的地方,绕开它等于把安全阀绕掉,哪怕全是只读动作)。两条路由读的是两处不同的东西:/pipeline读定稿(住仓库、可提交可 review),/candidates读候选(住<memoryDir>/candidates/<项目键>/、只在本地)。候选不进仓库是刻意的:它是草稿,一天可能长十条死九条,进仓库就是垃圾、也会被当成规程引用。候选是定稿的唯一入口,定稿只放人亲手批过的东西——这正是「一个会自己起草 SOP 的学徒」那句的字面意思。<项目键>取「目录名 + 路径哈希前 8 位」:同一项目每次算到同一个键,但不把绝对路径写进文件名(那里面是本机用户名)。升格两条都要满足:走过两次(至少一次worked——「复用第二次才算类型,第一次只是实例」)且写得出反事实;够格之后系统提议、不自己写进仓库(提交是 R1、推送是 R2,分开两档、人始终握着一道),promoteCandidate()只返回内容不写盘,补出来的东西照样要过validatePipeline——升格不是降低标准,是补齐标准。怎么死:只走过 1 次且 30 天没再被走到 → 归档(dropped,文件留着当负清单的邻居);failed直接进负清单(走不通是知识);人否掉记理由。候选不需要人维护——它只增不减地长,死掉的是「没被再走到」的那些。「跳过」与「绕开」是两个指标,这一版把它们分开了:跳过 = 这次不用这一步(≥3 次 → 提议删掉这一步);绕开 = 这条路整个不对(≥3 次 → 提议重看这条路径)。混在一起数,两种提议就都发不出来。绕行不给节点染色——被绕开不等于这一步坏了(可能只是这次不需要),染色留给真正被推翻过的refuted(唯一有硬判据的信号)。四处只有实机才现形的坑:① 兜底清单第一版把工具名当成动作,于是gh pr view(只看一眼 PR)也被拦下,直接毁掉「自主性住在 R0/R1」——工具名 + 子命令才是动作,条目因此拆成commands/requires/forbids三层;②requires与forbids的语义不能混:requires 表达「必须出现才算数」,forbids 表达「出现就排除」,对「默认安全、显式才危险」的动作(gh api不写方法时是 GET)只能用 requires,用 forbids 表达「默认安全」会让只读调用被误拦;③ 三种处置第一版写成if/else if,绕行把跳过吃掉了——一次绕行很可能同时是「这一步这次没用」+「从这里岔出去了」,两件事驱动的提议不同,吞掉一个就少一条提议,改成各自独立累加;④ 变异验证本身会假绿:node --test --test-name-pattern在零匹配时照样报 pass,拿断言里的提示语当用例名跑,得到的是「变红 ✗」的假象(同类的还有「只查最终树的冲突标记」)。边界(写在契约的boundaries里,免得以后有人以为忘了做):autoPromote: false/autoApprove: false/injectIntoMemory: false——不做自动升格、不做绕行自动批准、不把候选塞进注入路径,也不做「图自动重写」(图的变化一律经过提议 + 人批,回写是新版本)。执行侧仍未开始:所以这一版只有判定与落账的代码,没有触发绕行的地方,提议与批准的动作也还没有落点。回归锁 24 条进新增的test/candidates.test.js(分档、兜底清单的 34 条子命令判定、requires/forbids 语义、项目键不含绝对路径、候选按形状归并、两次才够格、失败留痕、归档不删文件、升格必须过validatePipeline),另 7 条进test/pipeline.test.js(跳过与绕开分开计数、绕行不染色、两种提议互不吞并、suggestions字段必须在、候选路由与三条边界);关键规则逐条变异验红(兜底失效、闸门失效、子命令不分、候选不归并、reason 不再是硬要求、升格跳过校验、归档改成删文件、绕行吃掉跳过——各自精确红对应那一条)。 -
设置面板把折叠线划在「新手之外」而不是「冷门」,条目名改说人话(issue #427,纯 UI 层取舍与文案):issue 实测的账是——「功能」卡 24 个常显开关,分组名(基础/检索与增强/巩固与睡眠/多会话隔离)是架构图的分区而不是人的问题,条目名是术语(
bm25SearchEnabled/preInjectGate.enforce),折叠线划在「不常用」上,于是新用户敢动的只有 3–4 个,其余 20 个要读文档才敢碰。本次只动lib/client.js(手写文件,不参与 sync)与测试,config.js一位未动——验收红线就是 git diff 不出现任何.default(...)变更。①折叠线挪到「新手之外」(建议 1):新增FEATURE_FIRST_SCREEN = ["group.basic", "group.scope"],新首组只装 3 个总开关(autoInject/autoSummarize/autoDream),作用域组整体留在首屏——「它记的东西会不会被别的会话看到」是新用户的必答题,藏进折叠等于没答;其余组整组收进新的「全部设置」第一层折叠(showAll),原「高级」降为它内部的第二层(showAdv保留,7 个条目一位不动,标签「更少用的 7 个开关」与数组长度互相锁)。首屏可点项 = 3 总开关 + autoInject 的 2 个子开关 + scope 2 开关 + 「全部设置」按钮 = 8,压在验收线 ≤ 8 上。成员是从 core/dream 组里移走而不是渲染时过滤——renderGroup是唯一的组渲染路径(g.items.map(flagRow)字面量保留在其内部,inject-parent-gate.test.js的门照过),同一项因此只可能渲染一次。scope 组不拆进 basic 还有个现实理由:client.test.js:991的组成正则锁着key: "group.scope", items: ["scopeEnabled", "strictScope"],整组留首屏让既有锁零改动。②条目名与 hint 重写(建议 2):22 个条目中英成对改写——名 = 一句「打开会怎样」(「顺带带上最近几轮原话」「别人设为私有的记忆我看不见」),hint = 「开:…;关:…(术语,默认 X)」三段式,术语与默认值沉到句尾;writeAdmission与preInjectGate两级在 hint 里互相指路(「要真拦,见下一项/需先打开上面那项」),hybridInject/selectiveInjectEnabled/adaptiveThresholdEnabled按建议 5 点明是「带什么回来」的三档而不是三个不相干的并列开关;「默认 X」逐条对照过src/config.js的 schema 值。删掉basicToggle/advancedToggle.hint三对旧键,新增group.basic与allToggle,features.desc补一句「先看常用,其余收在全部设置」。③「默认 / 已改」徽标(建议 3):FeatureRow加 tag prop,flagTag(k)读state.overrides并用hasOwnProperty判「已改」——settings.getFeatureFlags()无值时返回{},键不在其中就是仍取 config schema 默认值,面板自判、不加后端字段、不动 API 面;两个徽标类照抄 contrast 测试已验过的语义色组合,test/contrast.test.js零豁免通过。回归锁进test/client.test.js:首屏层组成正则、basic 成员必须从旧组移走(负向断言 core/dream 组头不再以这两个开关开头——防「渲染过滤」式回归造成双重渲染)、restGroups只在showAll展开后渲染且「高级」按钮序在其后、FEATURE_ADVANCED_BOOLS恰 7 项、徽标与分组/折叠键中英成对(occurrences ≥ 2)、判定必须走 overrides 而非后端字段。既有锁(inject-parent-gate、A4 scope 组、i18n 计数、contrast)全部零修改通过,全量 1772 tests 全绿。 -
M.O. 从「全局」改成「项目级 + 有名字的清单」(上一刀的回填):上一刀把工作流本体放进了插件包自己的
pipelines/,于是不管在哪个项目、哪个会话打开,读到的都是同一套——「项目级」这个词当时根本没落地。发布前的实测反馈指出:不管在哪个项目,读到的都是同一套工作流。根因三处都在这一刀自己写的代码里:①defaultPipelineDir()取的是import.meta.url的上一级,也就是装了哪个包就读哪个包的;② 面板把它注册成右栏页面类型,而右栏是面板级、与项目无关,会话头那个座位的标签又写死成「M.O. · 手法」,于是到处长一个样;③ 一进面板就是一张图,id与title只画在图顶一行小字,没有能点选的条目——Hermes 那边是一串能干活的条目,——条目列表才是这个功能该有的形状。改法:本体搬到<项目>/.mneme/pipelines/<id>.json,读的时候项目层在前、全局层(包内pipelines/,今天为空且有意为空)在后,顺序即优先级;同 id 两处都有则项目层赢,并把被挡住的那份记进shadowed——不静默丢弃,否则以后有人往全局层加一条同名工作流、发现它「不见了」,会去翻文件系统而不是查这条记录。项目层的坏文件进unreadable(带scope/dir/detail,detail点名是哪个文件)而不拖死全局层:一层写坏不该让人连兜底那份也看不见;全局层是自家发出去的包,坏了照抛。「默认项目级」这句话的落点就在这里,而它不只是口味:本机 7,647 条记忆里带workspace_scope的只有 227 条(≈3%)、活跃的 2,629 条里 2,517 条(95.7%)为空——默认全局等于把一条本地规约推给所有项目,会原样重演「全局变成谁都不看的杂物堆」。project由客户端从会话读出来带过去,服务端不猜:插件侧的ctx上只有logger/llm/reflect/webServer/inject/commands,没有 cwd;取不到时如实标「项目未知」,绝不回退process.cwd()——回退会把「读不到」变成「读到别处」,而后者在界面上看不出来,是最难查的一类错。路由GET /api/dsh-mneme/pipeline增加?project=<绝对路径>,不是绝对路径就回 400project-not-absolute:忽略它比拒绝危险得多——忽略会静默退成「全局」,而全局看起来一切正常。面板第一屏从「一张图」改成清单 + 图同屏(清单当选择器:名字 + 作用域 + 步数/闸门数/对照覆盖率 + 状态计数;画布在下面,默认展开第一条,点别的条目只是换图)——中间那一版做成「清单 ⟷ 图」两屏来回切,结果画布藏在一次点击后面、界面上没有任何提示,反馈连着两次说「看不到画布和节点」;作用域标签只取目录名(projectLabel()),绝对路径里有本机用户名,而面板是会被截图的那一面。两处必须拆成两个组件的地方:清单与图不能在一个组件里按openId分支——hook 不能条件调用,那样写会在点开那一下炸成「Rendered more hooks than during the previous render」,运行期才看得见;投影缓存也按项目 + 按工作流分桶,否则同一个进程里开着两个项目时会把 A 项目的清单端给 B 项目看,而画面看不出错。回归锁:test/pipeline.test.js14 → 18(清单行自带规模与作用域、同 id 项目层赢且报出遮蔽、项目层坏文件不拖死全局层、不给项目根就只读全局层),其中原来那条「pipelines/必须进package.json的files」原地反转成「不许在包里」——两头都防:进包则装完之后每个项目都能读到它,「项目级」又悄悄退回全局级,而这一幕在任何一次本地测试里都看不出来(测试读的是仓库目录);test/client.test.js三条既有锁按新签名改写,并新增「第一屏是清单不是图」。全量npm test1796 / 1795 pass / 0 fail / 1 skipped。同一段[Unreleased]里上一条 M.O. 文末那句「package.json的files漏了pipelines(已补,并加锁)」在本条被反转,两条一起发布时以本条为准。一处只有实机才现形的自伤:新写的详情屏容器与既有的「节点详情」面板撞了同名类.mneme-pipe-detail——后写的flex:1赢下冲突,先写的max-height:180px; overflow:auto却留着,于是详情屏被钉在 180px、里面的画布flex:1分到 0 像素,屏幕上只剩一片空白加一条滚动条(截图确认)。只查颜色的test/contrast.test.js抓不到这类撞车,所以补了一条通用的类名撞车锁(.mneme-pipe-*每个类名只许有一条规则,连扫描量一起钉住,避免正则失配时假绿),并给画布与窄态列表加了高度下限——flex:1的基准是 0,祖先没有确定高度时(浮层与居中 sheet 都是max-height封顶)画布会「在 DOM 里、但看不见」。窄态阈值从 640px 降到 320px:那张图是竖链、固有宽度只有 174px,栏窄一点反而更该画图——640 那档是按当年摊得很开的假数据定的,真数据下它把右栏(通常 400~600px)整个推给「一列节点卡片」那一支,于是「看不到图谱」。缩放条也提到宽窄分支外面,成了「新代码到没到」的肉眼标记。画布可缩放:−/+/ 「适应宽度」三个按钮,外加 Ctrl/⌘ + 滚轮。缩放靠改外层容器的百分比宽度而不是transform——百分比改的是布局尺寸,容器照常出滚动条,平移顺带就有了;transform只改绘制,放大出去的那一半永远滚不到。滚轮那一条自己挂非被动监听:React 的onWheel是挂在 root 上的被动监听,preventDefault()会被浏览器忽略(控制台那句Unable to preventDefault inside passive event listener),Ctrl+滚轮会变成「画布和整页一起缩」——这个错本地测不出来,所以测试锁的就是这两个字面量。一处只进浏览器控制台的自伤(实机复现):右栏那个 tab 的类型注册与正文座位注册原本写在同一个try里。宿主对重复的类型 id 会抛(sidebarRight: tab type id "…" is already registered),而这两件事的生命周期并不一样——类型挂在宿主服务sidebarRightTabs上、插件重挂时留着,正文座位挂插件 scope、随插件回收。于是第二次挂载时类型那一抛把正文一起带走:界面上只剩宿主的 fallback「这类内容还没有可用的查看方式」,而catch里只有一句console.warn,界面上零痕迹。改成两个try各自兜底 + 类型注册前先tabs.get(id)幂等判断;回归锁按「类型注册块里必须有 catch、且必须出现幂等判断」来钉(把幂等判断去掉即变红)。已知边界:画布观感未动(后续再补);执行侧与运行记录写入仍未开始,所以清单上每一条的状态计数都还是「候选 19」。 -
M.O.(Modus Operandi · 手法,beta):服务端旗标 + 客户端三面:新增 feature flag
actionPipelineEnabled,默认 false(opt-in)。config.js的 schema 与settings.js的白名单成对登记,test/api.test.js的旗标计数锁 96→97,并补默认值与 PUT 往返断言——只改白名单不够:effective 的默认值只从config.js的 schema 来,缺 schema 时无 override 会静默少一个键。显示名给全名「M.O.(Modus Operandi · 手法)」,缩写不靠注释解释、靠设置项与 hint 的第一句让用户第一眼看见;内部标识(旗标键与数据契约mneme.pipeline/v1)保持原样,改名纯亏。客户端在lib/client.js,三个座位(会话头 action、对话行卡片、右栏 tab)全部在旗标为真之后才注册,无旗标时一个座位都不注册;模块级 inject 仍只有["slots","locale"],右栏是软依赖。本阶段只到「界面就位」:服务端没有 M.O. 的表与路由,界面接的是内置样例数据,旗标默认关,因此对用户可见行为零影响。 -
会话蒸馏写入也盖作用域章(项目记忆的地基):
scopeEnabled开启时,此前只有memory_save那条工具路径标注agent_scope/workspace_scope,而机器提炼是写入量的绝大多数——实测某次真实部署的库中 7,385 条source='session:*'只有 1 条带 scope,于是「项目记忆」在库里几乎不存在。现在summarize.js复用同一把解析器(scope.js的createScopeResolver)与同一把闸门(scopeEnabled),值取session.header的agentPreset与 registry 反查的 canonical path(header.cwd兜底,取不到落 NULL,解析绝不阻塞写入),来源标'auto'——auto 不构成硬过滤,只吃软加权。合并进既有行时不盖章:作用域属于那一行原来被提炼出来的语境。关闸门时一个 scope 字段都不加,老行为逐字节不变。副作用:去重键含 scope,新盖章行与存量未标注行不再互相合并,过渡期可能出现同题重复——跨项目隔离是本次想要的效果。 -
面板补上蒸馏留存的两个开关,文档里那句「面板可启停」不再是空头支票(#399 跟进的第三步):
docs/CONFIGURATION.md写着distillRecords.enabled与distillRecords.retentionDays「两个键都走 feature_flags 白名单(面板可启停)」,而后端确实全都就位(config.js的 schema、settings.js的白名单、api.js的NESTED_FLAG_PATHS)——唯一缺的是面板:lib/client.js的FEATURE_GROUPS是一份写死的清单,这两个键在该文件里全历史零命中。用户照文档去找开关,够不着;验收这个功能只能绕开产品界面直接写库。现在留存总闸挂在 core 组末位,保留天数收在它的子块里、只在总闸开着时展开(关着一行不写,改天数没有意义),子块按组尾渲染所以总闸必须在组末。新增的整数输入行type刻意用text+inputMode="numeric"而不是number:number 输入框在部分内容被判非法时e.target.value会变成空串,草稿与用户所见脱节,就没法把非整数挡在本地并退回生效值。中英各两条文案,hint 讲清两件事——这是原始对话落盘(隐私面,所以文案要说明私密推理与工具结果不落),以及天数只管启动期裁剪(reclaim 走另一条--older-than,不读这个键)。8 条变异逐条验红(草稿不从 effective 初始化、天数行不看父开关、非整数不退回生效值、enabled 从组里删掉、数值键不进清单、中文文案删掉、子块不接到组尾、数值行不从 numRow 渲染);其中头一条断言起初写成子串匹配,把eff["distillRecords.enabled"]也算了进去——删掉组里那一项照样全绿,改成把 core 组的items数组抠出来单独看才成立。检验「某容器里有没有 X」必须定位到容器再查,子串断言会被别处的合法引用蒙混过关。 -
M.O. 从「界面就位」走到「读真数据」:工作流的本体住仓库,面板不再画假数据:上一版客户端三面接的是内置样例数据(
lib/client.js的PIPELINE_MOCK),服务端一条路由都没有——界面能开、形状对,但图上那 8 个节点与 3 条运行记录全是常量。这一刀补上读侧与数据面,并把那份常量删干净。住址的选择是这一刀的核心:工作流是要跑的东西,必须能提交、能 diff、能整版回退、能 PR、能 review、能被 CI 读——记忆库一条都给不了(表里改一行没有 diff);而「为什么这么定」与「跑得怎么样」反过来只有记忆库记得住。所以分工是记忆是「为什么」,仓库是「怎么跑」,图是「看起来怎么样」,三者不下同一张桌子:把工作流塞进记忆库,它会长成一份死文档。新增pipelines/<id>.json(一条工作流一个文件)与src/pipeline.js(读盘 → 校验 → 投影,零运行时依赖)。为什么是 JSON 不是 YAML:本包运行时依赖只有morphicons一个,为配置文件引 YAML 解析器不划算,手写子集解析器则是给未来埋一个「读起来对、跑起来错」的坑;人读的形态交给面板渲染,不靠文件格式。校验很硬:id/name/project/version/status/done与每一步的phase/title/when/do/done/why缺一不可,risk限 R0–R3、who限 main/subagent/human/ci、needs必须指向存在的步骤、图里有环直接报错——任何一处不合规就整条读不出来,不做「跳过坏文件继续」:半个工作流比没有工作流更危险,因为它会被当成完整的跑。报错一次列全(PipelineError.problems),不让人一轮修一条。新增只读路由GET /api/dsh-mneme/pipeline[?id=],刻意不做功能旗标闸门:旗标管的是客户端座位(关=一个座位都不注册),不是数据面,给它加闸门只会让「为什么面板打不开」多一种成因;读不出来回 500 +pipeline-unreadable,detail就是校验器那份清单。答复契约mneme.pipeline/v1——前端渲染与 agent 读的是同一份数据,画面只是它的投影(只画不存,agent 就什么也读不到)。投影里counterfactual装的是准入判据(「没有它,这次会不会失败」)而不是备注,答不出对照的步骤在图上要显得比答得出的虚;节点状态四值(refuted/trusted/stale/candidate)全部从运行记录推,不由作者写死——今天还没有任何运行记录的落点,所以全体恒为candidate,而这正是面板上那句 beta 免责必须摆在脸上的原因。客户端四处(会话头图标、居中 sheet、右栏 tab、对话行结算卡)改成读同一份模块级缓存,杜绝「会话头说有 3 条候选、右栏说有 5 条」这种两个数字都对、没人查得出来的分家。三处踩到的坑:①package.json的files原本没有pipelines——漏了的话源码树里跑得好好的,发布出去的包一条工作流都读不到(已补,并加锁);② 右栏 tab 原先打算把 hook 直接塞进slots.register的回调,改成包一层具名组件:那个回调是不是当组件渲染由宿主决定,赌错就是「Rendered more hooks than during the previous render」这种只在运行时炸、测试看不见的错;③ 校验器写完后自己验出两个 bug——tallyRuns建了计数对象却忘了放回 Map,于是所有节点都算无记录;测试对 node 用了不存在的needs字段(node 不暴露 needs,判达点必须走edges)。回归锁 14 条进新增的test/pipeline.test.js(必填字段、坏文件整条抛、重复 id、悬空 needs、成环、投影形状、状态四值、空目录、files白名单),另有 5 条客户端接线锁进test/client.test.js(假数据不得复活、四个座位共用一份缓存、数据面路由名、右栏 tab 必须走具名组件、空态与「全候选」两句文案中英齐全),5 条逐条变异验红。文档同步:新增dsh-mneme/docs/WORKFLOWS.md(住址的选择、文件形态、对外接口、状态如何推导、已知坑、还没做的),AGENTS.md模块地图加一行,中英 README 的文档表各加一行。已知边界:执行侧(触发、拆子 agent、接进 CI/CD)与运行记录的写入都还没做,今天只有读侧;source.memoryIds是留给本机的口子(记忆 id 是本机私有数据,不进仓库文件),今天还没有人填它。
🐛 修复
-
候选层的项目键不再借平台的
basename():同一条路径在 Linux 上不再退化成整条路径:projectKey()原先用path.basename(root)取目录名,而它跟着运行平台走——posix 上\不是分隔符,一条 Windows 路径会被整条当成文件名,再经「只留安全字符」清洗与 32 字符截断,得到C--Users-<用户名>-Desktop-<项目名前段>:用户名的残片进了目录名,而这把键的设计前提恰恰是「不把绝对路径写进文件名」。这条缺陷在 Windows 腿上是结构性看不见的(win32.basename两种分隔符都认,旧实现在本机算出来的就是对的),所以它在 CI 上的形状是 ubuntu 四腿全红、windows 两腿全绿。改成按[\\/]两种分隔符直接切、取最后一段非空片段,与平台无关;反斜杠、正斜杠、尾随分隔符三种写法结果一致,Linux 路径上的结果与改动前逐字相同。新增回归锁 1 条(三种写法都必须取到目录名,且目录名里不得残留分隔符与用户名),并已用变异检验确认杀伤力:把实现退回平台相关写法,新旧两条用例同时转红。这条锁在 Windows 上永远是绿的、也永远抓不住回退,注释里已写明它的杀伤力只在 ubuntu 矩阵腿上——免得日后有人在 Windows 上跑绿了就把它当冗余删掉。 -
面板上的作用域标签不再借平台的
basename():Windows 路径不再整条画进面板:projectLabel()原先用path.basename(projectRoot)取目录名,而它跟着运行平台走——posix 上\不是分隔符,一条 Windows 路径会被整条当成标签。这个函数的注释本来就写着「只取目录名,不把整条绝对路径画出去:绝对路径里有本机用户名,而面板是会被截图、被贴进 issue 的那一面」,旧写法在 Linux 上恰好把这条规矩破了:标签里带着本机用户名整条。改成语义显式指名——含反斜杠按 Windows 语义解析、否则按 posix,两个平台的正确结果就此一致;""回退整串的原语义一位不动。这条缺陷在 Windows 腿上是结构性看不见的(win32.basename两种分隔符都认,旧实现在本机算出来就是对的),所以它的杀伤力只在 ubuntu 矩阵腿上。新增回归锁 1 条(反斜杠 / 正斜杠 / posix 三种写法都必须取到目录名,标签里不得残留分隔符与用户名,取不到时回退原串),并已用变异检验确认杀伤力:把实现退回平台相关写法,本用例连同两条既有锁一起转红。 -
面板的保存失败不再静默,被拒的草稿退回生效值:功能开关卡片里,写入失败的提示只有一个座位、且带
!state条件——首屏加载失败才渲染;state一到手,此后每一次保存失败都不出声。于是「框里的值」与「在用的值」可以长期分家而人看不出来:新加的保留天数输入行把这条路径变成可达的(填 99999 提交,服务端按 1–3650 拒掉,面板照旧显示 99999)。改三处:put读出服务端 400 的error正文(api.js那条 400 本来就是为此把键名与范围写进去的,此前被丢掉只剩状态码),失败写进独立的saveError并在卡片描述下方渲染(不跟首屏那条走),返回值告诉调用方成败——字符串项与整数项都在失败时把草稿退回生效值。4 条变异逐条验红(摘掉渲染座位、丢掉 error 正文、整数项不退回、字符串项不退回);认「座位存在」的断言起初只查setSaveError的出现次数,而写入路径调它、渲染处读它,两处都在就算命中,必须把渲染位置单独钉住。已知边界(本次刻意不动):两个语言下的文案是「保存失败 — <服务端原文>」,服务端原文是英文;要汉化得在服务端另造键值对,那是另一件事。 -
失败表的保留期从写死改成可配(issue #275 伞下):
failure_memories(用户纠正 / 反思失败记录)的启动期清理此前是store.deleteOldFailures(new Date(Date.now() - 90 * 86400000).toISOString())——排在createStore之后、cfg装配之前,90 天硬编码在表达式里。它是四张启动期清理表(llm_audit_logs/distill_records/recall_runs/failure_memories)里唯一没有键可陈述的一张:另外三张各有自己的保留期键(llmAudit.retentionDays、distillRecords.retentionDays、recallRetentionDays),只有它既改不了也看不见。这次动的不是「加一个键」,是「挪一行清理」:新增顶层扁平键reflectionFailureRetentionDays(z.natural().min(1).max(3650).default(90),形态对齐recallRetentionDays),并把那条清理下移到cfg装配之后、与另外三张并排,从此读的是装配后的值(装配前读rawCfg会漏掉合进来的开关,另外三张的注释就是这条教训)。默认值 90 一位不动,不配置的库行为逐字节一致。为什么这次不做成面板可调:进settings.js白名单要同时补api.js的NESTED_FLAG_PATHS、lib/client.js的面板行与test/api.test.js的计数锁(#254 的教训:漏一处effective会静默缺键),那是三处机械改动、属「有人真要调」才值得花的面积;这一刀只补「写死的数字」这条欠账,面板留作后续,lib/与白名单一位未动。下限取 1 而非 0:这张表的用途是挖掘重复出现的纠正模式,窗口太短就只剩最近一次;要彻底停掉「记录用户原话」,源头关reflectionFailureTracking比写了再删更省(也少一段盘上停留)。已知边界三条:①dsh-mneme reclaim不读这个键——它只覆盖dream_run_inputs/archived_embeddings/distill_records三张表,本表不在其中,所以「让 reclaim 按同一个天数回收本表」目前做不到,得等 #275 那张伞往下收;② 本表没有 session 归属字段(#435 评审的同一条 concern),归属仍靠本地库的文件边界 + 启动期裁剪;③ 保留期只在启动时读一次,改完要重启才生效。回归锁 5 条进新增的test/failure-retention.test.js:行为锁(删窗口外留窗口内、边界两侧各留 1 小时裕量只锁比较方向、空表返回 0 不抛)、接线锁(清理必须排在cfg装配之后、天数必须取自cfg.reflectionFailureRetentionDays、写死的Date.now() - 90 * 86400000不得复活)、配置锁(默认 90、0/3651/2.5被拒、键形与recallRetentionDays一族同形、README 与docs/CONFIGURATION.md必须同步列出该键——加了键不改文档正是这一族欠账的来源)。五条变异逐条验红:天数写回 90 / 改读rawCfg/ 把清理挪回装配之前 / 默认值改成 30 / 文档里删掉该键,各自让对应用例转红,还原后摘要一致。 -
写路径不再为了取 20 条正文而物化整个记忆库(issue #430):
saveWithDedupe的质量过滤近重复判定只要最近 20 条content,代码却是store.all().slice(0, 20)——全表SELECT(updated_at DESC,无 WHERE 无 LIMIT)逐行走toRow(27 字段、三次 JSON 解析)之后只取前 20 条正文。memoryQualityFilter.enabled默认 true,于是这笔开销挂在每一次存记忆上:5k 行实测all()96.3ms /all().slice(0,20)95.0–114.1ms,而它真正要的那份数据(SELECT content FROM memories ORDER BY updated_at DESC LIMIT 20)是 0.06ms 级——三个数量级,也是本次唯一那种量级的改动。新增store.recentContents(limit = 20)承接它,口径与all().slice(0, 20)严格一致:不加 WHERE(archived/forgotten 行仍参与近重复判定,all()也不筛)、复用同一份updated_at DESC、同刻顺序未定义、limit 走sanitizePage(0/负数/undefined 落回 20,调用方不必自己防)。同一批把store.list()的SELECT *换成与all()共用的显式列清单(memoryColumns由 PRAGMA 派生、排除 embedding;toRow本就不输出向量,SELECT *只是把全表最重的列拖进内存再丢弃):无界档 5k 行 263ms → 117ms,窗口档 50ms → 41ms,受益的是本函数全部调用方(面板分页、document 索引、sleep 模式池、注册器精确 supersede 的全量扫描)。第三处落在写提交路径上的是syncMirror(每个写事务的finally都会走到):它此前store.all().filter(...)物化整库再逐行丢弃不可见行,现在改走store.list({ limit: null, order: "chrono", excludeTypes })——list默认的includeForgotten:false+includeArchived:false与旧 filter 逐字同口径(store 里就是这两条 clause),document 闸改由excludeTypes在 LIMIT 之前排除,#296第二批那条行为(闸关时顺手删掉陈旧的 documents.md)原样保留。顺带发现chrono排序里藏着一棵临时 B 树:ORDER BY updated_at DESC, id DESC用不上 #202 建的idx_memories_updated(EXPLAIN 多一条USE TEMP B-TREE FOR LAST TERM OF ORDER BY,100.6ms),去掉平局键后走纯索引扫描(23.4ms,裸 SQL 不含 toRow 21.4ms——代价全在那棵树,不在toRow)。改动只针对无界档(没有跨页拼接,今天唯一调用方 syncMirror 只用它算覆盖集,与顺序无关);分页档的 id 平局键一位不动——跨页拼接要求稳定,同刻两行在相邻页之间会漏一条或重复一条,谁去掉谁错。端到端 A/B(同一份 5006 行 fixture + 真镜像 + 质量过滤,各 9 轮取中位):saveWithDedupe370.24ms → 273.95ms,每次业务写约 −96ms(≈ −26%)。回归锁 6 条进新增的test/store-write-path-columns.test.js:recentContents与它替掉的all().slice(0,20)按集合等价且逐位同序(含 0/-1/undefined 落回 20、archived 行仍在、空库;顺序那条锁由评审提出,变异检验确认「取到的行一个不少、只是全反序」的写法集合锁抓不到)、list()不泄漏 embedding、SQL 级探针(包住store.db.prepare逐条断言:无SELECT *、无embedding、recentContents那条必须带LIMIT)、质量过滤写路径不得再碰all()(抛错探针)、syncMirror 不得再碰all()(抛错探针 + 真镜像)、document 闸在渲染之前就由 SQL 决定(含反向判据:闸开→documents.md 出现且含该 id,闸关→删除)。6 条变异逐条验红,其中两条初版抓不住、补齐判据后才转红的值得记下来:「syncMirror 不排除 document」起初全绿(假 document 行根本没进镜像,判据没判别力),补种真 document 行(store.saveDocument要求doc_path)+ 闸开/闸关反向断言后才红;「recentContents退回SELECT *」起初全绿(列清单里恰好没有 embedding),把探针扩到recentContents自身那条 SQL 后才红。已知边界(刻意不动):①这笔收益按活跃集比例走,不是常数——在几乎无归档行的干净库上 syncMirror 的新写法(100.6ms)反而比旧all()+filter(92.6ms)略贵,把一半行标 archived 后是 68.9ms 对 99.4ms;质量过滤会持续把低分行归档,真实库非零归档,所以按老写法钉「一定更快」的断言不能写;②本轮只动「每次业务写都付」的两条,dream.js/dream/sleep.js/recall-stats.js里的低频all()留给后续批次;③要再压 syncMirror 那层行数线性得换按 type 分页取,而任何截断都还不能加——「宣称覆盖活跃集」与静默截断不能共存(#278 第一批的纪律,注释里原位保留)。 -
连续性抢救的触发器从「试过压缩」改成「历史真的被折叠了」(#249 N3 后续):宿主的压缩插件在 每一步 都会试一次压缩(
agent/pre-step里的compactIfNeeded),而代理层会把原生压缩整个拒掉(本机实测:两个长会话 12,000+ 事件里compaction/start与带error的compaction/end各 1,287 / 2,895 次,compaction/summary0 次)。原口径把compaction/start|summary|end一视同仁地当成「压缩边缘」,于是每次失败尝试都置位一次:一次都没折叠成功的会话,触发器退化成「每步都救一次没被折叠的历史」。实测注入量 359 / 625 次,而真正落地的折叠只有 39 / 161 次 —— 降 91% / 88%。根因在宿主的事件契约里(dsh-compaction-basic/lib/index.js):compaction/end成败两条路都发,成功路只 spreadlifecycle,失败路额外带error(:484vs:495);只有compaction/summary是「正文真的提交了」的发射点(commitCompactionBody,:634),而compaction/start只是开锁标记(源码注释自述「a failed close deliberately leaves the unmatched start detectable」)。现在只认三种内容真的被移除的事件:compaction/summary、compaction/prune(tool-result-pruner 把超预算的旧结果真的折叠出表面,cmp-pruner.js:159)、以及不带error的compaction/end;compaction/start与带error的compaction/end不再置位。为什么不能只认compaction/summary:那个口径在本环境得 0 次注入 —— 那不是修好,是把这个功能停用。注入洪流本身是有代价的:「不变不重复」判重按全文比较,而快照的next_step取最近一条 assistant 回复的尾部 200 字符,每一步都在变(实测 331/331、551/596 全部不同),判重挡不住它;每条注入都是往工作线里追一条消息,是代理层按内容哈希记账的fold-reconcile的候选改写源之一。回归锁 3 条(test/continuity.test.js):start 与带错 end 都不置位且提案不落库、三种真落地都置位、以及照实测形状复刻的「50 次失败尝试 → 0 次注入 / 1 次真 prune → 恰好 1 次注入」。这三条是必需的:文件里既有的 11 处compaction/end触发点都不带error,按新旧两种口径都算落地,一条都抓不住这次改动。变异检验 3/3 逐条验红(退回旧口径 → 3 条转红;只认 summary/prune 漏掉无错 end → 8 条转红;带错 end 也放行 → 2 条转红)。触发口径同步写进docs/CONTINUITY.md与src/config.js的键注释。 -
连通性测试不再把「档位被拒」报成连接失败(issue #215 后续):面板的「测试连通性」按钮此前是仓里唯一「接受并转发调用方档位、却没有兜底」的 LLM 调用面——
src/api.js的testLlmConnectivity直接调llm.stream,档位被拒就 502 报红;而巩固/睡眠/蒸馏/总览/实体抽取的真实路径全都包在withEffortFallback里(实体抽取是src/index.js的手写等价版),被拒后丢掉reasoningEffort重试一次即成功。于是同一份配置「后台一直在正常跑、点测试却报错」,把用户引向「换哪家 Provider 都失败」这个错误结论(实际换 Provider 无用——档位是模型侧的能力声明,与线路无关)。现在探针复用dream.js的withEffortFallback+EFFORT_REJECT_RE(单一来源,不手写正则),路径与语义与真实 run 完全一致;降级跑通返回 200 并在响应里置effortDropped: true,面板在中英双语下把「连通了、但不是你配的档位」讲出来——静默成功和静默失败一样有害:前者让人以为档位生效,后者把人赶去换 Provider。真失败(鉴权、余额、网络)照旧 502 带原始原因,绝不重试、也不带effortDropped(不制造假降级)。流级拒收也已覆盖:适配器把 provider 的拒收折叠成终止errorfinish chunk(不抛错)时同样触发降级,只在 catch 里兜底的实现等于死代码。同时清掉一处从落地当天就是错的前提:面板与测试里那句「sleep 无回退重试,测试是事前唯一的暴露口」——git merge-base --is-ancestor 939fbfc 25f90e4证明回退(2026-09-08)早于该注释(2026-09-17),它不是腐化而是出生即错,两处注释与新增静态锁一并收口。回归锁 7 条(改写 1 条既有锁):抛错路径降级、流级拒收降级、真失败仍 502 且不盲重试、抛错型真失败(socket 断开)同样不盲重试、无档位配置时不报降级、防分叉静态锁(api.js不得自带拒收正则、必须 import 单一来源且只经withEffortFallback一次)、面板降级呈现(严格=== true读取 + 文案只在成功行 + 双语成对)。既有那条「抛错即 502」锁按行为变更原地反转成「降级即 200」,并保留其调用计数断言(降级必须是恰好两次:带档位一次、无档位一次)。13 条变异逐条验红(含「探针不做降级」「流级拒收不交还」「真失败吞成成功」「catch 路径盲重试」「catch 路径把真故障吞成成功」「重试仍带档位」「自带拒收正则」「只加中文」「陈旧前提复活」等);其中「重试仍带档位」由降级用例先一步抓住(重试仍被拒 → 502 而非 200),比字段断言更靠前,属预期。已知边界:result === undefined那道守卫按构造不可达(两边用同一份EFFORT_REJECT_RE判同一个字符串,中间无变更;codecov patch 会把它标为 uncovered)——保留它是因为该分支一旦真被走到就意味着判据漂移,宁可退回已捕获的流失败原因也不让探针 500;本仓无c8 ignore先例,不为两行引入新约定。 -
实体抽取的失败原因不再塌缩成一句话,审计行补上它归属的那条记忆(issue #405):
src/index.js在流以 error/aborted 结束时写死writeAudit("error", "llm stream aborted or errored"),reason.failure{message,code,status,providerRetryAfterMs}一个字段没读——主库llm_audit_logs里operation_type='entity_extract'共 1464 行,其中 error 735 行全是这一句,429 / 配额 / 鉴权 / 超时在账本上长得一模一样,排障只能求报告人贴日志。现在改走仓里已有的describeStreamFailure()(src/dream.js,api.js/summarize.js/dream/sleep.js早就在用同一份),把 code/status/message 归一后落进error_message;拿不到细节时退回原字面量,不制造新形状。同一行的related_memory_ids恒为[],审计行因此不知道这条失败属于哪条记忆——而该值本来就该从extractEntities(memory, …)的 options 里传下去,补一行if (memory.id) options.memoryId = memory.id;即可。记账纪律照旧:没有产生任何行就必须留空,挂一个不存在的 id 就是造虚账(src/service.js的既有注释),所以取不到 id 时仍是[]。已知边界(本次刻意不动):success仍然在流一结束就写,而 JSON 解析在其后的src/entities/extractor.js——「流成功但输出畸形」必然记 success,这是结构决定的,不是这一刀能关的口子。5 条回归锁(真实 finish chunk 带reason.failure/ 无细节兜底 / 抛错路径 / 取不到 id 不造假 id / 防分叉静态锁——归一逻辑只许有一份)。 -
注入审计行写真实 query,并找回蒸发掉的 recall_runs 保留期清理:两处水源在说谎。其一,
recall_runs.query恒写空串(实测注入行 100% 为空),而调用方inject.js本来就带着lastUserQuery传进来——信息是在service.js的落账块里被丢掉的。落账改记真值;NOT NULL约束与「真没有才空串」的占位语义不变(改列约束要重建表,不值得)。下游recall-stats按 mode 分账、不读 query,面板数字逐字不变。其二,purgeRecallRunsOlderThan与它的启动调用在dd0ed78(v0.7.12 发布提交)里整块蒸发,而配置键recallRetentionDays、README 与docs/CONFIGURATION.md一直宣称它有保留期——那张表于是涨到 18,442 行、跨度 2026-09-08 → 2026-10-13,只增不减。(数字出自一次真实部署的库,非新装环境。)这两件必须同批交付:query 里从此装的是用户原话,保留期就不再是「表膨胀」,而是「原话无限期留存」。启动调用读装配后的 cfg 而非 rawCfg,面板改保留期才生效;清理失败只 warn,绝不挡插件启动。 -
四个启动期清理都必须出声:failure-table / llm_audit / distill_records 三处原先静默吞异常(
catch { /* non-fatal */ }),与llm_audit注释里「失败只 warn,与失败表清理同款」的说法正好相反——注释是规约,代码不合规。连同recall_runs一起统一改成catch (err)+ctx.logger.warn。理由是这一族清理的失败方式刚刚演过一遍:函数与调用一起静默蒸发,表涨到两万余行而无人察觉;清理再失败而仍不出声,同一类回归照样看不见。 -
失败表的 query 落当轮用户原话,取法与注入侧共用一份:
failure_memories2,367 行里只有 69 行 query 非空(2.9%),但这个分母是误导的。按「expected 是否落在某次dream_runs.decisions里(±12h 窗口)」把它分成两侧:dream 侧 2,118 行、非空 0 行(0.0%);入口侧 249 行、非空 69 行(27.7%)。dream 侧恒空是设计使然——applyMerge/applyConflict/applySupersede/applyDifferentiate/applyUpdate五个巩固动作本来就不该有「用户原话」,机器批注没有意图可言。第二口径(receipt_chain按 run_id 还原碰过的 memory_id,±120s 窗口)独立复核给同向结论:命中侧 0.2–0.4%,未命中侧 8.5%。所以真正要修的是入口侧:memory_update工具的reason是可选参数,模型不填就没有 query——27.7% 是「模型愿意填」的比例,不是「拿不到」的比例。而工具execute的第二个参数同时握着改写动作与本轮会话,正是注入侧在吃的东西,所以没给 reason 就退回当轮用户原话,取法复用lastUserQuery——只把它 export,不复制第二份。复制一份的代价本仓库付过几次,而「visibleSeqs过滤被 rewind/compact 遮蔽的事件」这种细节在副本里最容易漏,漏了就会把用户撤回的话写进审计表;新测试专门有一条锁它。两边都取不到时写NULL而不是空串:空串会假装「有意图」,recall_runs那一列当年就是被空串占位糊过去的。expected/actual/failure_type/memory_id/before五个字段一个都没动。 -
mint 的记忆带上管道来源,机器写的不再落回 manual 兜底:pattern 表 144 行
source全是"manual",看着像「人手工写的」——实测是错的。按dream_runs的patterns.applied逐轮求和得 144,与表内行数 144 相等(72 轮 sleep 里 48 轮 ok,auto 轮 0),它们全是 sleep 的phasePatternsmint 的;"manual"只是saveWithDedupe没拿到source时的兜底值。于是「这类条目从哪来」在数据面上不可读,靠它做出的判断也跟着错。改法:来源由调用方(管道)声明,不从决策对象取——createpayload 是模型写的,信d.source等于让模型给自造条目盖「人写的」戳。三条 mint 管道各自声明(dream主链路"dream"、sleep 两处相位"sleep"),面板确认冲突那条刻意不传(由人触发,manual 兜底恰好是对的意思),注释写明是刻意的免得日后被当成漏传;取不到就一个字段都不传,落回原有兜底,行为与改前一致。连带更正service.js里那句「pattern 只由手工写入(实测 40 条全 source=manual)」——它的前提是错的,但它支撑的结论(巩固层护住而注入面不给看是同一口径两头不一致)不依赖那个前提,所以注释改写时把这点写清,免得把仍有效的决策一起推翻成无依据的推断。不改?? "manual"兜底本身(那会一次动到面板 / recall-stats / dream 等所有消费方的可取值),也不回填存量 144 行(改写用户记忆库不可撤销,留给人拍板)。 -
修掉一个能把宿主钉死的轮询:
setEmbedder里的if (emb.ready === true || attempts >= EMBED_READY_POLL_LIMIT)把上限判断写在了会抛错的读取右侧——emb.ready是 getter(OpenAI embedder 经settings.getVectorConfig()读库),getter 一抛错整条||当场中断,右侧的天花板永远轮不到。触发条件是真实宿主走的正路:插件被禁用或重载时fiber.dispose()会store.close(),此后每 100ms 读一次 ready 就抛一次Error: database is not open,定时器活过注释写明的「~3s ceiling; never poll forever」。修成先readReady()带 try/catch(只 warn 一次)再自增判定,探针读数由「读 46 次 / 天花板 31 被击穿 / 残留 1 个 Timer / 进程挂住」变成「31 次 / 未击穿 / 0 Timer / 正常退出」。上限判断别写在会抛错的读取右侧——短路求值让右侧在左侧抛错时根本不执行。 -
注释里的行号引用改成带符号名带日期:审计指出本批在
inject.js头部插了导出注释后行号整体 +5,而树内注释与交付记录仍写旧值。查下来面比这条大:本批给index.js插了 28 行、给tools.js插了 1 行 import,把别的文件里对它们的引用也一并推歪了。判据不取「本批改动使该行内容变了」——那会把早就腐坏的引用一起算进来;改用两个维度交叉:把引用点名的符号写成正则看它在基线里能否找到(锚点法),再用git diff --unified=0算净位移(平移法)。结论是本批推歪 3 条(已改)、既有腐坏 18 条(只登记不改,改动面不在本批写域)、反常 0 条。修法不是改数字(明天还会错),而是去裸行号、改成「<文件> 的 <符号名>,<日期> 在 :<行号>」——静态行号是腐坏不是笔误,只改数字治标。早期探针只跑锚点法、且把锚点抠成了引用正文里的字样,于是把一条本批造成的腐坏误判成既有腐坏;自动从注释里猜「这条引用指哪个符号」不可靠,锚点必须人工定。 -
会话蒸馏的作用域章:两处「读起来对、跑起来错」(自动评审核出):CodeRabbit 在 PR 上报了四条,逐条对着代码核验后,两条是真缺陷、一条是真问题、一条是文档歧义,全部在这一条里交代。其一,去重命中后新行丢章:
scopeEnabled与summarizeDedupeMode同开时,第二轮蒸馏会命中第一轮的行;候选筛(type + source + 时间窗)不看 scope,真正决定并不并的是saveWithDedupe的scopeMatches门。原实现用「命中就不传章」表达「不重贴既有行」,可新条目的 scope 因此变成 null——与已带章的候选不等 → 门拒 → 回落新建,而那一行同样是新行、同样该带章却没有章。实测两轮蒸馏留下同题两行、一行auto一行 NULL,恰好是「跨作用域隔离没做成、同题重复倒留下了」。改成两维都无条件传,让合并分支按它自己的口径处理(它只认 explicit 来源升级,从不采纳这里传的 auto 字段),「不重贴」与「新行必贴」才相容。其二,header 字段读取会穿出解析器:scope.js文件头写着硬约束「任何异常都被吞掉」,但它只覆盖了session.header的取得与 registry 查询,header 的字段读取裸着——宿主给出会抛错的 accessor 时异常直接穿出,蒸馏路径上一个窗口的记忆与游标一起丢、工具路径上是一次memory_save直接失败。两处字段读取各自护住,失败落 NULL 并 warn 一次。其三,居中回退面板(老宿主没有右栏服务时的唯一完整入口)此前挂在没有tabIndex的元素上收 Escape、打开时也不移焦点,键盘用户只剩 Tab 摸到关闭按钮一条路;现与记忆库浮层同款:打开时焦点移进面板、Escape 挂在 window 上收、关掉把焦点还给 trigger。其四,配置文档里scopeEnabled一行补明「标注只贴新建行:并入既有行时不重贴」。回归锁 4 条(test/scope-distill-stamp.test.js),三条经变异验证确认会变红:回退其一时两个方向各红一条(跨作用域那条把新内容并进未标注行、同作用域那条反被门拒),回退其二、其三各红一条;还原后哈希逐一核对。
🧪 测试
-
ego 2-hop 用例不再拿墙钟当规格线,改钉确定性的遍历工作量(issue #409):原用例断言
ms < 50并自称「spec §7」——那个 50ms 预算在docs/里找不到出处,而且它要抓的东西本身也不对:全量并行跑实测最坏 60.4ms,于是 CI 偶发翻车;真正的退化(把逐节点取邻接改成全图扫描)在 121 个节点上根本看不出区别。ego 路由的遍历工作量由limit卡死、与图规模无关,且请求内service.getRelations的唯一调用方就是这次遍历,所以现在数它的调用次数:小图(121 节点)上界 3×limit;再拿约 5× 大的图(641 节点)跑同一个查询,断言工作量不随图长——一条不含时钟的确定性锁,全图扫描型退化会立刻越界。墙钟只保留 250ms(≈4× 实测最坏值)的离群告警,不再是规格线。 -
蒸馏留存的完整回路由装配级用例锁住:新增两条端到端用例,从 setting 一路走到磁盘——
setFeatureFlags写进 kv(面板真正的写入面)→ 真装配插件(bundle 里说关、kv 里说开)→ 驱动一轮session/event的turn/end→ 复查库里两张表。一条断言开着的会长行,另一条断言从没设过这个开关时一行都不写。另有test/failure-query.test.js(7 条,含「用被撤回遮蔽的用户消息不算原话」——这条同时锁住「没有抄第二份取值实现」,naive 副本会漏掉可见面过滤)、test/mint-source.test.js(9 条,含对抗条:决策对象自带source: "manual"时管道来源照样赢,并先用validateDecisions(...).ok === true证明这个威胁真实存在)、test/embed-ready-poll.test.js(1 条三断言,钉住上限读取次数、越过之后不再读取、只 warn 一次)、test/recall-retention.test.js(七条,故意分「行为锁」与「接线锁」两半——弄丢的正是「两半都在」这件事,只锁一半,另一半断了也没人知道)。
🧹 工程
-
发布闸门从假绿改成真闸门(
release.yml的Verify published version):原先这一步是echo "Published: $(npm view "@modusensus/dsh-mneme@${V}" version)"—— 命令替换里的失败不会让set -e退出。v0.8.16 那次日志白纸黑字写着npm error code E404 / 404 No match found for version 0.8.16,step 与 job 却都是 success:真发不出去(token 失效 / 权限不足 / npm 侧拒绝)与发布成功在 CI 上是同一种颜色,只有人翻日志才看得出来——而闸门的意义恰恰是没人翻日志的时候。换成scripts/publish-gate.mjs:轮询 registry 公开 HTTP 面直到该版本可见,超时(默认 10 分钟 / 每 15 秒一次)即非零退出,日志里带上每次探测的原因与最终的重试次数。为什么是轮询——registry 有传播延迟,v0.8.16 实测 publish→可见约 4.5 分钟,一次性查询只有「忽略失败」(就是原来的假绿)或「拿传播延迟当发布失败」(假红)两种结局;而幂等跳过那条路径(版本已存在)首次探测即通过,所以只有真发新版才付等待成本。探测走公开 registry HTTP 面而不是npm view:不依赖.npmrc里的 token、不依赖 npm CLI 的 E404 语义,且能区分「还没传播」与「网络异常」——两种都重试,但日志里必须分得开,否则排障又得靠猜。超时预算的语义刻意定成「等多久才放弃」而非「多久之后就算没发布」:探测成功即通过、不再回头比期限(单次探测自带 15 秒 HTTP 超时,晚到的成功最多超出十几秒),因为版本真的在 registry 上就是发布成功——消灭假绿不该顺手制造假红。14 条回归锁(假时钟 + 注入 probe/sleep/now,零网络零等待)钉住两端与这条语义:版本已发布 → 首次探测即通过、不白等;等满预算仍看不见 → 必须ok:false且入口退出 1,谁把「拿不到」改成「跳过校验」谁转红。附带修掉一个把成功报成崩溃的退出路径:process.exit()紧跟 HTTP 请求会在 Windows 上撞到 libuv 的Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)(成功路径实测退出码 0xC0000409),改用process.exitCode。已知边界(已由下一条的独立改动收口):releasejob 当时仍只needs: [verify, test],publish 失败时 GitHub Release 仍会建出来。 -
Release 不许抢在 publish 之前(
releasejob 的needs):GitHub Release 是对外的「这个版本已发布」,registry 才是唯一事实。原先release只needs: [verify, test],于是 publish 真失败(token 失效 / registry 拒绝)时 Release 照样建出来:Release 页写着 v0.8.x、registry 上却没有,用户照 Release 装回的是上一个版本——与Verify published version同源的一处豁口,那边是校验假绿,这边是声明抢跑。改成needs: [verify, test, publish]后两条路径都收口:publish 失败 ⇒ 本 job 被跳过;dry_run 时 publish 被跳过、而本 job 的if同为假 ⇒ 不会发出无从兑现的发布声明。回归锁进test/release-workflow.test.js三条:release 必须等 publish;仍必须等 verify 与 test(免得修依赖顺序时顺手绕开版本号与 CHANGELOG 校验);publish 内必须调用那道阻塞闸门、且不得退回echo "Published: $(npm view …)"(否则 release 等的是一个不做校验的空壳,第一条形同虚设)。工作流是 YAML 而本仓不引解析依赖,锁落在needs: [a, b]的内联列表形状上:改成块状列表会红,那是真变化、值得人看一眼再改锁,而不是静默失效。 -
注释里写死的行号引用改成「符号名 + 日期」:三处在写下时都是对的(
tools.js的memory_save里resolveSessionScope到workspaceLabel那一段、autoStamping那一行、scope.js的createScopeResolver),但写死行号的引用不是笔误而是会腐坏——跨文件插行会把别处的引用整批推歪,把数字改对只治当下,下次再有人插一行又错,而注释里的行号没有任何测试会去测它。改成「<文件> 的 <符号名>,<日期> 在 :<行号>」:符号名是稳定锚点,日期说明这个行号属于哪一刻的树。行为零改动,只动注释。