Repository navigation
Releases
v0.15.1
-
验证基线只记 HEAD:工作区里还没提交的改动,提交之后就永远判不到了(#648) —
verifiedBaseline一直是git rev-parse HEAD,而 claims 的行号取自磁盘上的内容——工作区脏的时候,这两者不是同一棵树。细节
机理很具体:改了某文件第 30 行但没提交,这时跑 T1,claim 按新内容算出的行号落库、基线却记成当前 HEAD;等这个改动被提交,
diffSince(HEAD, 新HEAD)从改动之后起算,diff 里根本看不到这一段,claim 会被判成「没被动过」,而且这个窗口不会自愈——改动永远落在基线之前。修法是把「工作区状态」单独记一份,而不是给基线换一种表示:T1 富化时用git status --porcelain -z --untracked-files=all取脏文件清单(src/wiki/t0.js新增gitDirtyFiles,与gitHead并列),连同verified_baseline一起落库(choro_pages.verified_dirty_files,幂等迁移补列)与 evidence 旁证,页面 frontmatter 只写一个布尔verifiedDirty(清单太长,不该淹掉给人读的页头)。判定侧在src/wiki/claims.js的summarizeClaims里分两层:清单里被 claim 引用的文件判新理由uncommitted(行号来自任何提交都证明不了的树),其余文件照旧走精确 diff——不做整页降级,一个无关脏文件不该杀掉整页的漂移检测。刻意不写git write-tree/ stash 式 ref:那会留下悬空对象或额外 ref,默认 2 周后被 gc 掉,等于把可解释性押在对象存活上;文件清单是纯文本,随库走。.chorography/**从清单里滤掉——那是工具自己的产地(这一轮写盘本身就会让它脏),而 claims 永远不会指向它。老数据不猜:verified_dirty_files为 NULL 的页 = 「不知道当时工作区状态」,不判uncommitted、不回填,面板照旧按提交基线比对;空数组才是明确的「当时干净」,两者严格分开。展示面同步:面板的复核理由多一档中文/英文文案,页头 provenance 对脏工作区验证过的页改成「对着 {sha} 验证(当时工作区有未提交改动)」,不再冒充干净提交。回归测试 +4(脏工作区富化记清单且 claim 判uncommitted、提交之后仍不自愈;只有被引用的脏文件才牵连;老数据 NULL 与空数组语义分开;旧库幂等补列),test/wiki-provenance.test.js10/10。变异自证:去掉uncommitted覆盖 → 前两条红;把脏清单当空数组处理 → 老数据用例红;去掉.chorography过滤 → 清单条数断言红;迁移不补列 → 迁移用例红。 -
无会话身份的系统写入整片没进写入准入:三类出口在
if (!sessionKey) return verdict处直接把判据跳过了(#663) —writeAdmission的判据挂在service.saveWithDedupe的汇流口上,而evaluate开头有一句「无会话身份 = 系统写入,不进预算」就return——预算确实不该进,判据却连跑都没跑。细节
于是 autoSummarize 的蒸馏产出、dream 的总览落盘、
bootstrapFromDirectory的整目录导入这三条路,密钥/PII 与空白噪声一次都没判过;writeAdmission.enforce打开时它们照样无条件入库。更麻烦的是三条调用点各自把返回值丢了:dream.js写总览的地方根本不看saveWithDedupe的返回,summaryStored = true无条件置位(拒绝也被当成「已写入」,直接反映到运行结果与常驻条);bootstrap.js解构出memory就往items.push({ id: memory.id })走,被拒时memory是null,解引用抛错掉进外层逐条 catch,拒绝理由与被拒条数一起丢,只剩一条 warn。修法是把「会话时间线」和「判据」拆开:第 1 级判据(空白/噪声、密钥/PII)与会话身份无关,对所有写入照跑;命中且enforce时gate记null(没有会话就没有预算维度)、拒绝照样生效;判据之后才if (!sessionKey) return judged;挡在预算/同话题冷却/内容哈希之前——这三样都要一条真实会话时间线做基准,硬凑一个假session_key会把真实会话的分布污染成两截,读审计的人再也分不开。record的落行条件同步改成「放行档必须有会话且有 gate,拒绝档一律落行」:系统写入的放行不该留计量行(读不出分布,只撑大审计表),但拒绝是一次真实的丢弃,必须留行,session_key记为 NULL。调用点各自补上拒绝处理:dream 只在非denied时置summaryStored = true;bootstrap 解构出reason、denied计数与items.push({ action, id: null, title, reason }),返回对象多一个denied字段——不再让null掉进 catch 把理由吃掉;standalone HTTP 那条路径的注释从「拦不到」改成「只过第 1 级」,显式 422 分支保留(落到toApiList([null])会变 500,把「被拒」伪装成「服务器出错」)。sensitiveScanEnabled的文档口径一起校正:它覆盖所有写入的第 1 级,无会话身份的路径同样在内。两处刻意没接:share-import.js走store.save而不走汇流口(docs/SHARE.md把收包侧定成信任边界,要接得先改那条路,不在本件范围);saveWithDedupe的existing合并分支在准入之前就return,合并写入整条绕过闸门——这是另一条缺口,同样单独处理。测试 3,496 → 3,502(+6):test/write-admission.test.js覆盖三档系统写入(默认档不判不落行、观察档落行且session_key为 NULL /metadata.gate === null、拦截档denied且零行),以及 bootstrap 与 dream 两条真实出口的集成(被拒条数、id: null、无 summary 行、审计行带命中类型)。变异自证:把if (!sessionKey) return judged;挪回判据之前 → 4 红;record恢复旧 guard → 拒绝档落行断言红;dream 改回无条件summaryStored = true→ 集成用例红;bootstrap 不接denied分支 →report.denied与items[0].id断言红。 -
状态页「8 周沉淀」图看得懂了:图例补颜色色标,柱高的共同基准也写明白了(用户反馈) — 图上每周两根柱(琥珀=进来的、绿=稳住的),图例却只有四段纯文字「新增 / 巩固 / 归档 / 巩固·新增」,读者只能猜哪根柱对应哪个数字;「柱高按最近 8 周单周最高新增折算」这件事也一个字没提。
细节
现在图例每一项前面挂同色圆点(直接引用
--mneme-sink-added/--mneme-sink-merged两个语义色,不另立颜色,避免与 donut 的组件色撞成两义),归档压根不给色块、只在数值后注明「未画入柱」——它本来就不进曲线、只报总量,给它一个色标(哪怕是空心点)就会长得像第三根柱的别名;图下补一行「柱高同基准:按最近 8 周单周最高新增折算」。lib/client.js是独立手写的面板 bundle(scripts/sync-lib.js只做 src→lib 拷贝、不碰它),所以改动只落在它自身与测试/CHANGELOG。回归锁加在test/client.test.js的沉淀图用例:两个色标必须引用语义色、归档项必须标明未画入柱。
v0.15.0
-
第三处「默认开」的开关翻过来了:
memoryQualityFilter.enabled的读取从=== true改成!== false,裸配置调用方不再静默拿到「关」(#692 尾巴) — 。上一批(同一条 issue 的另两处dreamMergeGuard/resilientModelDownload)把这一处刻意留下,理由是它挂在自身带.default({})的对象里、经 Config 的调用方一直是开的;现在按拍板把它翻过来…细节
契约变成一句话:默认开,只有显式
false才关。翻的过程中浮出第二层问题,比读法本身更隐蔽——评分块的阈值读取是qf.minContentLength ?? 10,qf自身缺席时(裸配置不传memoryQualityFilter)这行直接抛 TypeError,被saveWithDedupe的 best-effort catch 吞掉,于是「没归档、也没分数」,看起来就像默认关:只改读法、不改这里,翻完等于什么都没发生(第一版实测 3499 条只红 1 条,且那 1 条是src/↔lib/发布漂移)。所以minContentLength/archiveThreshold/exemptImportance三处读取全部走可选链,回落到 10 / 30 / 4。修好之后才是真实代价:全量 430 条红,全部来自 fixture 用两三字短正文(「入门」「喜欢猫」),靠过滤器默认关才留在库里被检索到——那些测试考的从来不是过滤规则。落法不是逐条改正文,而是把前提写一次:新增test/helpers/test-config.js的testConfig(extra),返回{ memoryQualityFilter: { enabled: false }, ...extra };118 个测试文件的 295 处config: {}/config: {…}包成testConfig({…}),另有 15 个自己拼config变量的 setup 在createService处包testConfig(config)(调用方传的对象仍优先覆盖,显式enabled: true照旧生效)。需要过滤行为的测试自己显式传{ memoryQualityFilter: { enabled: true } }——test/quality-filter.test.js的 setup 就是这样,它的文件头注释也从「raw configs 保持旧行为」改成「默认开、只有显式 false 才关」。行为变化:绕过Config的程序化createService调用方(第三方、直连单元的装配)现在默认开着质量过滤,短写入会被归档;要旧行为须显式memoryQualityFilter: { enabled: false }。docs/CONFIGURATION.md与 README 的两处表格本来就写「默认true」,这次是代码终于追上文档,不再改文档。daemon 那一格是评审照出来的:src/serve.js自己装配的最小 cfg 原本一个memoryQualityFilter键都不带 —— 旧契约下缺席等于关,翻成默认开之后缺席就等于开,而 daemon 只从持久化 flags 展开过distillRecords.enabled一组、结构上看不到宿主的 bundle config。后果不是「多归档几条」,而是面板上的开关对 daemon 无效:用户明明关掉了质量过滤,独立服务这边照样把短写入归档(实测content:"中文"/importance:3→archived:true, score:20, tags:["short_content","low_quality"])。修法与蒸馏那一格同款:把 kv 里平铺的memoryQualityFilter.enabled展开进 cfg;没设过就不写,默认值只留在src/config.js的 schema 里,本处不自造第二份默认口径。docs/DAEMON.md的「第一期不吃宿主配置」补上这两个例外,以及那条残留不对称(只靠 bundle 配置打开、从没在面板点过的用户,宿主认、daemon 不认)。测试 3,499 → 3,503(+4):裸配置下短正文被归档且quality_score确实落库(这条同时是「阈值读取不许抛」的回归锁——抛了就没有分数)、显式enabled: false时不评分不归档不打标(分数是 NULL 不是 0)、面板关掉后 daemon 不评分也不归档、从没设过时 daemon 按默认开处理。变异自证:读法回退成=== true→ 1 红;minContentLength去掉可选链 → 1 红;撤掉serve.js的 flags 展开 → daemon 那条 1 红;还原 → 全绿。 -
两个「默认开」的开关用
=== true读取,绕过 Config 的裸配置调用方下默认值落不了地(#692) — 。dreamMergeGuard(src/dream.js的 dream 主链路、src/dream/sleep.js的 sleep 链路各一处)与resilientModelDownload的默认值都只写在 zod schema(src/config.js)里:走宿主没问题——src/index.js先经Config(config)解析…细节
默认值已落进对象;但绕过 Config 的调用方(测试、第三方程序化
createService、直接 import 单元的装配)拿到的是undefined,=== true把它当 false,于是「默认开」等于默认关。两处统一改成!== false(与autoInject/autoDream/hybridInject/heatEnabled/summarizeBatchCrossTypeDedupe同一惯例)。resilientModelDownload的落点特意不在构造函数:LocalEmbedder/Reranker刻意「只在显式 true 时启用」(直连构造的旧调用方保持裸 fetch,见src/local-embedder.js的 issue #194 注释,test/local-embedder.test.js:79锁着这个契约),所以判据收成resilientDownloadEnabled(cfg)放在装配层(src/semantic.js),两条装配路径共用。schema 里的默认值一律不动,关掉仍然靠显式false。
第三处memoryQualityFilter.enabled本批刻意留下,已由上一条翻过来。当时不动的理由与上面两处不同:它挂在一个自身带.default({})的对象里,经 Config 的调用方(也就是宿主与所有正常装配)一直是开的;=== true与!== false的差别只出现在「整个对象都不传」的裸配置调用方身上。把那半边也翻过来,等于重定义「程序化createService的默认契约」——当时按既有 fixture 估算会让断言 421 条变红(复测确切是 430 条,差在 #695 之后新增的用例),因此这属于要单独拍板的接口决定,留在 issue #692 里跟进,不与本批两处混在一起。
测试 3,496 → 3,499(+3):test/issue-692-default-on-reads.test.js三条——裸配置下 guarded 类型(constraint)的 merge 被 skip 且理由点名dreamMergeGuard、显式dreamMergeGuard: false时同一条 merge 落库、装配判据三态({}/undefined/true为开,false为关)。变异自证:两处各自改回=== true→ 2 红(默认开那两条),还原 → 3/3 绿;「改成恒真」会被那两条反向对照抓住。 -
知识库未启用时的空态文案指错了分组名(#668 尾巴) — 。空态一直写「设置 → 记忆(mneme) → 功能 → 知识库」,但 v0.13 起
group.ext是独立分组、标签是「扩展功能」(英文Extensions),「知识库」只是组里的一项——照这句话去设置页找「知识库」分组会找不到。细节
中英两版都改成「功能 → 扩展功能」并点名组内那一项,
test/client.test.js的文案锁同步跟上。 -
跨类型同义双录:蒸馏提示词里「宁可拆成多条」把同一件事记了两遍,而三处去重各守一道类型闸(#687);实体表 75% 的实体只被提过一次、名字只按大小写敏感精确匹配(#688) — 。两条 issue 报告的是同一个根病:记忆的「类型」被当成了「对象身份」。
细节
#687 上报者(xueyue33)给的分布很刺眼——他自己库里
sim>=0.85的 81 对近重复中 72 对跨类型、>=0.90的 7 对中 6 对跨类型;sim>=0.88的 24 对里 21 对跨类型。四处夹缝逐个核过都成立:src/lang.js的codingSummary明确让模型「宁可拆成多条」而pitfall(现象+根因+规避)与constraint(约束+来源)本就是同一结论的两种写法;src/service.js:1480的findSessionDuplicate用store.list({ type: memory.type, source, limit: 200 })锁死同类型同会话;src/dream/clustering.js:117的findPotentialConflicts首行if (memories[i].type !== memories[j].type) continue;;dream 巩固提示词同样写着「仅合并同类型」。我们不开常开类型闸:本机活跃库 9,275 条带向量的记忆里跨类型cosine>=0.90共 45 对,逐对读正文后 36 对(80%)确为同义,9 对合并会丢信息(5 对时间序列/状态快照、2 对症状 vs 修复、1 对结论反转、1 对版本号不同)——也就是说「凡是跨类型高相似就合并」每 5 对要吞掉 1 对真互补的信息,而这类对只占活跃总量 0.97%,收益与风险不对称。落地的三层是源头互斥 + 批内去重 + 存量一次性处置:①codingSummary提示词(中英双语)补一条互斥约束——同一结论只记一条,pitfall与constraint不双写,时间序列/状态快照写进同一条;② 蒸馏落盘前做批内跨类型去重,只对pitfall↔constraint、history↔project两组生效(正是实测最集中的应用对:9 对 + 8 对),planBatchCrossTypeDedupe(entries, { minSim })是纯函数——按相似度降序、每条最多被退休一次、不许链式藏行(A 取代 B 的同时 B 又取代过 C 会把 C 永久埋在链底);门槛BATCH_CROSS_TYPE_MIN_SIM = 0.85(textSim,不是余弦,词面重合是这两组对的主特征)、BATCH_CROSS_TYPE_MIN_CHARS = 24、BATCH_CROSS_TYPE_MIN_LEN_RATIO = 0.5;③ 处置走store.supersedeChain只写superseded_by/superseded_at,保留正文更全的一条、被取代者正文零改写、不归档、不 merge——判错时旧行仍在库里、显式搜索照旧读得到,且supersededIds()的JOIN ... AND w.archived=0让继任者一旦被归档旧行自动回到可见,等于自带回退。刻意没有复用src/dream/decisions.js的supersedeInfoFirstPair:它走service.update会在正文尾部追加「已被取代」前缀,与「正文零改写」直接冲突。默认开(2026-11-13 拍板):这个开关存在的意义就是让新写入不再双录,默认关等于修了不生效,所以src/config.js给true、设false是 kill switch,且关时审计行的batchDeduped/batchPairs两个键根本不出现——加开关之前的行为逐字节不变。两个键同时登记进settings.js的FEATURE_FLAG_BOOLEANS与FEATURE_FLAG_NUMBER_RANGES,面板上能改。存量 17 对不由代码处置:只读脚本scripts/cross-type-dedupe-dryrun.mjs出清单(输出禁止落在仓库内,清单里含记忆原文),复核后由人决定、按纯 SQL 打链。
#688 的修法只落在写入侧。上报者库里 12,700+ 实体、38,500+ 关系、孤儿 41%、mention_count=1占 72%;本机 live 库(只读打开)实测实体 25,474、关系 59,715、mention_count=1占 75%、大小写/空白重复 262 组;而按entity_relations.memory_id真实连接口径,孤儿是 17.9%(4,560 行) 而不是 41%——口径不同,这里写清楚。存量不做合并:实体 id 是randomUUID(),合并要重指 59,715 行关系且entity_relations没有唯一约束,重指过程一旦中断会造出重复边,收益(0.68% 的实体)与风险同样不对称。改的是查找键——findEntityByName从WHERE name = ?改成lower(trim(name))+type的等价比较并为此建表达式索引(DeepSeek-V3与DeepSeek-V4是两个不同对象,不剥版本号后缀;createEntity落库只去首尾空白、保留原大小写用于展示)。类型维度不能省:实测去掉 type 会误并 95 组(constraint与pitfall常同名)。另外entities.canonical_memory_id是 v0.7.1(5b5a2aa4)引入的预留列,从没接通线——src/store.js里只有 DDL、行映射、INSERT、UPDATE四处 plumbing,没有任何调用方写它或读它,25,474 行全 NULL;这次按幂等迁移删列(PRAGMA table_info判断 +ALTER TABLE ... DROP COLUMN,宿主 SQLite 3.51.3 支持)。迁移成本实测:25,474 行旧 schema 表首次createStore396ms(含建表达式索引),二次 24ms。
升级不可逆,降级前必须备份:ALTER TABLE ... DROP COLUMN 是单向门——旧代码里没有 addColumn,把插件降回仍带 canonical_memory_id 的版本后,UPDATE entities ... canonical_memory_id 会报 no such column;src/entities/extractor.js 逐实体 catch 后 extractEntities 降级为 { ok: false },即记忆写入照常、实体与关系停止落库(warn 变多),直到重新升级。历史库本身安全:sqlite_master 里没有引用该列的 view / trigger / index。
-
仓库地志的身份与位置分家:
portable_key+ 每机位置表,双系统 / 双机共享同一个 memory.db 时地志第一次可以跨端复用(#581) — mneme 把「库共享」做到了(memoryDir+ WAL + busy_timeout),chorography 是唯一一处因为记了本机绝对路径而破坏这个前提的功能——Windows 端登记的行,macOS 端拼出来的路径不存在;再跑一次 T0 又多出第二行…细节
从此不带
repo参数的调用命中 repo-ambiguous;T1 的逐字符严格匹配更意味着每端都必须先各自跑一次 T0。修法按 issue 的最小方案落地,三个概念各就各位:身份 =choro_repos.portable_key(新列,幂等迁移),取remote.origin.url归一化——协议头 / 尾斜杠 / 尾.git/ 大小写剥平,scp 形态(git@host:path)的冒号换/,userinfo 整体剥掉(token 不进键:泄进库里是一回事,令牌轮换后同一仓库算出不同键是另一回事);无 remote 退化到根提交哈希(git:<hash>,跨副本恒定且与「碰巧同名的别的仓库」天然区分——issue 提的「退化为仓库名」不能用,目录名与 package name 重合率太高,第二次登记会吃掉别人的行,实现期在测试夹具上当场撞到)。位置 = 新表choro_repo_paths(repo_key, host_id, root_path, last_seen_at),host_id = os.hostname(),T0/T1 登记时 upsert。读侧(queries.js的localRootPath)按当前 host 取本机路径、取不到回退身份行的 root_path——单机库两个值恒等、行为逐字节不变;本机没登记过时落进既有的 page-missing / unreadable 失败口径,不会拿别的机器的路径读出错误内容。T1 的登记校验从「root_path 逐字符相同」改成「本机路径命中或身份已登记」——同一仓库在另一台机器登记过,本机直接认领那一行富化,消灭「每端先各跑一次 T0」;认领同时落本机位置行。写路径(/choro/run、/choro/t1后台任务、/choro命令的过期判定)同样改吃本机根。两个刻意的取舍:① 同键登记收敛到同一行,但 root_path 的 UPDATE 前先查目标值是否被别的行占着(被占保留原值、位置走位置表)——升格期已跨端登记出的重复行不合并(页面与 claims 挂在 repo_id 上,机器间合并行等于改写归属),强行改会撞idx_choro_repos_root直接抛;② 升格存量行的键留 NULL,由它自己的机器重跑 T0/T1 时补上,不猜。已知边界:kb 导入的 choro_page source 是导入当时的绝对路径快照,跨端复用时会失配,另账处理。回归测试 +10(URL 归一化形态表含凭据剥离 / 无 remote 退化链与恒定性 / 旧库补列补表且重开幂等 / 位置表同键同机覆写 / 同键跨端登记收敛同一行且 created_at 不动 / 升格期 root 被占不强改 / 读侧三 host 各解析各的 / T1 跨端认领端到端:克隆检出直接富化身份行并登记本机位置),另把四个「同库登记两个相同夹具」的既有用例显式区分开(无 remote 键是根提交哈希,同秒提交的相同内容会同键收敛——这本身就是退化链设计的防误并性质)。变异自证 3 例(删upsertChoroRepoPath/choroRepoPathFor的 return 出口 → store 用例 3 红;读侧localRootPath去掉位置行解析 → queries 用例红;T1 去掉 byKey 认领 → 端到端用例红),恢复后全绿。评审补丁(PR #679,六条 Major 全部成立):① 路径回退要核验身份——两台主机的同一条绝对路径可能各自检出不同仓库,upsertChoroRepo/ T1 的登记校验、读侧localRootPath都改成「身份优先,路径回退只接受无键存量行或键一致的行」;核验用portableKeyFor(一次 git 读取,按「键+路径」记忆化),不符时读侧返回空串并按page-missing/ 不可读上报,绝不join("", rel)去读进程 CWD;claimDrift的hasT1同样加了空根守卫。② 身份唯一:portable_key加部分唯一索引(WHERE portable_key IS NOT NULL),插入撞唯一键时回读同一身份行改走更新——并发登记不再产生重复行;索引建失败(存量重复键)退成非唯一索引,不让插件起不来。③ URL 解析分清端口与 scp 分隔符:带协议头时 authority 的冒号是端口(默认端口 ssh 22 / https 443 / git 9418 剥掉,其余保留),只有 scp 形态的冒号才是路径分隔符;④ userinfo 只在 authority 段内剥,仓库路径里的@(repo@stable)不再把主机与路径一起切掉。⑤repoByCwd的最长前缀改为比较候选自身路径长度(原实现拿身份行里那条外地路径比长度,内层本机路径永远赢不了)。新增回归 5 条(端口 vs 路径段 / 路径里的 @ / 同路径另一身份拒绝且原行不变 / 回退目录身份不符报 page-missing / 前缀比较用候选长度),变异自证 6 例逐条变红。二轮评审补丁:① 写路径补空根守卫——/choro/run原先直接吃localRootForRepo的返回值,该方法在本机未登记位置、且回退目录身份核验不过时返回空串,resolve("")即宿主 cwd,T0 会把那里的模块与页写进这条 repo 行(「同一路径不同仓库」...
v0.14.0
-
注入的规则池只看得到前 200 条记忆:候选池此前固定
store.list({ limit: 200 }),合格行 8400+ 条里 8200 条永远进不了窗(#619) — 。这个窗口按importance DESC取,于是注入位实际上由「谁更重要」决定一个固定的前 200 名,而真正的排序规则(summary 优先档 → preference → importance×quality×heat×scope)只在这 200 条内部生效。细节
实测本机库 9067 条活跃记忆里,按注入闸门(
injectType/ 非 narrative / importance ≥ 3)合格的有 8421 条,其中 8210 条永远进不了候选窗;窗口内 200 条的 importance 分布是全部 5——也就是说 summary 优先档这条规则,在最需要它的场景里根本没机会触发。- 窗口改成只限制回表条数,不再决定谁能参与排序。
store.listInjectPool()取全部活跃行的排序键(12 列:id / type / source / importance / quality_score / last_accessed_at / created_at / updated_at / agent_scope / workspace_scope / archived / forgotten),过滤与排序都在 JS 侧做,最后只对入选的max(200, poolSize)条回表补正文。回表深度与改动前一致(200),所以 backfill(槽位没填满时往下补)的行为不变。 - 不走
store.list({ limit: null }):list 的投影由 PRAGMA 派生、搬全部列(含 content),9k 行实测 340-400ms;这里 12 列实测 200ms,正文只对 200 条用listByIdsForInject点查回来(6.8ms)。加起来仍低于改动前那条LIMIT 200查询的 ~76ms + 后续排序,但账要算清:这不是变快,是把「看不见的记忆」换成了「多扫一遍排序键」,换来召回覆盖面。 - 排序键按
mirror_state.generation缓存,与词法索引同纪律(水位每次现读、读不到时不写缓存)。但heatEnabled && injectHeatEnabled同时打开时绕开缓存:heat 依赖last_accessed_at,而touchLastAccess刻意不 bump generation(读不该让镜像变脏),缓存会让 heat 停在上一轮的读数上。 - 补上末位平局键
updated_at DESC再id。此前同分组的次序靠Array.sort的稳定性继承 SQL 行序,等于随数据库返回顺序漂移——实测全库只有 43 种排序键取值、37 个并列组、最大一组 2276 行,改一行updated_at就能造成上千对交换。现在同分组的次序可复现,轮换窗口不会因为一次无关写入而整批换人。 - 测试:
test/inject-pool-full.test.js4 条(250 条 importance 5 填充 + 一条 importance 1 的 summary,后者必须排在第一位并进注入——旧实现下它在第 251 位、根本进不了窗;同 generation 内多次注入只取一次排序键;heat 双开关打开时每轮都重取;入选条目带完整content/tags,证明走了回表而不是排序键快照)。变异自证四轮各自变红:语料改回 200 行窗口(3 红)、去掉排序键缓存(1 红)、去掉 heat 绕开缓存的判断(1 红)、直接返回排序键快照不回表(2 红)。 - 如实记一处变异假绿:删掉
updated_at/id两个平局键后 4 条用例全绿。原因是updated_at由save()铸造、精度到毫秒,测试里造不出「同分且同updated_at」的两行;靠毫秒碰撞来造这种数据的用例本身就会随机红绿。所以平局键的收益(同分组次序可复现)今天只能靠代码审查守住,不能靠测试——与 #616 的删除补偿是同一种情况,不假装它被覆盖了。
- 窗口改成只限制回表条数,不再决定谁能参与排序。
-
词法召回只看得到前 500 条记忆:BM25 语料此前固定
store.list({ limit: 500 }),语料超过 500 行时,排在 500 名之后的记忆永远进不了检索(#616) — 。500 名按importance DESC, updated_at DESC排,所以越旧、越不重要的记忆越容易掉出窗口——而它们恰恰是最需要靠检索才想得起来的那批。细节
实测 8 个查询里,500 行窗口的 top10 与全量语料只有 1-4 条重合(
记忆 审查2/10、矛盾 冻结1/10、注入 规则池 截断4/10)。这是静默的召回缺失:不报错、不变慢、日志里什么都没有,只是「有这条记忆但搜不到」。- 不是把 500 改成 null 就完事。全量重建一份索引实测 340-356ms,而现在 500 行只要 14-23ms;只放开截断,每次写入后的重建会从 ~110ms 涨到 ~680ms,等于用一个可感知的卡顿换召回。所以这次拆成两件事:语料放开到全量,同时把索引改成增量维护,重建成本随变化的行数走而不是随语料规模走(实测 upsert 50 行 3.4ms,单行 0.5ms)。本库 9052 行活跃记忆中,最近一小时变化的只有 49 行。
- 新开口
store.listBm25Corpus(),不走store.list({ limit: null })。后者的投影由 PRAGMA 派生,搬全部列,无界时 9k 行实测 340-400ms;这里只要id / title / content / updated_at四列,实测 74-108ms,且ORDER BY updated_at DESC命中idx_memories_updated。刻意不取content_hash:把这一列加进查询会让耗时涨到 310ms(覆盖索引失效),比省下的分词还贵——所以判据用updated_at,它同时覆盖「改内容」「归档」「遗忘」三类变更(这三类都会刷新它),只有删除不在行上留痕。 - 删除靠 id 集合差集收掉。语料每轮全量取一次(这一步省不掉,只能省分词与建索引),拿本轮存活 id 减去索引里还留着的 id,差集逐个
remove。所以「每轮取一次全量」不是浪费,是删除补偿的唯一依据。 - 索引表示选紧凑数组而不是 Map。
src/search/bm25.js新增createCorpusIndex():每文档存升序Int32Array(termId)+Uint16Array(频次),查询时与查询词项归并求交;vocab只增不减(删掉的词保留 id、df 归零),槽位数组复用空位。同一 tokenizer、同一 K1=1.5 / B=0.75、同一打分式,实测 80 组 top10 与createBM25Index逐位相同,全量 build 418ms / 堆 +38MB(Map 版 +53.8MB)。createBM25Index保留给知识库那边——它的语料本来就不截断,且 docs 带额外row字段。 - 索引只留 id,命中后回表。
bm25Recall拿到{ id, score }后用store.listByIdsForInject批量补全(60 条 1.12ms)。这一步不可省:融合层把行直接塞进结果集,字段必须完整。回表时取不到、或已经被别的宿主进程归档/遗忘的命中直接丢弃——索引与回表之间是有窗口的。 - 失效键仍是
mirror_state.generation,逻辑与 #617-O2 一致(水位每次现读、读不到时不写缓存、只缓存索引不缓存查询结果);只是缓存条目多带一份seen: Map<id, updated_at>用于增量判断。 - 测试:
test/bm25-corpus-index.test.js6 条(与批量索引命中与分数逐条一致、重复 upsert 不重复计 df、remove 后分数等于「从没插过它」、更新后新词命中旧词不命中、limit 与归一化、空索引空查询)。test/bm25-corpus-full.test.js4 条(第 621 行记忆仍可召回、同轮多查询只取一次语料、删除后退出、更新后按新内容召回)。test/bm25-corpus-cache.test.js的观测点从「store.list被以limit:500调用几次」改为「store.listBm25Corpus被调用几次」,8 条断言值一个没动。变异自证三轮各自变红:把语料查询改回ORDER BY importance DESC LIMIT 500(1 红)、把增量判据从「updated_at变过」改成「见过就跳过」(1 红)、删掉删除补偿(未变红——见下)。 - 如实记一处变异假绿:删掉
index.remove那一段后「删除后退出词法召回」仍然绿,因为bm25Recall回表时查不到行会丢弃命中,把索引里的死行挡住了。这是防御性冗余,不是漏测——但它意味着「索引必须干净」这条契约今天只能靠代码审查守住,不能靠测试。删补偿的代价是索引随删除行数膨胀(内存不回收),不是结果错误。
-
后台 LLM 调用一个 session id 都没带,走 OpenCode 路由必然 400(#668):七个调用点统一补上固定后台会话标识 — 。插件自己的后台调用(整理、蒸馏、实体抽取、注入门控、图像描述、连通性探测)此前只传
provider/model/messages,sessionId全程缺席。细节
这在多数网关上无声无息,但 OpenCode 适配器是硬要求:pi-ai 的
dist/providers/opencode-headers.js里withSessionHeader只在options.sessionId存在时才注入x-opencode-session头,缺了就直接 400。上报者(#668)说的正是这条。- 一处收口,不散点补:新增
src/llm-session.js,BACKGROUND_SESSION_ID = "dsh-mneme"与withBackgroundSession(options)两个导出。七个点全部经它包一层——src/dream.js与src/dream/sleep.js各有本地streamText,改单点即覆盖全部 7 个 dream/sleep 相位;另五处是src/index.js、src/summarize.js、src/pre-inject-gate.js、src/api.js、src/image-describe.js。 - 固定标识而不是按 run id 派生:按 run id 会让网关把每次整理当成一个新会话,限流按会话计数时反而更容易撞墙,排障时也分不清请求来自哪一层。固定成一个,既满足头部要求,也让网关侧能一眼认出「这是插件的后台流量」。
- 不覆盖宿主已经给的真会话:
options.sessionId已存在时原样返回,不覆盖。注入链路上宿主可能带着真实用户会话进来,那时把 id 换成插件标识等于伪造来源。 - 测试:
test/llm-session.test.js6 条(填 id 且不改入参、已给真 id 时不覆盖、容忍 undefined/null、七个调用点源码各含预期 needle、src全域不得再出现裸.stream(options)或.stream({ ...route,、常量值就是"dsh-mneme"且长度 1–64 符合网关约束)。变异自证五轮各自变红:撤掉src/dream.js接线、把 helper 改成恒等返回、撤掉src/image-describe.js接线、改成无条件覆盖 sessionId、撤掉src/summarize.js接线。
- 一处收口,不散点补:新增
-
情感记忆此前只写在库里、模型看不见:单次场合的情绪(occasion 档)补上注入出口,
affectiveInjectEnabled默认关 — 。sleep的 affective 相位从 0.13 就在跑——它扫情绪归因、往entity_relations写affective_context边(emotion/attribution/sessionCount/evidence)…细节
面板的「情感」页也能按「谁·情绪·对象·场合/倾向」聚合展示。但边不是记忆:
INJECT_TYPES是 memory 类型白名单,边不在里面,所以 occasion 档一百条也进不了模型视野。跨够sleepAffectiveTraitMinSessions(默认 3)个独立会话升上来的 trait 会铸成type: "pattern",那是真进注入的——可模型读到的是一条普通 pattern,不知道它是情绪归因。于是最典型的「她那天很焦虑」写进了库、面板看得见,问模型的时候它答不出来。- 复用 #641 的注释通道,不新开块:
src/inject.js渲染每行时已经会拼m.conflictNote(矛盾对标注),情绪线索挂在同一位置、另起字段affectiveNote,顺序是「正文 → 情绪 → 冲突」——情绪说明背景,矛盾标注是决策指令必须贴最后。这样不新增ctx.systemPrompt.context(那会改变整个注入块的顺序与预算分配),也不动 schema。 - 挂哪条边、挂不挂由 service 决定:
src/store.js新增affectiveRelationsByMemories(memoryIds, perMemory = 1),一次 SQL 按候选记忆 id 批量取边(WHERE relation_type = 'affective_context' AND memory_id IN (…),两端 LEFT JOIN entities 取人名与对象名),按created_at倒序、每条记忆最多一条。src/service.js在injectCandidates的 superseded 过滤之后、pinnedStats 之前挂注释,trait 档跳过(attribution === "trait"或promotedToTrait === true)——它已经以 pattern 身份进来了,再挂一遍等于同一件事说两次。名字为空的边(实体被删)直接跳过,不渲染「unknown 对 unknown」。 - 注释吃注入块预算,所以限长:
MAX_BLOCK = maxContent + 600,一条超长注释会把同批候选挤成降级标题行。AFFECTIVE_NOTE_MAX = 160字,超了整条不挂(不是截断——截一半的情绪说明比没有更误导),why单独截 60 字。 - 开关是子项,不占新座位:
affectiveInjectEnabled默认 false,父开关是sleepAffectiveEnabled(后端INJECT_CHILD_FLAGS,面板FEATURE_CHILDREN,两表同源由test/inject-parent-gate.test.js锁)。父关时子项一并不挂但持久值保留,父开关缺省(未写过)时只按子项自己判。进LIGHT_MODE_OFF:低资源档本来就连sleepAffectiveEnabled一起关,情绪边都不会产出,留着这个注入开关没有意义。 - 测试:
test/affective-inject.test.js8 条(默认关不改注入、开着带全四项事实且单次场合不写次数、trait 档不重复挂、父关一并不挂与父缺省只按子项判、实体被删不挂、超长整条放弃但记忆仍在块里、同一记忆多条边只挂最新一条、配置默认值与轻量档)。变异自证五轮各自变红:删掉inject.js的拼接、删掉 trait 跳过、把父闸换成裸读子项、把「任一侧名字为空」写成「两侧都空」、删掉长度闸。
- 复用 #641 的注释通道,不新开块:
-
描述进得了转录、留不进记忆:蒸馏提示词补一句「描述行是事实来源」,让图里写了什么真的落库、真的能被检索到 — 。上一版的描述档只把
描述:…并进转录,然后把转录交给蒸馏——但蒸馏提示词从没提过「描述」这回事,模型不知道那一行值不值得记。实测最坏的情况是把描述当排版噪音丢掉,蒸馏出来只剩「用户发了张截图」,而那张图里唯一能复原的信息(报错原文、界面上的数字)在落库前就没了,等于描述档白跑一次视觉调用。细节
- 只在真有描述时加:
describeStats.described > 0才把这句拼进 system prompt。没有描述却说「描述行是事实来源」,等于让模型去找一行不存在的文本;默认关着、或描述全失败时,提示词与 #611 逐字节一致。 - 前缀字面量从生成端导入,不在提示词里手抄一遍
[图:/描述:。这两处一旦漂移,症状是「提示词让模型去找『描述:』,而转录里写的是别的」,不报错、不崩、只是安静地不生效——最难查的一类。 - 不扩大自由度:这句话只陈述「描述里的内容属于事实来源,与用户原话同等对待」,仍然受既有的立场纪律约束(不记对用户本人的评价);它让模型可以抄图里的关键文字进 content,而不是把「发了张图」当成记忆的全部。
- 测试:
test/image-description.test.js再增 3 条(有描述才加且前缀同源、描述失败不加、默认关逐字节不变)。变异自证两轮:把提示恒置空(1 红)、把> 0放松成>= 0让失败态也加(2 红)。
- 只在真有描述时加:
-
图像描述档此前整档静默:开了开关、配了模型、一次都没跑成,审计里与「这个窗口没有图」长得一样——现在四种结局都留痕 — 。
describeImages的catch {}吞掉所有异常,provider/model 留空整档不跑也不报,宿主textOnly降级同样无声;审计 metadata 里只有一个imageCount(图块数)。细节
于是「我明明开了」和「库里从来没进过图」在运维上无法区分(实测本库 273MB 里含
[图的记忆 6 条,全是关于这个功能本身的说明文字,没有一条来自真实图片)。- 计数走出参,不认开关态:
describeImages(..., options.stats)多一个可选出参(口径同injectCandidates的pinnedStats:不传时行为逐字节等于改前),逐张记attempted、成功记described、空描述与抛异常各记failed并留首条错误原文(截 120 字)。src/summarize.js加的describeAuditPatch(stats)是唯一出口,三处写审计的分支(窗口过小 / 解析失败 / 成功)共用,避免抄三遍漂移。 - 四条结局:跑成了写
imageDescribed+imageDescribeFailed: 0(失败数显式给 0,缺键会被读成「没跑」,那正是要避免的模糊);试了失败写失败数与imageDescribeError;开着没配路由写imageDescribeSkipped: "no route";模型明确不能看图写"model-cannot-see-images"。默认关着时一个imageDescribe*键都不写,老档位审计行逐字节不变。 - 测试:
test/image-description.test.js增 5 条(跑成 / 失败带原文 / 没路由 / 看不懂图 / 关闭态不写任何键)。变异自证三轮:去掉成功分支的 patch(4 红)、去掉 no route 的记账(1 红)、去掉attempted计数(2 红)。
- 计数走出参,不认开关态:
-
图像记忆只到「图变成一句文字」为止:新增图像溯源表,让每条记忆能回指它来自哪张图(
imageMemoryEnabled,默认关) — 。此前图像链路的终点是转录:图块转成[图: …]占位、描述档再给它补一句描述:…,两者都只是蒸馏的输入。细节
落库的记忆与那张图之间没有任何关联——想知道「这条记忆是从哪张截图里看出来的」,除了翻会话日志没有别的办法;而会话日志会被 DSH 的图像 offload 机制永久改写(超预算的旧图换成占位文本,不再回来),也不保证还在。
- 不新增 memory type,只加一张溯源表。
image若作为一种记忆类型,要同时改src/store.js的TYPES、src/mirror.js的TYPE_FILE、src/share.js与src/document.js的类型表,以及两处把差异量钉死的测试;牵一发动全身,与「溯源」这件事本身无关。做法是CREATE TABLE IF NOT EXISTS image_sources(11 列,主键(memory_id, session_id, seq, fallback_key)),与既有的memory_provenance(记忆 ← 蒸馏窗口)合成完整链路:记忆 ← 窗口 ← 图。 - 粒度是窗口级,不是「这张图贡献了这条记忆」。蒸馏是模型读完一整段转录后提炼的,没有任何信号能证明「第三张图只贡献了第一条记忆」。按行去猜,会把「这条记忆恰好也提到了图里的词」写成「确有此证」——那是伪造证据,比没有证据更糟。所以只记「这批记忆来自这个窗口...
- 不新增 memory type,只加一张溯源表。
v0.14.0-beta.3
-
情绪页白屏(beta.1 起的回归,beta.2 仍在):点开「情感」页整块面板变空白,且此后每次打开都空白 — 。根因是
lib/client.js的AffectivePanel里一行formatRelativeTime(it.lastSeen)漏传第二个参数t——该函数内部要调t("memory.time.now"),缺参就抛t is not a function…细节
而这行跑在 render 里,React 整棵树卸载,日志里什么都不留。引入提交是 #619(
244da66d),v0.14.0-beta.1与v0.14.0-beta.2都带它;只有库里存在带lastSeen的情绪边时才触发,所以不是每个用户都会遇到。修法是补上, t,并加一条静态断言:扫出所有formatRelativeTime(调用点,逐个做括号配对、数顶层逗号,实参不足 2 个就红。全库只有这一处漏传(formatDate/formatDateShort本来只收一个参数,扫描器报的几处是误报)。变异自证:撤掉修复后断言变红并直接指出:8168 (argc=1)。 -
选了「off(人审队列)」却仍不断刷出待审冲突卡片,原因是「矛盾冻结」优先于档位,此前这条互斥关系只在设置项提示语里,下拉旁边看不到 — 。档位为
off或shadow且conflictFreezeEnabled为真时,判成 conflict 的对照旧停车进conflict_pending,档位选哪一档都不会减少;只有enforce且冻结关闭时才绕过队列…细节
两者同开时冻结让位(
src/dream.js:1535的ledgerEnforce,sleep 侧同款降级)。现在在档位下拉下方直接标一行「⚠ 冻结正在覆盖本档位」,判据与后端同源(enumOverridden:仅conflictLedgerMode、仅off/shadow、且读effective里的冻结值),文案中英各一条。不改默认值、不改语义——只是把已经在发生的事写明白,用户仍要自己关「矛盾冻结」才会停止新增。
v0.14.0-beta.2
-
模型从此看得见记忆有多旧(时间线索):注入行与三个检索工具的回执都带日期,
occurred_at优先、缺省回落到created_at— 模型此前拿到的记忆只有标题、类型与正文——created_at从头到尾没进过任何一条渲染路径(src/inject.js全文零命中,memory_get连updated_at都不给,唯一露出的时间戳是renderTimePrefix的「当前时间」一句,且默认关)。细节
于是「三天前用户说要 A、昨天改口说 B」在模型眼里是两条等重的陈述,先后关系全靠正文里有没有恰好写着日期。
- 为什么年龄必须看
created_at:updated_at会被巩固刷新——实测本库updated_at > created_at的 4528 行里,2765 行差值不足 1 分钟(插入时两列写同一个毫秒,正常),真漂移(≥1 天)656 行、中位 30 天、最长 51 天。拿updated_at当年龄,最长会年轻一个半月。 - 只给日期不给时分:注入块预算紧(
MAX_BLOCK = maxContent + 600),秒级精度在这里没有信息量,2026-11-11足够回答「这是什么时候的」。新增src/date-hint.js统一切法:只认YYYY-MM-DD前缀、按 UTC 切、坏值返回空串(不渲染占位符也不抛错)——落库是 ISO UTC,跨机器要确定性,最坏差一天不影响「多旧」的判断。 - 改动面 3 个文件 4 个位置:
src/lang.js的entryLine/graphHintLine各加一个可选date参数(不传时输出逐字节不变,三条旧断言一条没动);src/inject.js在类型括号后、标题前插入日期,降级标题行是独立渲染点,同步带上;src/tools.js三处(memory_search/memory_list/memory_get)由新的MEMORY_AGE同时给出created与updated。MEMORY_PROVENANCE一个字节没动——它有「未标注行渲染与 A2 前逐字节一致」的契约,往里加字段会让所有行都变。 - 测试:
test/date-hint.test.js8 条(形状、优先级、不传时逐字节不变、传时位置正确、三工具回执同形、降级行、坏值不渲染)。三条变异各自变红:降级行去日期、MEMORY_AGE里created置空、entryLine不传date。
- 为什么年龄必须看
-
图像描述档(
imageDescribeEnabled,默认关):蒸馏前让一个能看图的模型给每张图写一句描述,把描述并进转录 — 图片此前只以[图: 文件名 image/png 1543x983]占位进蒸馏(#611 补的,src/attachment-text.js明确不调 vision、不读字节、不落原图)。细节
占位能让模型知道「这里有过一张图」,但不知道图里是什么——用户发一张报错截图再问「这个怎么修」,蒸馏出来的记忆只剩「用户发了张图」。库已 273MB,原图档不做是既定口径(
docs/DISTILL.md),描述档是它的替代。- 不碰图字节:
@deepseek-ai/dsh-llm的 adapter 会按modelInfo.inputModalities决定编不编——含image就原样把字节发出去,不含就换textOnlyImageText占位。所以本档只负责「把 image 块放进一次调用」,不读文件、不做 base64、不需要 attachments store。 - 必须独立 provider 槽,留空 = 整档不跑:蒸馏路由(
summarizeProvider或会话主模型)多半是纯文本模型,拿它看图只会换回一句「本模型不支持图片」的占位,白花钱且看不出错。imageDescribeProvider/imageDescribeModel两条留空的语义与其它 provider 键不同:不是「跟随当前模型」,是「这一档不跑」。 - 准入三态,unknown 照发:
canSeeImages返回true/false/unknown,只有模型明确声明不含 image 才跳过;查询能力失败、inputModalities非数组、根本没有resolveModelInfo全按unknown处理并照发。理由与宿主一致:对未知能力是不投影,赌它不支持会静默丢掉描述。 - 300 字、单行、同附件只描述一次:四张图各写 800 字会把真正要蒸馏的正文挤出
trimTranscript的尾部窗口,所以压到 300 字并折成单行。同一次蒸馏里同一个attachmentId只描述一次(按 attachmentId 缓存),重复挂载的图不会产生两行「描述:」。 - 描述失败绝不影响蒸馏:单块 try/catch 吞异常,失败的图退回占位、带
描述:前缀的行不出现;finish报error/aborted就停手。默认关着时蒸馏输出与 0.14.0-beta.1 逐字节一致。 - 测试:
test/image-description.test.js14 条(准入三态、只有明确不支持才跳过、能力查询抛错、默认关逐字节不变、路由留空整档不跑、描述进转录且 attachmentId 未被改写、同附件只描述一次、失败退化、300 字截断、换行压平、只替换第一处、imageBlocks 过滤)。变异自证三条,其中「把sight !== false改成sight === true」第一次跑出假绿——因为用例传undefined正好落进解构默认值,改成null才真变红。
- 不碰图字节:
-
设置面板三处「有渲染、没登记」(#656) —
FEATURE_FIRST_SCREEN缺整个group.dream(巩固与睡眠那一组的sleepModeEnabled是与group.ext同级的总闸,却整组躺在二级折叠里,首屏可点项 11 → 13);affective/diary两个页签无条件注册…细节
总闸关着时能点进去看一页全关的开关(改成由各自总闸决定,
features未到手前先不渲染,拉取失败按全关处理);conflictLedgerMode那个三档下拉是手写的——渲染里有、常量表里没有,意味着它被误删也不会有任何测试红,而它确实被误删过(#641)。收进FEATURE_ENUMS+enumRow后由表驱动,并补上 i18n 完整性测试对枚举行标签与每一档取值的扫描(那四组文案此前从没被任何测试碰过)。 -
仓库地志两个子开关各渲染两遍(#658) —
chorographyAutoRefresh/chorographyT1FromPanel既在FEATURE_CHILDREN里(flagRow会展开),又由choroSub手写了一份——5c41d31 先写手写行,5bad8a6 补父关系时没删旧的。细节
面板上这两个开关各出现两行、点上面那行下面不动,看起来像开关失灵;持久值与后端行为一直是正常的。子项回到
flagRow统一展开,顺带补上父关时的「不生效」标注(值保留仍可预置)。复现条件是地志总闸开着(默认 false),所以只影响开了这个功能的用户。附带改掉一句误导人的注释:FEATURE_CHILDREN是面板渲染关系,INJECT_CHILD_FLAGS只收「父关但子项仍有执行路径」的键,两张表本就不必相等。
v0.14.0-beta.1
-
冲突裁决改成可查账的账本(#641,beta):矛盾对先记进
conflict_ledger再按档位处置,enforce档下存储层不再由 LLM 决定谁赢 — 此前「两条记忆打架」只有一条路——交给模型判一条留、一条归档(#126 之前的自动裁决),判完只剩结果、没有过程:哪一对判过、按什么理由判的、有没有反复判,库里都看不出来。细节
报告者统计 788 对已裁决里近重复 46%、跨作用域 20%、真矛盾 27%、时序演进 5%,而两侧
created_at差不到 1 天的占 76%——「谁更新谁赢」在这个分布上约等于掷硬币,P(毒胜出) 86.8% 正是这么来的。- 新的账本表:
conflict_ledger记memory_a / memory_b / tier / status / similarity / reason / first_seen_at / last_seen_at,四档分诊——T1 近重复(相似度过conflictLedgerDupMinSimilarity,默认 0.9)走取代、T2 跨作用域只记账不处置、T3 时序演进落superseded_by取代链、T4 真矛盾两条都活。recordLedgerTriage是唯一写入点,同一对重复出现只更新last_seen_at。 - 三档开关:
conflictLedgerMode: "off" | "shadow" | "enforce",默认off(行为与 0.13.0 完全一致,不产生任何账本写入)。shadow只记账、不做任何处置——先让账本在真实库上跑一段,看分档分布合不合理再决定是否开处置;enforce才真正归档与落链。与conflictFreezeEnabled同时开启时降级并 warn(保持 freeze 语义 / shadow 只记账),不抛错:这两键都是用户配置,撞车时让既有行为继续跑比让启动失败更安全。 - enforce 档不经过 LLM:
src/dream/sleep.js的 enforce 分支在三道 skip 之后、LLM 调用之前直接返回处置结果,src/dream.js的 applyList 也过滤掉action === "conflict"的决策——存储层的胜负由确定性判据(信息量 →updated_at→ id)决定,模型不再参与。冲突条目的 LLM 裁决、人审队列在 enforce 档下都不再消费冲突候选。 - 检索侧(T3/T4):被取代的行整行不进注入(不是降权,是剔除);同批出现仍
open的矛盾对按 epistemic 信任级(observation > inferred > subjective)排位、平手取新者,两条各自带一句冲突标注(src/inject.js渲染时接在记忆正文后)。取代链一旦成立不是单向门:继任者归档或遗忘后,旧行重新可见(supersededIds()JOIN 继任者判活)。 - 回执不再说谎:
enforce档真实归档 + 落链的数量此前不进applied——库里明明改了,dream_runs.applied与回执都记 0(与 #619 同一形状)。现在 sleep 侧返回applied: t1Resolved + t3Chained,dream 侧 finish 用applied + ledgerApplied。 - 幂等:
supersedeChain加了指向守卫,同一对每轮重跑不再刷superseded_at、不再虚增t3Chained;T1/T3 处置前先确保账本行存在(此前 dream 侧只UPDATE不插行,命中 0 行时状态推进静默丢失)。 - 两档暂未接线:
differentiated/zombied已进枚举与展示,但暂无写入点,代码与文档都标为预留——不当作已实现宣传。 - 测试:
test/conflict-ledger-enforce.test.js19 条(分档、幂等重放、继任者退场后旧行复权、回执计数、互斥降级);变异自证四轮——删幂等守卫、删 JOIN 判活、删 dream 侧账本写入、把失败退化成空列表,各自都能让对应用例变红。
- 新的账本表:
-
autoDream 永久停摆(#634):阈值基线只随成功轮前进,归档把活跃量压到基线之下后巩固从此静默失联——修法是判定口夹回基线,归掉的量不必重新长回来 — 这不是调度器坏了,是阈值语义里藏了一个单向棘轮。
细节
基线(上次成功轮结束时的活跃库规模)只在成功轮刷新(
startRun的result.baseline落dream_runs,#89 半边),而触发判据overBase = 活跃 ≥ 基线 + 阈值的左侧会被归档/删除/合并压下去——活跃量一旦跌破基线,overBase恒为假,越整理越不可能再巩固,且不跑的时候一个字的账都不留(maybeSchedule的阈值不满足分支与 #89 的最小间隔口径一致:静默跳过)。报告者实测两次停摆:13 天与 22 小时零轮,末轮基线 1,475 条/299k 字符,归档后活跃 798 条/261k,门槛永不满足;同期sleep轮正常,面板只能看到「最近巩固:很久以前」。- 修法选在判定口(
shouldTrigger)而不是各写路径:活跃量跌破基线的轴把基线夹回当前值(每轴独立取 min),阈值从新的低点重新起算。归档路径本就不触发notifyWrite(service 里「状态开关不算内容写入」的既有口径),且收缩的来源不止归档——删除、合并、暂停作用域(filterPaused)、外来行占比变化都压activeStoreSize的读数,逐写路径维护基线碰的面大、漏面也大;调度器判定口是所有收缩来源的必经汇点,一处收敛。夹回后基线 == 活跃量,直到下一轮成功把基线抬高,所以每次「收缩 → 再触发」周期至多夹回一次。 - 不落库也能跨重启自愈:夹回只动调度器闭包里的基线。重启后种子若仍是旧峰值(
lastDreamBaseline读的还是收缩前的成功轮),第一次判定在shouldTrigger里同样被夹回——写入本来就要过判定口,夹回不会漏。不新增持久化形状(dream_runs的「每一行都能回放一次巩固」契约不受扰动),下一次成功轮自然把收缩后的规模写成新基线。 - 夹回可观测:夹回发生时走新的
auditBaselineClamp回调落一行llm_audit_logs(status: "skipped"、error_message: "baseline-clamp"、metadata 带previous(旧峰值)与夹回后的活跃量),与auditPeakSkip同一 fail-safe 口径——llmAudit.enabled关掉不写、写失败只 warn 绝不反噬调度、调度器侧再兜一层 try/catch。status 与 peak-hours 同用"skipped":审计聚合(auditSummary)按状态分组计数,不会把这行算歪;「为什么 X 天没巩固」从此有账可查。 - 语义边界:夹回不改变阈值的「增长」语义——收缩后的第一次写入就把基线钉在当前水平,之后仍要净增
thresholdCount/thresholdChars才触发,不会因为归档了一大批就立刻开跑一轮;零基线(新库/非法种子)路径不变(活跃量不可能低于 0)。 - 报告者建议的其余三项不在本修复里夹带:面板补「当前活跃/门槛」状态行、手动「立即巩固」入口(
runDream目前只有调度器一个调用点)、净增长口径重定义——各自需要独立的产品决定(手动入口是新路由,要过 policy 分类与 SECURITY.md 表),留给后续。 - 测试:新增
test/dream-baseline-clamp.test.js五条——①活着的调度器亲历收缩(归档 10/12 条后增长阈值量仍触发,修复前此路恒假);②重启后旧峰值种子在第一次判定被夹回;③夹回记账恰好一次、不收缩不记、同周期内不重复;④两轴独立夹回(只缩字符不缩条数时条数轴基线不动——用例走_overwrite更新路径制造纯字符收缩,去重命中默认是appendContent追加,这点在用例注释里钉了);⑤index.js接线的源码级锁(#291 同款:注入行被删即红)。变异自证:把夹回条件改成恒假 → 五条里四条行为用例全红(接线锁不受影响,符合分工),还原后全绿。全量 3267 tests / 3266 pass / 0 fail / 1 skipped(既有跳过项);npm run sync后 check-sync 平价通过。
- 修法选在判定口(
-
注入块轮内快照洪泛(#637):热记忆不再回放「正在组装的这一轮」,同一轮的注入块文本恒定、快照数降到每轮一份;
injectRotationTurns进面板白名单 — 报告者晒的现象是「大量重复注入、内容整体一致,一轮里 4 次强制插话」,自己已经把三层原因拆完(数据不脏、injectRotationTurns默认 0、热记忆让注入块在一轮内至少变两次)。细节
宿主对动态 systemPrompt context 的契约是「文本变了就追加一份 runtime-context 快照,旧份永久留在会话历史」——所以 mneme 侧能根治的是快照洪泛那一半:让块在一轮内别变。
- 抖动源是尾部 pending 轮:
roundsFromEvents把「正在组装的这一轮」也配对出来——本轮提问已落事件日志、回答还没来,于是热记忆里渲染一行Q: 本轮提问\nA:(空 A:)。这一行的内容与 prompt 里的真实 user message 逐字重复,是纯冗余;回答落地后它又抖成正文。修复前一轮内每次组装的块文本都在变(报告实测:4 轮 → 10 份快照、36.7 KB 里 75% 是已出现过的行;记忆段与用户设置段恒定,抖动全来自热记忆)。修法是注入侧omitPendingRound: true(src/inject.js的extractRounds):一轮内块文本恒定,回答落地时恰好变一次——快照数降到该机制下的理论最小值(每轮一份)。循环中途被新 user message 顶掉的历史 pending(双发消息的已被放弃提问)不属于「正在组装的这一轮」,照旧渲染空A:。 - 为什么是加参数而不是改默认配对:
roundsFromEvents是直调的纯函数(单元测试直接锁它的形状,含「保留尾部 pending」的既有断言),后台渲染等直调方要的是完整配对——「截断在最后一条 user/message」那个更激进的方案会把后台渲染时的最近一轮也丢掉,且要改三个既有测试的语义。省略尾部 pending 是唯一同时满足「一轮内恒定」「后台渲染零损失」「既有断言全保」的切口。 - 快照的轮间节奏:第 N 轮回答落地 → 块从「1..N−1 轮」变成「1..N 轮」(本轮唯一一次变化,宿主追一份快照);第 N+1 轮开头的组装里第 N+1 问是 pending 被省略 → 块与上一轮收尾逐字节一致 → 不再追快照。会话首轮无已完成轮次 → 热块整块不渲染(原来渲染一行空
A:,模型看到「模型自己刚被问的问题」的复读)。 injectRotationTurns进白名单(报告者自愈时只能写宿主 patch 文件cordis.patch.yml,面板上连键的存在都看不见):走FEATURE_FLAG_INT_RANGES[0,20](与 config.js schema 成对),面板挂在autoInject下的rotationSub(整数行 + 双语提示),计数锁 128 → 129。默认值维持 0 不动:跨轮轮换是 #205 的 opt-in 语义,它拿「最优相关的同一批」换「覆盖面」,是质量取舍不是缺陷——默认翻转留给评审拍板。- 测试:
test/hot-memory.test.js四条(pending 轮省略 / 闭合恰好变一次且轮间稳定 / 中途被顶掉的历史 pending 照旧 / 与 surface 投影叠加),test/inject.test.js三条(本轮提问不进热块 / 同一轮多次组装逐字节相等——快照洪泛的直接反例锁 / 回答落地是轮内唯一变化且轮界稳定)。变异自证:把omitPendingRound还原为false→ 七条全红;还原全绿。全量 3269 tests / 3268 pass / 0 fail / 1 skipped(既有跳过项);npm run sync后 check-sync 平价通过。
- 抖动源是尾部 pending 轮:
v0.13.0
🆕 新增
-
错峰时段串写错不再静默:启动日志告警 + 面板红色提示(#622,可观测性) — 。
parsePeakSpec的失败语义是「整串视为未配置」——排程是省钱手段,绝不因为写错格式把蒸馏/巩固停掉,这个契约不动。细节
但它的代价是配置写错的唯一症状是「功能悄悄没生效」:用户明明写了
summarizePeakHours/dreamPeakHours,排程却回到全天候,日志、面板、审计里没有任何痕迹(#424 主楼的排查只能靠「高峰期还在巩固」倒推)。#621 的全角归一收掉了等价类写法,但 24:00 越界、点号当冒号这类只能靠可见性。- 启动告警(
src/index.js):cfg 装配后对两个时段键各跑一次解析——非空且解析为 null 时logger.warn一条(键名 + 原串 + 正确写法示例)。空串是合法的未配置,不告警(否则每个没配错峰的用户开机都被警告一遍,验收第 2 条);行为零变化,只 log,不改 cfg。 - 面板提示(
lib/client.js+src/api.js):GET/PUT /features答复新增invalidPeakHours(服务端用同一份parsePeakSpec算好,面板只渲染——独立 bundle 复制解析器必然漂移,与 config-keys 卡「分类在服务端算好」同一纪律)。两个时段键的输入行下,值非法时出红色提示行(含正确写法);保存(PUT 同走featureSnapshot)后提示立即跟着换。 - 单一口径(
src/peak-hours.js):新增PEAK_SPEC_KEYS+invalidPeakSpecs(cfg)——告警点与提示点认同一份键表,helper 只转述parsePeakSpec的话:等价类归一(PR #621,评审中)落地后救得了的写法自然不再告警,两批无需联动。 - 验证:
test/peak-hours-observability.test.js新增 3 条(helper 只报「非空且解析失败」+ 接线锁:启动告警读装配后的 cfg 且不改写它 + 解析语义原样未动);test/api.test.js新增 1 条(GET/PUT 的invalidPeakHours与 helper 同源、合法/空串不报);test/client.test.js新增面板锁(提示行走服务端字段、反拷贝锁匹配调用形态、i18n 双语键)。全量 3159 用例 / 3158 通过 / 0 失败 / 1 跳过;check-sync 通过。
- 启动告警(
-
记忆面板的「共 N 条」与分类 chip 改读服务端真分布(#597) — 。浏览页被 type 筛过又分了页,页内现数两头都数不出真分布:页头「共 N 条」用
visible.length(滚动一次后恒停在 200),分类 chip 从已加载页现数——选中任一类后其它类计数为 0 被整条滤掉,分类栏塌剩两枚、数字从 5 跳到 200。细节
- 服务端:
store.count()的 WHERE 构造抽成共用的pushCountFilters,新增countByType(opts)(同一份筛选、不带 type,一次 GROUP BY 拿全库分布);宿主内/api/dsh-mneme/list与 Bearer/memories两张面同口径补typeCounts格(按 id 取单条的答复不带——分布无意义,客户端据此降级)。 - 客户端:页头 = 服务端
total(搜索态的 total 本就是命中数);chips =typeCounts全库分布、缺类补 0(选中一类后分类栏恒定不塌);「全部」chip = 分布合计(不再从 5 跳到 200)。老服务端没有typeCounts时退回页内现数——功能不炸,只是回到旧的「只数已加载页」行为。 - 验证:
test/store.test.js+1(分布合计恒等于count(undefined)、筛选同口径);test/api.test.js/test/standalone-api.test.js各 +1(带 type 请求下分布仍全量、合计 = total);test/client.test.js+1 源码断言(服务端源 + 降级路径同时在)。顺带一提:状态页MemoriesStatusCard的每类一次请求(N+1)是同一痛点的另一处,可后续切到typeCounts,不在本 PR。
- 服务端:
-
仓库地志侧栏入口改旗标门控:
chorographyEnabled关着(默认)时侧栏不再常驻那一格(#598) — 。此前侧栏座位无条件注册(P11),从没用过地志的用户侧栏永久多一个图标、点进去只有一句「未启用」。细节
- 成对规则拆成两半:
main(key"dsh-mneme-wiki")无条件保留——#562 那堵墙(布局存着activePanelId="dsh-mneme-wiki"时少注册 main,宿主 selectPanel 直接抛);侧栏 glyph 的登记挪进座位闸,与 M.O.(v0.9.0)/日记(v0.13)同一形态——读一次/features、fail-closed(读不到就不注册,不猜)、开关改动重启生效。 - 提示文案跟着说准:两语言 hint 的「关着时它们全部不存在」现在包括侧栏入口(此前这句与实现不符,正是 #598 的另一半)。
- kb 座位刻意不动:契约 §3.6 选的是「入口常驻 + 空态自解释」(#607 的另一种写法),两种形态并存是 #598 评论区 §四的拍板;独立开关(issue 建议②)是已排期的 D2 形态,不在本 PR。
- 验证:
test/client.test.js座位闸断言重写(main 无条件 / glyph 在闸内 / fail-closed / panellist 清理扫帚少了 wiki glyph);panellist 结构测试更新(无条件集合只剩记忆 + kb 两对 glyph,main 仍三份)。
- 成对规则拆成两半:
-
引导请求不再被 tab 关闭吞掉:任务态搬到模块级,重开标签页回放结果(#558) — 。面板「⋯ → 从目录引导」的请求在飞时关掉整个 better-sidebar 标签页,MemoryExplorer 与引导弹层随标签页体一起卸载——keepMounted 覆盖隐藏与切视图、不含关闭(它自己的设计文档写明「记录的寿命只由原生 tab 关闭与会话消失回收」)。
细节
请求照样跑完、行照样落库(三元组去重 + _overwrite 刷新,重复执行不写坏数据),但成功/失败提示没地方显示、忙碌态也丢了,重开还能再提交一次。
- 状态住模块级(
lib/client.js):新增bootstrapJobstore(phase: idle/running/done/failed + dir/result/error)与useBootstrapJob订阅钩子——就是overlayListeners/setOverlayOpen的现成形状。busy/msg/err 全部从 store 派生,弹层只是任务态的视图;prop 钻孔链(弹层 → explorer → overlay 的onBusyChange三段)整条拆掉,MemoryOverlay 的 Esc/背板/✕ 三条关闭守卫改为直接订阅——顺带修掉 tab 注册路径守卫为空的老缺口(那条路径没有 onBusyChange,busy 从来报不上去)。 - 存活规则一条:「只存活用户没来得及看的那一份」。卸载发生在 running 时(tab 关闭 / 切视图),结果还没人看过——terminal 化发生在卸载之后,这份留给重开回放;卸载时已是 done/failed(用户见过),explorer 的卸载兜底把它回 idle,保持「重开是干净弹层」的既有行为。人看得见的关闭(取消 / 自动收口 / ⋯ 菜单收起)都显式回 idle 并保留目录(重试同目录是最常见的下一步,输入框从上次的 dir 播种)。
- 弹层的两处跟随调整:① terminal 化只写 store——tab 关闭后结果落地时组件已卸载,写 store 才有人接(这正是要修的路径);② fresh done 的 900ms 阅读窗锁提交钮但不锁取消——旧实现靠 busy 连 cancel 一起锁,那时 busy 是本地 state 必然随组件解锁;现在 done 是 store 态、可能被重开回放,取消若也锁死,旧结果会把弹层永久焊住。
- 同步性顺带变强:旧链的「同步报 busy」靠回调在渲染提交前追那一拍(#4452539705);现在
setBootstrapJob(running)一落,三处订阅者同步被唤醒,那一拍不存在了。 - 验证:
test/client.test.js更新两条旧锁到新形状(⋯ 收起连带回 idle、overlay 从 store 派生 busy)+ 新增 #558 专项锁(模块级 store、弹层为视图、存活规则、fresh-done 与回放的按钮差异、terminal 只写 store);全量 3155 用例 / 3154 通过 / 0 失败 / 1 跳过;check-sync 通过(client.js 是 lib-only 手写件,无 src 对应物)。
- 状态住模块级(
-
选择性遗忘 × 事实更正的根因定案:失效关系在 dream 第一次运行之前不存在(#532,分析文档 + 行粒度警示) — 。#532 的现象——更正两个事实、只忘一个、保留的那个答回更正前的旧值——不是某一行写错…
细节
而是三段契约的接缝:更正由蒸馏落成新行(
(type,title,scope)三元组不命中;清理并存的 dream 冲突裁决是阈值门控的,现场 0 次)→memory_forget是行粒度的(接口契约:按 ID 翻forgotten标志)→ 合流在新值行里的其他事实连带退出可见面,旧值行成了唯一幸存载体。三个确认请求在docs/FORGET.md逐条给出源码级回答:Q1 更正/遗忘的推荐操作顺序(update 原行 / archive 旧行 / 遗忘前先 get 看行里还装着什么);Q2 旧行与新行之间没有任何持久链接——失效关系今天只存在于 document supersede(#230)与 dream 冲突裁决两处,普通蒸馏行在 dream 首跑前无失效关系,历史回溯与当前答案也无法机械区分;Q3 配置无遗漏,现象在当前 main 上结构成立。修复方向(蒸馏拆分粒度 / 写侧更正失效关系 / dream 触发前移 / 遗忘回执带同侪提示)的取舍涉及 LLM 判断进哪条路径,按 R2/R3 纪律列表待拍板,本 PR 不擅自实现。- 本 PR 落地两件零争议的:①
memory_forget工具描述补行粒度警示(抑制的是一行——合流事实连带退出;请求只针对一个事实时先memory_get看行、memory_update拆分或在回复里说清连带)——这是 #532 对话 5 模型误用的直接对策,模型无从得知语义粒度与能力粒度的落差,除非描述告诉它;②docs/FORGET.md(已挂 README 文档表)含零 LLM 的最小合成轨迹(议主要求的最小复现:test/forget-corrections.test.js四步断言,存储层等价物,机制修复落地时第 ⑤ 条翻转——注释写明翻转条件,防止被当成「永远该绿」的现状锁)。 - 验证:
test/forget-corrections.test.js新增 2 条(合成轨迹 + 描述锁锁语义不锁逐字);全量 3156 用例 / 3155 通过 / 0 失败 / 1 跳过;check-sync 通过。
- 本 PR 落地两件零争议的:①
-
dream 审计行输入快照瘦身(#557 建议 2,opt-in 默认关) — 。
dream_runs.input的单行体积随记忆量涨(#557 实测平均 40-79 KB/轮,绝大部分是 content),轮数又随天数涨…细节
两者相乘超线性增长(报告库占 43% 的页;
recall_runs/llm_audit_logs都有保留期与回收口,这张表只有手动 reclaim 一个口)。新增dreamInputSummaryEnabled(默认 false = 审计行逐字节一致):开启后 auto 轮的快照只落骨架(id/type/title/importance/updated_at,不含 content)。- 判定与回执不受影响:巩固的 LLM 输入由记忆库现算、
snapshot_hash在内存里对含 content 的快照计算,两者都不经过落库副本——prompt 与 receipt 完整性不变;变的只是离线回放(原文不在库里就重算不出 hash,#512「每一行都能回放一次巩固」对瘦身轮降级为骨架级)。 - organize 轮结构性豁免:整理轮的重放载荷走
organize.js自己的saveDreamRun,不经过 dream.js 的快照构造——与 #275 reclaim 清 input 的run_type != 'organize'谓词同一条边界,apply 重放永远不会被瘦身破坏。 - 不删行:拍板 3(裁列同意、删行不同意)不动,
receipt_chain/memory_mints的run_id指针不涉及悬空问题;存量库的大行仍走dsh-mneme reclaim回收(#275)。 - 验证:
test/dream.test.js新增 2 例(默认关 content 原样落库 / 开启后骨架 + 判定回执不变);test/api.test.js计数锁 121 → 122 并钉默认值;docs/CONFIGURATION.md补键说明、docs/STORAGE.md补写入期瘦身与拍板边界。
- 判定与回执不受影响:巩固的 LLM 输入由记忆库现算、
-
容量预警(#275 空间维度):库体积 / 归档区规模过线时,
/health与面板都看得见 — 。维护者 2026-10-21 对照main的现状盘点里,这是「还缺什么」第 2 条:保留期(retentionDays)给了时间维度,空间维度一直缺着——库在涨、归档区在涨,但没有任何地方会说过线。细节
接手口径点得很直白:「
/health已经有镜像健康卡,加一个库体积卡是同一条路径」。- 三道阈值,全是观察口(
src/config.js):capacityWarning.dbSizeWarnMb(256 MB)、capacityWarning.archivedRowsWarn(5000 行)、capacityWarning.archivedRatioWarn(0.95)。默认值全部锚定 #275 议题自己的实测,不拍脑袋——55 MB 库 VACUUM 0.8s,256 MB 起代价才值得先看数字(议题「现在便宜、以后贵」);归档 5,000 是「数千条这个量级」的上沿(议题实测 2,400 时还没坏);占比健康区就在 0.8~0.9(活跃数百 / 归档数千,议题实测 0.90),预警线刻意放 0.95,不然对着健康库天天报警。没有 enabled 总开关:预警没有周期性消费者(/health被读到才算一次),也不自动归档 / 删除任何东西(#275 拍板:真删由人显式确认)——数字恒在,某条检查不要就把阈值设 0(0 = 显式关)。 - 判级一层、答复一处(
src/service.js+src/api.js):service.getCapacityHealth与镜像健康的 fail-safe 同款——读失败显式status:"unknown",绝不伪装 ok;store 关掉不抛。GET /api/dsh-mneme/health的四个答复分支全带上capacity块(镜像读失败不该把容量数字一起藏掉,两个半张互相独立)。每条检查回value / threshold,报警要能对上数字;:memory:库的体积检查缺席而不是 ok(「量不到」≠「零体积」);答复只带数字、不带库文件路径(storageStats 里有 path,/health是半张对外面)。 - 面板「库体积」卡(
lib/client.js):进「库内一览」组,大数字是磁盘足迹,行给活跃 / 归档(含占比)/ 可回收空闲页;过线的检查逐条列出并走--mneme-warn预警色(新增.mneme-statrow--warn/.mneme-statusnum--warn两层,StatusCard加了numWarn与逐行warn两个可选参数,不传的卡渲染逐字节不变)。三种拿不到数据的情况(fetch 失败 / 老宿主没这个块 / 后端 unknown)全部 fail-closed 渲染「未知」。增速刻意不算:归档净增速率是 recall-statsarchive块的第五指标,同一个数字长两处就会分叉。 - 键的三件套成对登记:
settings.js白名单(两个 int 走FEATURE_FLAG_INT_RANGES、占比浮点走FEATURE_FLAG_NUMBER_RANGES)+api.js的NESTED_FLAG_PATHS三个点号键 + 计数锁 121 → 124。阈值改动是启动快照(与panel_mode同节奏),面板显示「待重启」。 - 文档与验证:
docs/CONFIGURATION.md新增「容量预警」一节、docs/STORAGE.md补「容量预警(#275 空间维度)」节(含三道阈值的过线含义与读法边界)、README 键表补capacityWarning行(docs-config-keys 的全键闸与键名断言都要过)。test/capacity-warning.test.js新增 9 条(行数口径、schema 边界、三道检查各自的过线与关闭、fail-safe、/health挂载与不泄露路径、没接线时显式 null);test/client.test.js新增面板卡形状锁(fail-closed 表达式、warn class、i18n 双语键各出现两次);test/api.test.js往返测试补三个点号键。全量 3164 用例 / 3163 通过 / 0 失败 / 1 跳过;check-sync 通过。
- 三道阈值,全是观察口(
-
目录归属文档(#278 第 3 子项) — 新增 docs/DIRECTORIES.md,把散在注释里的「哪个目录是什么」收成一处总账。
细节
开篇钉死三个相似名:
~/.dsh/memory是记忆库(SQLite 正本 + 镜像 md + 候选 / 提议 / 观察 / 日记 / 文档目录),~/.dsh/mneme是模型与运行时缓存(不是记忆库,可整目录删掉重来),~/.dsh-mneme是 CLI 连接配置——把模型缓存当成记忆库是这个项目真实踩过的坑。每个落点标了拥有者(人改 / 机器写 / 外部只读)与出处文件,另附「删了会怎样」速查、七张路径配置键的展开规则差异(memoryDir一族做~展开、模型 / 运行时一族不做)、与已知坑(documents.md≠documents/、镜像手改窗口、两个 CLI 的默认落点各站一边)。README 中英文档表与 AGENTS.md 模块地图同步加了索引行。#278 的另两个子项(~/.dsh迁出、统一导出)是大活,按 issue 建议拆批另行落地。 -
情绪终于有地方可放,而且「这一次很焦虑」不会被记成「你是个焦虑的人」 — 。此前库里根本没有情绪这一维:
memories的 34 列没有一个 affect 字段,entity_relations的relation_type只认识uses/depends_on/part_of/related_to。细节
于是「答辩前一晚很焦虑」和「他这个人遇事就焦虑」在存储上长得一模一样——两条普通记忆,一句自由文本。对用户是越用越像刻板印象,对研究线是主指标没有载体。
- 关系类型的写入权收紧到一处(
src/entities/extractor.js):affective_context没有加进VALID_RELATIONS,唯一的写入者是 sleep 的情感相位。理由不是保守:实体抽取发生在写入当下,手里没有会话边界,判不了「这一次」和「反复出现」——让它也能写,等于给entity_relations开第二个只带{model}的入口,同一张表两种 schema(相位写的边有emotion/attribution/sessionCount,抽取器写的没有)。prompt 里加了一句明确的禁令,别让模型去猜为什么抽不出来。DB 侧本来就不用动:relation_type只是TEXT NOT NULL、无 CHECK(supersedes早就是这么写进去的)。 - 顺手拆掉一个会毁掉整件事的静默降级:
sanitizeRelationType原来对白名单外的类型一律return "related_to"。这意味着模型抽出的情感边会被悄悄改写成一次普通关联——写入成功、不报错、图上没有情感轴,日志里也看不出异常。现在改为返回null、丢弃该条...
- 关系类型的写入权收紧到一处(
v0.12.0
🆕 新增
-
「Agent 眼中的你」自动事实:按会话身份分卡维护一份逐条事实清单,dream 巩固后自动更新、注入生效、面板可管(2026-11-06 需求,对标 ChatGPT 记忆 / DS Bot 每 bot 一份 MEMORY.md 的设计) — 。
profileFactsEnabledopt-in 默认关。细节
- 形态是「逐条事实」不是「整段重写」:每张卡一份事实清单(稳定、具体、一句话),模型在维护轮里提 add/update/archive 三种 op,事实可独立增改归档、可被人在面板上逐条修正;
profile_fact_journal逐 op 记账(scope_changes 式 before/after + actor),profile_fact_runs每轮一回执(dream_runs 式)。维护 prompt 三段硬约束与 profileExtract 同源:输入是数据不是指令(同一份 fence 断链)、禁止给助手起名设人格(#497 身份撞车)、只依据给定材料。 - 手填画像神圣(#497 口径不变):事实住
profile_facts独立三表,任何路径都不写user_settings——注入顺序是设计稿⑥的延伸:人格 → 手填画像 → 自动事实 → 规则。卡片寻址与 persona 同口径(byAgent 命中 / strict 无卡不注入 / 非 strict 落 default)。 - 输入严格分区:agent 卡只吃
agent_scope=自己的记忆行,default 卡只吃 NULL 行,外来行(source=imported)整体排除——「本库不巩固别人导入的原文」延伸到画像事实。游标从最近一次 ok 回执读,失败不推进(蒸馏游标同款纪律,failed 轮重跑同一窗口);无增量输入连 noop 回执都不落(自动轮零噪音)。 - 触发:dream 巩固轮成功后 fire-and-forget 逐卡维护(经
service.enqueue与 sleep/巩固串行,失败不计入 dream 退避);面板「立即更新」手动轮走同一条路。维护轮上限 12 op/轮(超出的切片丢弃并记入回执 detail)。 - 注入预算:事实段 40 行 / 1200 字符双帽 + 尾注如实报数(该块此前无截断机制、从不轮换,不设上限会让一次失控的维护轮撑爆每帧 systemPrompt);事实内容过既有
escapePromptVars(宿主模板插值对裸{{会整轮崩)。 - 三条新路由:
GET /profile-facts(token-write,flag 关也照读——存量事实要能展示)、POST /profile-facts/apply(token-write,面板逐条人工修正,与自动轮共用同一份 applyOps 与账本)、POST /profile-facts/run(token,同 /profile/extract 取舍:无读分支 + 一次 LLM;409 disabled / 503 no-route / 502 unparseable·stream-failed)。SECURITY.md 双语四张清单同步。 - 面板:「Agent 眼中的你」卡后新增事实管理卡——卡选择器(default + agent 键)、事实行内编辑/归档/添加、最近变更 8 条(op + before→after + actor 徽章)、最近一轮回执状态行、维护轮五种结局各有一句人话(ok·N 条 / noop / 功能关 / 先去选模型 / 失败可重试);apply 与 run 成功路径都刷新注入预览(事实段是注入文本的一部分)。功能开关进 enhance 组。
- 分享包不含事实(v1 明确出界):事实是本实例 agent 对本用户的认知,跨库语义未定。
- 验证:新增
test/profile-facts.test.js17 例(ops 解析形状、跨卡守卫、去重跳过、no-route/noop/unparseable 可区分、失败不推进游标、重跑即 noop、scope 分区与外来行排除、autoPassCandidates、账本裁剪、注入顺序/strict 寻址/关闸零读数/截断尾注/花括号转义、HTTP 面 405/400/401/409/503/502);test/client.test.js新增面板锁(三路由对齐、五种结局 i18n、卡片位置、事实域切片内无 PUT /profile 字面量——#497 的镜像锁);旗标计数锁 113 → 114;docs-config-keys 锁补 CONFIGURATION.md 行。全量npm test2827 tests / 2826 pass / 0 fail / 1 skipped;check-sync 95 一致。
- 形态是「逐条事实」不是「整段重写」:每张卡一份事实清单(稳定、具体、一句话),模型在维护轮里提 add/update/archive 三种 op,事实可独立增改归档、可被人在面板上逐条修正;
-
pin 从「按类型推断」改成用户亲手钉的显式事实:新表
pinned_memories+memory_pin/memory_unpin,钉住的行在注入时优先占位,不再依赖它恰好是constraint/preference— (默认pinnedInjectBudget = 0,预算关时注入序列与改前逐条相同——这条线不改变任何人的现有行为)。细节
- 为什么另开一张表:pin 是「用户与记忆之间的一条关系」,不是记忆自身的属性。独立表回退只需一条
DROP,不必碰主表 31 列;created_at还能给出 pin 顺序。幂等靠CREATE TABLE IF NOT EXISTS,本表没有回填步骤所以不登记applied_migrations。外泄面为零——mirror / share 都不SELECT本表。 - pin 池此前会被向量路的封顶挤出候选池:pin 筛选发生在 hybrid 合并之后,而合并结果已按
poolSize封顶(生产maxItems=5→poolSize=10,向量路一次正好返回 10 条即 break),规则窗口里的constraint/preference一条都进不了候选池,pin 只能从那 10 条里碰运气。修法:合并块之后、topic 重排之前,把被poolSize挤掉的 pin 类行补到候选池尾部;只在预算 > 0 时补,strictScope与scopeState两道闸门照旧作用其上。真库副本实测(6733 条有向量行、1024 维、maxItems=5):pin 注入 2/5 → 5/5、注入总条数 7 → 10,前 5 位新进的三条之外原有条目只后移、无一条被挤出注入块;无向量路径改前改后都 5/5、10 条且逐条同序。 - 判据从「表里有几行」改成「有几条能用的」:旧判据
countPinned() > 0是错的——pin 行会在memories那边被归档、遗忘、硬删,而pinned_memories对这三种一无所知。只要表里还剩一行(哪怕早已归档),判据就一直判 explicit,可选集恒为空,pin 全灭且永不回落到旧的类型口径,无日志无报错。pinnedStats.source因此三档:explicit/fallback-none-usable(钉过但一条都用不了,静默降级必须留痕)/type-fallback(从没钉过,升级即等价)。不整段回落:若因为「有 3 条归档」就回落类型口径,会突然冒出一批用户根本没钉过的条目,那比少注入更糟。 memory_pin回执补上「暂停作用域」:描述文案、README 与 render 文案三处都写着「遗忘 / 归档 / 暂停作用域都优先于 pin,回执如实报告」,而execute只算了遗忘与归档两项,被按下作用域的行因此报injectable: true而实际一条都没注入。改法:回执改用service.filterPaused([target]),与注入侧共用同一个出口,以后改闸不会漏掉回执这一侧——注入侧本身没坏,坏的是回执另写了一份判据。- 不做:
PINNED_MEMORY_TYPES保持原样不动——它在 write-admission 还承担写入配额豁免语义,与注入侧是两件不同的事,本线只把注入侧解耦。 - 验收:存量库升级后新表为空 → 回落旧类型口径,两档
maxItems(5 / 10)在真库副本上逐条 id 相同;预算 0 时任何路径都不改注入序列;归档 / 遗忘 / 硬删三种场景各有用例;向量路填满poolSize时 pin 仍拿满预算;暂停作用域下回执如实为 false;全量 2858 用例 / 2857 通过 / 0 失败 / 1 跳过。
- 为什么另开一张表:pin 是「用户与记忆之间的一条关系」,不是记忆自身的属性。独立表回退只需一条
-
chorography 收尾:面板「重建骨架」入口 + 页级向量检索(V4 落地) — spec 消费面最后两块——生成触发点(spec §3「现在生成」)落成面板按钮 +
POST /choro/run(token-write,T0 apply 秒级同步回执;t1Loss 警告原样透出——T0 重建会把 enriched 页退回骨架,M2 补洞的清点在按钮回执里看得见)…细节
向量检索(spec §5 的
choro_vectors空壳就此启用):choro_vectors补vector/source_generated_at列(addColumn 幂等),检索时惰性物化(P37:host 侧 /choro/search 先嵌查询拿expectDim,再补嵌缺页/过期页),余弦 Top-N 以独立semantic数组返回(P39:两路分数量纲不同,不与 keywordhits混排序)。- V4 的答案(P38):
model/dim记行内、不与记忆向量锁死——查询向量维度与任一行失配 = 换了 embedder 模型,本轮fresh检查被dimDrift压过、全量重嵌;代价是几十页重嵌而不是作废记忆向量。embedder 缺席(CLI / 未配置)→semantic: null,检索静默退关键词。 - 两个 handler 的 promise 链必须
return:invoke/测试await route.handler(...),不 return 就在响应发出前返回——实测症状是 semantic 恒缺席、body 恒空。/choro/search 与 /choro/run 都改成return readBody(...).then(...)(nudge 同款)。 - 嵌入对象:页正文剥 frontmatter / CHORO 标记行后的全文(P40,页级;claims 级留待真有精排需求)。staleness 两维:页
generated_at变了(内容变)、行dim失配(模型变)。 - 验证:新增 10 例(wiki-queries 3:staleness 两维 / dim 失配全量重嵌 / cosine 排序与空查询;wiki-api 3:run 路由 policy+405+快乐路径、闸语义 409/404、semantic 在场与缺席;wiki-store 向量读写随用例覆盖;client 锁 1:按钮打 /choro/run、防连点、T1 留 CLI 的 hint、回执计数)。全量
npm test2814 pass / 0 fail / 1 skipped;check-sync 94 一致。 - 桌面实测反馈的美化:文件清单从「竖线等宽行」换成真 table(用户看截图后拍板):MD 渲染器解析连续
|行为真表格——表头、对齐元数据(---:→ 行数列右对齐等宽数字)、路径单元格等宽底色、斑马纹与 hover;分隔行不再画成可见行;引导引言缩小一档。回归锁三件(真 table / 分隔行被消费 / 数字列对齐)。
- V4 的答案(P38):
🎨 面板
-
面板跨组件失效广播:改库动作后记忆列表/实体页/状态工作台即时重取,不再「关面板重开才看到变化」 — (用户 2026-11-06 反馈:裁决完冲突要关掉插件面板才能看到败者归档落地)。
细节
- 病灶不是没刷新,是没广播:全面板此前唯一的跨组件失效机制是
mneme:conflicts-changed(一个派发点、三个状态侧监听者,全是状态页小组件);记忆列表与冲突徽章只靠弹层成功回调、手动刷新按钮与 30s 首页合并续命,实体页与归档工作台干脆只有挂载时取一次。tab 切换本就卸载重挂,所以症状集中在同一次打开的面板内。 - 新增
broadcastStoreChanged助手派发mneme:store-changed,10 处改库动作的成功路径统一发布:冲突裁决、删除、编辑/归档/恢复(applyMemoryUpdate 一处按分支发一种)、工作台恢复、导入×3、建条、整包撤销、回收确认模式。各动作自己的本地刷新(reloadKey / 列表过滤)保留——偶发的重复 GET 换「永远不陈旧」,划算。 - 订阅方:MemoryExplorer(一个 reloadKey 带动浏览列表、搜索、冲突徽章三份 effect)、EntityPanel(目录/图谱/属性三层全挂重取游标)、ConflictsQueue(参与记忆被删时联表侧会失真)、WorkbenchSection、侧栏红点 hook 与两处 dream-status 消费者(涉及冲突的记忆被删除/恢复同样改变 pending 集)。
mneme:conflicts-changed原样保留——它是裁决专属的精准信号,语义更窄,已有监听者不必搬家。 - 回归锁:
test/client.test.js新增一例锁事件名、发布点 ledger(新增改库动作忘了广播时先红)、订阅方数量与裁决双信号并存;onCreated 的既有原样锁同步带上广播。 - 验证:全量
npm test2809 tests / 2808 pass / 0 fail / 1 skipped;test/client.test.js 100 例全绿(含新增锁);test/client-share.test.js 全绿。
- 病灶不是没刷新,是没广播:全面板此前唯一的跨组件失效机制是
🐛 修复
-
修复 #590:严格寻址把「一张身份卡都没建」也判成「没有这张卡」,唯一那张人格卡静默失效 —
personaStrictAddressing开时,resolvePersonaCard把两种完全不同的处境都判成source: "none"——① 建了卡、但没有这张身份的卡(按 none 处理是合理的)…细节
② 一张身份卡都没建(按 none 处理等于把 default 永久废掉,而面板不报错)。第 2 种在 DS Bot 这类宿主里是必然:所有 bot 会话共用同一个
agentPreset、且会话建成就不可变,用户按 bot 名字建卡永远不会命中,于是一开严格寻址,唯一那张人格卡就静默失效——症状正是「第一次派任务时它不知道自己是谁、人设与回复格式如何」。- 判据改成「有没有键」而不是「有没有可用的卡」:严格模式只在
Object.keys(byAgent).length > 0时才拒绝兜底。一张enabled=false的卡也算用户已经做过分区——否则「关掉唯一那张身份卡」会重新落回 default,「关掉这张卡」这个动作又变成没意义。resolveFactsCard是同一处语义的镜像,同改。 - 回归锁三件:一张都没建 → 严格模式下仍落 default、建一张后例外立即失效;身份卡全被关掉仍算「建过卡」(锁住判据不被改成「有可用卡才算」);事实侧同口径(含「关着的卡也算建过」)。变异自证三处:去掉「建过卡」前提 → pass 33 / fail 1;判据换成「有 enabled 卡才算」→ pass 32 / fail 2;facts 侧去掉前提 → pass 33 / fail 1。全量 2842 tests / 2841 pass / 0 fail / 1 skipped。
- 判据改成「有没有键」而不是「有没有可用的卡」:严格模式只在
-
修复 #579:仓库地志的三个只读工具在错误分支返回 schema 之外的字段,宿主直接判
invalid output,面板只剩一句与真实原因无关的报错 —choro_module_map/choro_read_page/choro_search的output.schema是additionalProperties: false且只声明了成功形状…细节
而
resolveChoroRepo与读页 / 检索的失败分支返回{ error, hint }、{ error, slug, relPath }这类对象——错误载荷原样穿过宿主校验,用户看到的是「缺value.repo」「value.error不是已声明的属性」。- 改走宿主工具错误路径(抛错),而不是继续扩 schema:仓库里既有的工具错误本来就是
throw(参数校验、记忆不存在等),扩 schema 等于把「错误」写进「正常返回」的形状里,每加一个错误码就要多改一处契约。 - 检索不再把「读不到」答成「没匹配」:
src/wiki/queries.js的检索循环对读不到的页原先静默continue,于是「三页里两页读不了」会退化成「0 hits」——这是同一个 issue 里最危险的一条。现在保留已读到的命中、把读不到的页单独列出;无命中且有页读不到时明确报「检索不完整」。 - 回归测试:
test/wiki-tools.test.js重写(错误与成功两条路径都用临时库,覆盖「仓库行存在但本机路径不可达」「页记录存在但文件读不了」「真正未知的仓库 id」三类且必须互相可区分),test/wiki-api.test.js补闸语义;16 例全过。
- 改走宿主工具错误路径(抛错),而不是继续扩 schema:仓库里既有的工具错误本来就是
-
修复 #593 留下的两处旁路:显式钉住的行仍会被文档升格吸收静默归档;
memory_pin回执在pinnedInjectBudget=0(默认关闭)时仍报injectable: true— 。#593 把 pin 从「按类型推断」改成显式存储(pinned_memories表),但两处消费方没跟上:吸收只拿得到记忆行、拿不到 pin 表,于是退回按类型猜;回执只看了遗忘/归档/暂停作用域,没看预算开关。细节
- 判据①:新增
store.isPinned(memoryId)单行点查(pinned_memories的唯一读侧谓词)。src/document.js的 evidence 吸收循环改为两条并列判据:store.isPinned(row.id)(显式钉住)或pinnedTypes.has(row.type)(#249 逐字保真池类型 constraint/preference)——任一成立就不归档。上一版只有后者,所以一条被钉的pattern/decision在文档升格时照样setArchived:用户钉过的行被悄悄收走,pin 语义当场失效。store.isPinned缺席时自动退回类型口径(fail-safe,不因新谓词缺失而放宽)。 - 判据②:
injectable加上两道注入闸——pinnedBudget > 0与autoInject未关。前者上限与src/service.js:1885同款Math.max(0, Math.min(5, Math.floor(config?.pinnedInjectBudget ?? 0))),pin 池整体由它开关(service.js里pinnedBudget > 0才读表选路),默认 0 = 关闭;后者是整条注入通道的总闸,关着时src/index.js:617根本不注册注入器,pin 段一行都跑不到。两处都属「钉住动作确实成功、但一条都不会被注入」,回执照样报 true 正是「返回成功不等于生效」。autoInject按「只有显式 false 才算关」判(config schema 默认 true,见src/config.js:10),测试直连createTools拿不到已填 defaults 的 config 时不会被误报成 false。工具 description、schema 注释与 blocked 文案同步改写。 - 回归锁:
test/document.test.js新增一例用type:"pattern"(刻意不在PINNED_MEMORY_TYPES里,否则会被类型口径顺手护住、测不到缺口)——钉一条、不钉一条,注册文档吸收两条,断言被钉行archived===false、未钉行archived===true、evidence_archived===1;test/tools.test.js新增两例:预算 0 时pinned:true与injectable:false分开报、autoInject:false时同样报 false 且不显式传时不得误报 false;test/tools-dto-schema-matrix.test.js的injectable断言由 true 改 false(该矩阵用默认配置,预算本就是 0)。 - 口径:回执回答的是「pin 这条通道此刻开不开」,不是「这条记忆被排除在所有块之外」——
injectable:false的行仍可能凭自身重要性走常规候选路进块,工具描述与 blocked 文案都写明了这一点(首版文案写成「不会被注入」,被 #596 评审指出是过度承诺)。 - 验证:
npm test2875 tests / 2874 pass / 0 fail / 1 skipped;`...
- 判据①:新增
v0.11.0
-
修复 #573:宽态侧栏的记忆入口落在「更多」行下面,点开「更多」时被菜单盖住 — 宿主 0.2.2 起把面板列表的「更多」行(
PanelMoreMenu)也追加进同一个nav.panelList,于是组尾不再是面板行——我们仍按「追加到末尾」落位,入口就排到了「更多」下面。细节
- 落位改成插在「更多」之前:
place()先在列表里找[class*="moreAnchor"](与既有panelRow/regionArea同一套前缀判据,不写死宿主哈希类名),命中就insertBefore(created, more),没命中(老宿主没有这一行)原样退回appendChild。幂等判据同步换成created.nextElementSibling === more——稳态下观察器一次 DOM 写都不做,否则insertBefore会自激成 mutation 风暴(该坑在place()上方的注释里记着)。 - 回归锁:
test/client.test.js新增一例,同时钉住「找 moreAnchor」「插在它之前」「对着它幂等」,以及老宿主那条追加组尾的分支必须还在——少了它,没有「更多」行的宿主上入口根本不落位。 - 验证:全量
npm test2808 tests / 2807 pass / 0 fail / 1 skipped;check-sync 94 一致。
- 落位改成插在「更多」之前:
-
chorography 收口:
.chorography/走.gitignore,扫默认排除改为点开头目录整类 — spec §11 的最后两条待实测落地。细节
- V5 已定(维护者拍):生成物不入库。
docs/specs/2026-11-04-chorography-design.md§11 原本倾向「提交,因为 Markdown 就是真源」,实际取舍反过来:别人 clone 不该看到几十个生成页。规则先落在本仓(#572)——忽略目录内容而不是整个目录(.chorography/*+!.chorography/plan.yaml),因为忽略整个目录会让白名单失效、plan.yaml这张人工正面控制表一起进不去。代价如实写进注释:页内CHORO:HUMAN保留区只在本机存活,git clean -fdX会连它一起删。baleen / study-desk 两个邻仓同步补同一段。 - 点开头目录整类排除(
src/wiki/scan.js的isDefaultExcludedDir):V2 在三仓实测到的会话产物目录是.qoder-credits/.superpowers/.shots/.zcode/.dsh-ops/.v2c,全是提问原文、调试快照与第三方工具缓存——baleen 上它们占 15 个模块里的 6 个,study-desk 上占 5 个里的 3 个。逐条点名等于把一台机器的工具链钉进发布包,换个工具又漏一批;「点开头即非仓库内容」更短、与实测分布一致、不随工具换代失效。DEFAULT_EXCLUDE_DIRS里 8 个点前缀条目随之删掉,只剩node_modules/dist/build/out/coverage。 - 代价与边界写进注释:
.vitepress/theme、.docusaurus这类确实把真源码放在点目录里的仓库会漏掉那部分,且拉不回来——扫描阶段的排除先于plan.include白名单生效(plan 的include只能收窄,不能把被默认表排除的路径捞回来)。只按目录名判,点开头的文件(.eslintrc.json之类)是仓库配置不是产物,照旧扫描。 - 验证:
test/wiki-scan.test.js新增一例锁这两面——用一份从未进过任何名单的.whatever证明规则不是点名法,同时在nested/与src/两级各放一个点目录证明任意深度生效,再用.eslintrc.json/src/.prettierrc.json反向锁「点开头文件照旧进清单」。全量npm test2807 tests / 2806 pass / 0 fail / 1 skipped;check-sync 94 一致。
- V5 已定(维护者拍):生成物不入库。
-
chorography M3:开局注入 + 过期提示 +
/choro命令 — spec §10 的消费面收尾。会话工作目录命中已登记仓库时,每轮往 systemPrompt 里塞一小块标题级提纲(模块名 / 文件数 / 页状态 / 页相对路径,约 8 行)…细节
档位
chorographyInject=light(默认,前 6 个模块)/full(全部)/off;/choro给主动查看,且只提示过期、绝不自动重跑(spec §2 D6)。- 折进既有 memory context,不新开第三个 context(P24):宿主
@deepseek-ai/dsh-system-prompt的 context 只按order排序、同 order 没有 name 兜底(V3 实读源码确认),多开一块就多一份拼装抖动;spec §7 也明写「与 mneme 现有 systemPrompt 注入合并成一次拼装」。拼接次序时间 → 地志 → 热上下文 → 长期记忆——地志是稳定背景,热上下文每轮都变,稳定在前。 - 宿主侧没有任何体积上限(V3 实测,本机 asar 解包读源码):
renderPrompt只是sections.map(...).join("\n\n"),没有 token/字符预算、没有截断、没有超限报错。所以上限只能自己兜:light900 字符 /full6000 字符,逐行累加截断而不是拼完再切——切在行中间会让最后一条模块行变成半句话,模型读到的路径就可能是断的。空文本 context 被宿主filter(text.length > 0)过滤、不占位,所以「不注入」就是返回空串。 chorographyInject不进LIGHT_MODE_OFF(P26):那张表对平铺键一律赋false,枚举值塞进去会得到与 schema 不符的false,而false !== "off"时注入照样渲染——比不关更糟。改为在消费侧判config.lightMode === true即off(wiki/inject.js的resolveInjectTier)。但档位不是命令闸:轻量档下resolveInjectTier同样返回off,若拿它当/choro的开关,用户会听到「没开,去设置里打开」而设置里明明是开着的——/choro只认chorographyEnabled(档位管注入,命令管子系统开没开,实测复现后拆开的)。chorographyEnabled本身仍不进轻量档,但理由收窄成一句:它是「是否启用这个子系统」,不是「每轮付多少」,键注释与 CONFIGURATION.md 同步改写。- 热路径不 fork git(P29):注入块只吃 DB 已有的行,生成时间写进去但不重算基线;真过期判定(
git rev-parse HEADvschoro_repos.git_baseline)只放在/choro里——那是人主动敲的,付得起一次 rev-parse。注入器带渲染缓存(每轮仍查一次 DB 取键,那是索引查询不是 fork git),键tier|lang|repoId|outline.generatedAt——不是仓库行的updated_at:T1 富化只推choro_pages.generated_at、不动仓库行,若按仓库行做键,用户刚跑完--t1 --apply注入块还会写着「骨架」而/choro已经报「已有语义段」。不额外挂失效回调(热路径上少一条回调就少一处能崩的地方)。 - 三态过期,不许把「读不到」说成「没过期」:
/choro分别报 基线一致 / 可能已过期(附两个短 sha)/ 读不到 HEAD(不是 git 仓库或 git 不可用)。 - 按 cwd 找仓库复用既有 scope 口径(P27/P28):注入侧直接吃注入器已经解析好的
scope.workspace_scope(workspaceRegistry 反查优先、header.cwd兜底),不新写一处 registry 读取(#487:cordis 4 的 ctx 是 Proxy,裸读未 inject 的属性会抛)。匹配用祖先前缀而非精确相等(会话 cwd 常在子目录),多仓嵌套时最长 root 胜出;Windows 上按小写比较。 - 注入永不抛(P31):取 cwd / 查仓库 / 渲染任一步失败都退化成空串 + 一条 warn,不让一块附属信息把整轮 systemPrompt 搞崩。从仓库读来的标题与路径过一次
escapePromptVars——宿主interpolate()扫到非法{{…}}会抛错崩整轮装配;转义只做在注入这一层,/choro的 UI 回执直接用未转义文本(它不进宿主模板)。 - 面板补上总闸 + 档位(M1 留的洞):
chorographyEnabled此前面板里没有任何一行,而空态的文案却让用户「去设置里打开」。现在它挂在group.enhance组末,档位下拉作组尾子块(枚举键没有通用渲染器,与summarizeSub/distillSub同款手写)。 /choro只读(P33):不做 rebuild、不写档位、不落任何数据;参数一律不带(重建与富化都在 CLI)。返回值走 DSH 的 CommandResult 契约,只作 UI 回执、不进模型上下文——要模型看见提纲,靠的是开局注入。- 两处口径级修正(端到端冒烟里看出来的):① 注入行原来把页路径直接粘在右括号后(
(3 文件).chorography/modules/src.md),补一个分隔空格;②light与full原来各按各的排序(后者按 slug 字母序),同一份地志在两个档位下次序不同、读起来像被重扫过——统一按文件数降序 + slug 升序。/choro提纲超 6000 字符时的截断语也从「另有 N 个模块(总数)」改成差额:报总数会让读者以为大部分模块都看不到。
- 折进既有 memory context,不新开第三个 context(P24):宿主
-
chorography M2:T1 语义生成轮——逐模块 headless 会话补四段语义正文 + 溯源 claims —
dsh-mneme-choro <repo> --t1 --apply(缺省 dry-run 只列候选;--module单跑 /--model路由 /--timeout墙钟 /--retry-failed拉回失败页)。细节
会话产出的架构/约定/技术栈/构建四段插在骨架页「生成说明」节之前(T0 确定性段落原样保留),claims 落
choro_claims表 +evidence/<slug>.json双处同源。- V1 已实测通过(T1 的全部前提):桌面版 headless 通道通——两个必需配方进了真实 runner:①绕过
.cmdwrapper 直接调 exe(ELECTRON_RUN_AS_NODE=1 + --expose-internals,wrapper 在 Git Bash 下路径引号会碎);②模型路由走--patch一次性临时 overlay 指池模型(bili-scroll 同款、零配置改动;headless 默认路由 deepseek-official 要 API key,MISSING_CREDENTIAL)。overlay 只有 provider/model 名,无 key(红线 4)。 - 三重上限在 NDJSON 事件流上增量计数(工具调用 / 输出字节 / 墙钟,超限即刻 kill):这里有一个实测坑——初版每个数据块都把整个累积 buffer 重扫一遍,tool_call 被平方级重复计数,11 秒就撞 40 上限的假象;改成按「已计行数」增量扫描。事件形状以 V1 实测为准(
tool_call与tool_result成对,只数前者;最终答案取final.text)。 - 输出契约同过同拒(P16):章程要求只输出一个 JSON 对象;解析失败或 claims 的
sourcePath不在该模块文件清单里 → 该轮 failed、一条 claims 都不落——编造的锚点比没有更伤。首跑真实会话就是这条契约拦下了跨模块引用(../../tools/build-client.mjs),章程收紧(跨模块结论写进段落文字、不带锚点)后重跑通过。 - 失败记账(P17/P18):
choro_pages.fail_count列(addColumn 幂等迁移),连续两次到阈值标failed(骨架原样保留),--retry-failed可拉回、成功清零;环境级失败(起不来 / MISSING_CREDENTIAL)≠ 页失败——整档中止、页状态与计数都不动,receipt 带原因。 - exe 定位:
DSH_EXE环境变量 >--dsh-exe> 进程自身 > 标准安装位;cli.js 在 app.asar 内部,不能 existsSync 校验(CLI 是普通 node,没有 asar 支持;exe 自己加载没问题,V1 实测)。 - 边界如实声明(P22):headless 工具集由宿主管,章程里「只读、不修改文件」是软约束不是安全边界;T1 的硬保证是——人发起(CLI 显式
--t1 --apply)、本进程写路径断言在.chorography/**、越界动作 git diff 可见。 - 真实冒烟(dsh-baleen src-client,池模型 deepseek-v4.1-flash):29s 富化完成、四段全出、33 条 claims 锚点文件存活率 33/33。模型工具路由是 CLI flag(P21:T1 只手动触发,不进 config schema)。
- 重跑 T0 不许静默吞掉语义段(M2 补洞):T0 重建整页时按 P19 丢弃富化层是设计意图,但不说一声才是 bug——实测在 dsh-baleen 上重跑
--apply,enriched 页退回 skeleton、choro_claims清空,而.chorography/evidence/*.json留成孤儿,全程零提示。现在 T0 在 dry-run/apply 之前先清点(页内<!-- CHORO:T1:START -->计数 + evidence 下.json计数),命中就进warnings并在 receipt 带结构化t1Loss: { pages, orphanEvidence };清点只读,不改字节、不删 evidence,页已退回骨架后自动不再报警(免得变成常驻噪音)。T1 的两个标记因此收进src/wiki/render.js(与CHORO:HUMAN同住)——写的人是 t1.js,读的人还有 t0.js。 - 验证:新增 9 例(
test/wiki-t1.test.js7 例,runner 全部注入 stub——单测绝不真开会话):dry-run 零会话、快乐路径(富化 + 保留区共存 + 重富化剥旧段)、失败记账与重试、编造锚点拒绝、环境级中止、上限路径、未登记拒绝;test/wiki-t0.test.js2 例锁 t1Loss 清点(dry-run 也出声、evidence 不删、退回骨架后不再报警)与 CLI 解析(--timeout只收正数秒——NaN 混进 spawn 的 timeoutMs 时墙钟上限会静默失效,不报错)。全量npm test2780 tests / 2779 pass / 0 fail / 1 skipped;check-sync 92 一致。
- V1 已实测通过(T1 的全部前提):桌面版 headless 通道通——两个必需配方进了真实 runner:①绕过
-
chorography M1:仓库地志面板(浏览)+ 三个只读工具 + 关键词检索 — 消费面第一批落四张脸——面板:第二个宿主座位(
sidebar.panellist+main,id/key 逐字同为dsh-mneme-wiki),左模块树 / 右正文 / 顶部检索,只读 MD 渲染(React 文本节点天然转义,零 innerHTML 通道)…细节
路由:
/choro/repos|modules|page|search四条全policy: "open"(宿主围栏统一鉴权),chorographyEnabled关着统一 409 disabled(#497「路由挂着、逻辑层挡」先例);工具:choro_search/choro_read_page/choro_module_map走 hiddenTools 闸默认不在场,零写工具(红线 3);schema:choro_edges表补口(spec §7 的 module_map 要依赖边,M0 schema 漏了),replaceChoroGraph 顺手多删多插一段,幂等语义不变。- 开关语义:
chorographyEnabledopt-in 默认关(工具描述常驻模型上下文 = 新增成本,旗标计数锁 111 → 112);刻意不进LIGHT_MODE_OFF——choro 零每轮注入物、零热路径挂载,只有被动查询,轻量档关它省不出任何东西(config.js 键注释写明取舍)。 - 座位常挂(实现计划 P11):inject 同名二次注册的 cordis 语义没验证过,不赌——wiki 座位挂在 #562 的 panellist inject 块内,内容层按路由 409 渲染「未启用」空态,开关生效走既有重启纪律;两个座位的清理并入同一把扫帚(client.test.js 锁四个 dispose 一起跑)。
- 检索口径(P10):关键词子串、大小写不敏感、按
##分节返回命中与行号;frontmatter 的 sourceFiles 与正文清单都会命中——两层是契约不是巧合,节名(「页首」vs「文件清单(N 个)」)让调用方区分。向量留给 embedder 接入(V4)之后再上。 - 读侧真源纪律(P12):正文永远读仓库磁盘的 MD(root_path + DB rel_path),slug 只从 DB 白名单解析——路径拼装零用户输入,
../etc/passwd形态的 slug 走未知页 404;盘上缺页如实报 page-missing(409 + 重跑指引),不伪造内容。 - 验证:新增 13 例(wiki-queries 4 / wiki-tools 4 / wiki-api 4 / client 座位锁 1 + 行为锁口径更新为「记忆 + 地志两对座位」);SECURITY.md 双语开放读清单同步(security-doc 双向比对,漏一条 CI 就红);
docs-config-keys文档锁补 CONFIGURATION.md 行。全量npm test2770 pass / 0 fail / 1 skipped;check-sync 91 文件一致。
- 开关语义:
-
chorography(仓库地志)M0:T0 确定性骨架 +
choro_*五表 +dsh-mneme-choroCLI — 对标 Qoder 知识库/Repo Wiki 的 mneme 子系统第一批(spec:仓库根docs/specs/2026-11-04-chorography-design.md,实现计划与 P1–P7 决策:仓库根docs/plans/2026-11-04-chorography-m0.md)。读一个 JS/TS 仓库…细节
把文件树 + import 图(ESM 静态/副作用/动态、CJS require、Vue SFC
<script>、workspace/exports)编译成模块划分与仓库内<repo>/.chorography/的骨架页(frontmatter 带源文件清单与 git 基线),全程零模型零网络可离线;语义段落(架构/约定/技术栈)由 M2 的 headless 生成轮补。「生成吃宿主模型额度、不要求额外模型商 key」是整条线成立的前提(用户 2026-11-04 拍板),T0 这一步刻意一分钱不花。- 与记忆行物理隔离是硬边界不是实现细节:wiki 行永不进 saveWithDedupe / dream / 质量过滤的任何池子——autoDream 的「对核心知识库长期参考价值较低」归档理由一旦够得着 wiki 行,机器生成页就会被巩固流程自己归档掉(
dream-archive-guardrail测的正是这个语义)。五张choro_*表在 store.js 独立成段,replaceChoroGraph整仓替换(重跑幂等 = 单事务删了重插,划分变了不留孤儿行)。 - 保留区机制:页面里
CHORO:HUMAN标记对包住的内容跨重生成逐字节存活(抽整块、按原相对顺序回填页尾),块外手改不保留是明说语义不是事故。可重建不变量(同状态两次生成逐字节相同)由「generatedAt只进 state.json、失败不推进、下次沿用」撑住——spec §4 的 frontmatter 字段清单与 §9 的字节稳定要求表面冲突,在实现计划 P3 拆掉。渲染层有一条自反雷已排:页面说明行不得出现成对的标记字面量,否则下一轮抽取会把说明行当成保留区(回...
- 与记忆行物理隔离是硬边界不是实现细节:wiki 行永不进 saveWithDedupe / dream / 质量过滤的任何池子——autoDream 的「对核心知识库长期参考价值较低」归档理由一旦够得着 wiki 行,机器生成页就会被巩固流程自己归档掉(
v0.10.1
-
修一个打开记忆详情就崩掉整个宿主的崩溃(#553) — 0.10.0 里点开侧边栏任意一条记忆的详情抽屉,渲染进程立刻抛
ReferenceError: provenance is not defined。崩的是 renderer,整个宿主跟着重启,进行中的会话被打断,100% 复现。细节
根因是两个隔了三千多行的组件:
MemoryDrawer的归因 effect 与渲染块在读provenance/setProvenance,而那句useState落在了同文件上一个组件EntityPanel里(74ba401加归因区块时放错了位置)。EntityPanel里那份是纯死代码——整个函数体除声明外对provenance零引用。渲染先于 effect,所以第一帧就炸;就算渲染放过,effect 也会在setProvenance上炸。声明挪进MemoryDrawer、死代码删掉,并写明这个 state 为什么必须住在读它的那个组件里。
这个形状躲得过眼睛(隔三千行、同名 state 看着完全正常)、语法检查(provenance是合法标识符,只是不在作用域里)和原有用例(只读源码字符串,从不执行组件),所以补的不是「锁一个名字」的用例,而是按组件扫「调了setXxx(却没有声明」——锁的是这个结构。扫描器要认的几种合法情形都放行了:setTimeout/setInterval这类全局、把 setter 当形参往下传的嵌套函数、模块级同名 setter(openLibrary调setOverlayOpen,那是模块级 pub/sub,不是 React state)。变异自证:把声明原样搬回EntityPanel,用例当场打红MemoryDrawer calls setProvenance without declaring it。 -
图谱的锚定层补上另外两路起点,被动确认有了能查的地方(#24 收尾) — issue #24 的四块设计里,块 1 的「精确名 / Tag / 向量三路起点」当初只接了精确名一路——
anchoring.js本身早就支持多路(anchorSeeds累加、HOP_QUOTA分层都在)…细节
缺的是
service.js的调用点只喂了normalizePath(..., { path: "exact" })一路,而通路身份在合并那一步就丢了(normalizePath的返回值里没有path,anchorSeeds里p.path ?? "?"把每一路都记成"?")。块 4 的「例外管理」同样只有自动确认、没有可见面——面板里passiveConfirm一次都不出现。
Tag 路走全等匹配,不走子串:store.findEntitiesByTags把查询按标点和空白切成词、滤掉单字(中文单字作 tag 没有区分度),再要求tags数组里某个 tag 与词完全相等。子串匹配会让ai命中mail、让Java命中JavaScript,而这条路的作用是「用户提到了某个主题词」,模糊命中的代价是把整类无关记忆拉进图轴。排序按命中记忆数降序、名称升序——同分时要有确定性顺序,否则同一查询两次跑出来的种子次序不同。
锚点反查要看关系两端,不能只看 attr 行:findEntitiesByTags与entityIdsForMemories首版都只读entity_attrs,而dream/sleep.js的phaseRelations只写entity_relations.memory_id、不建 attr 行——仅由关系关联的记忆,对关系两端一个种子都生不出来(实测:只建关系不建 attr 时两个反查都返回空,而走saveAttr的对照组正常)。两个函数改为UNION三源(attrs / from / to),与getLinkedMemoryIds早就有的 attr|relation 双 tier 口径一致。同一条语句有三处 IN 列表共用记忆 id 集合,命中一多就撞 SQLite 变量上限,而调用方try/catch兜着——越限的表现是整条 Tag 锚点静默消失,故按 500 一批分块,块间via直接相加(各块记忆集合互不相交)。
向量路复用同一份 query 向量:searchMemories里已经有qv,把它以{ queryVector }传进entityRecall,而不是在图轴内部再嵌一次——二次嵌入既多一次模型调用,也会因为本地模型的随机性让「检索用的向量」和「图轴用的向量」不是同一个,而这种偏差只在图轴结果上体现,排查时看不到。近邻记忆到实体仍是 MAX 语义(一条记忆挂多个实体时取最高分),最低分项必须滤掉:min-max 会把这路里最弱的那条压成 0,而anchorSeeds照收 0 分种子,下游给种子挂联记忆用的是固定满权 tier(attr 1.0 / relation 0.9),不滤就等于「0 分的种子按满权拉记忆」。
早退条件也得改:原来if (!entities.length && !tagEntities.length) return []会把一个只有向量路能工作的查询直接掐死(比如查询里不含任何已知实体名,但语义上和若干记忆很近)——那恰恰是向量路存在的理由。
被动确认的复核面:新增GET /semantic/graph/boosted-relations(policy: "open",同/semantic/graph/*前缀,SECURITY.md两张表不必新增行),返回「合法抬升量 > 0 的边」——gain必须由后端算,因为要和来源基线比对(RELATION_SOURCE_DEFAULTS:manual 1.0 / confirmed 1.0 / tag 0.3 / llm 0.4),前端手里只有当前weight。判据用gain > 0而不是>= 0:weight是 REAL,1.0 - 1.0在浮点里可能是1e-17,用>=会把每一条从未被抬过的边都列进来。排序也必须在算完gain之后做:SQL 按weight DESC排、循环里再按条数截断,会让 llm 边(0.95,gain 0.55)压掉 tag 边(0.90,gain 0.60),被limit砍掉的恰恰是抬得更多的那条——故收全后按gain降序再slice。ego 路由的edges同时补上weight与source——只看weight会把「tag 边被抬到 0.5」误读成「比 manual 边弱」,而0.3 → 0.5恰恰是被用出来的证据。面板里这一面默认收起、展开才取数:它是审计视角而不是日常视角;闸关时只说「今后不会再自动抬权;下面列的是此前已经抬过的边」,不隐藏列表(关闸之后正是复核既有提升的时候),且空表提示与闸关状态解耦——否则「闸关 + 一条都没有」会只剩一句提示、没有任何行,读起来像这一屏坏了;取数失败单独说,不冒充闸关。
验收:test/graph-multipath.test.js(10 条)与test/graph-boosted-relations.test.js(9 条)新增,面板侧锁在test/client.test.js的 #24 用例;每条新用例都做过变异自证(把被它锁住的那行改坏,看它是否真的变红),其中两处首版用例是空转的、被变异自证当场抓住后重写:0 分种子用例原本把两条记忆挂在同一个实体上,滤不滤 0 分都到不了;排序用例把宽实体命名成字母序靠前的名字,按名字排也能过。全量 2680 tests / 2679 pass / 0 fail / 1 skipped。
贡献者 / Thanks
-
@strange2000 — — #553 的现场报告:把崩溃定位到「打开记忆详情必崩」这一步,贴出两条 renderer 日志、确认库本身无损(27 张表、
integrity_check正常),并顺手指出同一个 slot 里组件抛错会带走整个 renderer、建议补 error boundary。细节
根因到行号级的排查是从这份报告起步的。