# 记忆中心(dsh-memory-center)需求与验收标准 v1 > 已与用户逐项确认于本次会话。实现须严格对齐本文档。 ## 1. 背景 现有 Hindsight 插件(`@vectorize-io/hindsight-coding-agents` 0.8.2)因未配置 API key, 每次请求返回 `401 Authentication failed: API key required`,知识页为 0, 即"记忆"从未真正落过任何内容,且用户无法查看/修改。 因此 v1 不复用 Hindsight,改为**自建本地记忆库**,数据完全归用户所有。 ## 2. 已确认的决策 | 决策点 | 结论 | |---|---| | 后端 | 全新本地记忆库,不依赖 Hindsight | | 存储位置 | 自动探测(见 lib/paths.js):`DSH_MEMORY_DIR` → 面板选择 → 已有 store.json 的老目录 → `/memory` → 各盘符 `\dsh-data\memory` → `~/.dsh/memory`;不写死盘符,面板里可改 | | 文件夹结构 | 单层文件夹 + 标签;**标签可由用户自定义新增/重命名/删除** | | 模型写入 | 允许,但先进入「待确认」区,用户点确认才转为正式记忆 | | 新会话注入 | 每次都问:不加载 / 加载全部启用项 / 选文件夹 | | 中途导入 | 输入框 chip 的「导入」按钮 + 面板「加入本次会话」,两者都要 | ## 3. 功能点 ### F1 记忆主面板(资源管理器式,v1.1 改版) - 左侧栏新增「记忆」文字入口(`sidebar.panellist`,id=`memory`;宽侧栏由该行自身显示 label「记忆」,收起成竖栏时用「记忆」两个字代替图标),点击切换到全宽主面板(`main`,key=`memory`)。 - 设置页内提供同一记忆库的嵌入入口(`settings.section`,id=`memory`,order=60,标题「记忆库」)。 - 布局:左栏文件夹(含「全部记忆」「待确认」「未归类」),右栏顶部面包屑(`记忆 › 当前文件夹`,点「记忆」回全部)+ 条目卡片。 - 左栏文件夹行:单击进入、双击进入;悬停出现「…」、右键也弹出同一菜单(带入本会话 / 重命名 / 上移 / 下移 / 删除文件夹)。 - 条目卡片:可拖拽到左栏任意文件夹(含「未归类」)完成移动;拖拽中的行高亮为放置目标。 - 多选与批量:卡片与「全选」复选框,选中后顶部出现批量条(移动到… / 停用 / 启用 / 置顶 / 转正 / 加入本会话 / 删除 / 取消)。 - 条目卡片右键或「更多…」:编辑 / 置顶 / 停用 / 复制 / 转正 / 加入本会话 / 移动到(文件夹按钮组)/ 删除。 - 顶部:搜索框、导入 / 导出 / 预览注入;左栏底部显示统计与数据目录。 - 空库显示引导态。 ### F2 文件夹与标签可编辑 - 文件夹:新建、重命名、删除、排序(上移/下移)、条目跨文件夹移动(拖拽 / 批量条 / 右键菜单三条路径);删组时可选「迁入未归类」或「一并删除」。 - 标签:自由增删改,可在条目上多选;标签集合由已用标签自动汇总并去重。 - 术语统一为「文件夹 / 未归类」(不再出现「分组」)。 ### F3 条目编辑 - 字段:`title`、`body`(Markdown)、`tags[]`、`groupId`、`enabled`、`pinned`、`source`(manual/auto/import)、`createdAt`、`updatedAt`。 - 支持新建 / 编辑 / 删除 / 复制 / 置顶 / 启用停用 / 批量操作。 - 搜索即时过滤标题与正文。 ### F4 新会话询问 - 空会话时在输入框上方显示卡片:`本次是否加载记忆?` → 不加载 / 全部启用项 / 打开选择弹层。 - 选择弹层支持两种粒度并可混用:按文件夹(`groupIds`,组内后续新增的条目自动带上)与按单条(`itemIds`);未归类的条目只能按条选。保存后 `mode=custom`,两者取并集,计数与预览一致。 - 选择后卡片收起,输入框左侧出现 chip:`🧠 已加载 N 条 · M 组`(不加载时为 `🧠 未加载记忆`)。 - chip 弹层除「不加载 / 全部 / 文件夹勾选」外提供「按条选…」,打开同一个选择弹层。 - 点 chip 可打开预览,显示**本次真正注入给模型的原文**(只读),并可临时改选。 ### F5 中途导入 - chip 内「导入」按钮:勾选文件夹/条目,追加到本会话,下一轮生效。 - 面板内每个文件夹/条目有「加入本次会话」动作。 ### F6 模型侧工具 - `memory_search(query, limit?)`:检索已启用记忆(标题/正文/标签命中),返回条目摘要。 - `memory_write(title, body, tags?)`:写入后先落「待确认」,**不直接进正式库**;模型不能指定文件夹。同一个会话累计到第 2 条起,插件自动把这些候选一起放进一个以会话标题命名的文件夹(仍待确认),见 F11。 - 写入策略写进系统提示(`MEMORY_POLICY`),顺序是「关键词为主、自判为辅」:①用户明说「记一下/记住」直接写;②命中偏好、固定事实、拍板决策的关键词就写(只排除明显只对这一次有效的临时状态);③没命中关键词再四问自判(会不会误事 / 长期还是这次 / 用户明说还是模型推测 / 库里是否已有)。标题写结论,正文带具体值与原因;单次对话最多 3 条。 - 用户可在面板对任意条目右键「存放到文件夹」完成归类(菜单里逐条列出文件夹,当前所在项打 ✓)。 - 「待确认」页签顶部提供批量兜底:全部转正 / 存放到某文件夹并全部转正 / 全部删除;该页签注记「待确认的记忆不会参与注入」。 - 工具结果不泄露数据目录之外的绝对路径。 ### F7 持久化 - 数据目录自动探测(`lib/paths.js`),不假设任何盘符:`DSH_MEMORY_DIR` → 面板里记住的选择(不可写则跳过)→ 已存在 `store.json` 的老目录 → `/memory` → 各盘符 `\dsh-data\memory` → `~/.dsh/memory` → 临时目录兜底。 - `` 解析顺序:环境变量 `DSH_HOME` → 由 `DSH_PROFILE_DIR` 上溯两级 → `~/.dsh`。 - 数据目录可在面板「存储位置」里修改:路径输入框可直接编辑(双击全选)、旁边有「复制」与「选择…」(系统目录选择器)、「恢复默认」、可选「把现有记忆复制到新目录」;改完点「应用」才切换,选择写入 `<数据目录>/config.json`(设置 `DSH_MEMORY_CONFIG` 时写该文件),环境变量 `DSH_MEMORY_DIR` 存在时输入框只读。 - 切换后立即按新目录重载(不重启),旧目录不删除。 - 「恢复默认」= 重新自动探测(`config.get` 的 `defaultDir`),不回到某个写死的路径。 - 单一 JSON 存储:`<数据目录>/store.json`(自动建目录,原子写入:临时文件 + rename)。 - 会话级选择:`<数据目录>/sessions.json`,重启后仍保留进行中会话的选择。 - 记忆文件只写在该目录内,不写进项目源码目录。 ### F8 设置内入口(v1.1,用户追加需求) - 设置页左侧导航新增「记忆库」一项(`settings.section`,id=`memory`,order=60)。 - 内容与侧栏「记忆」面板同源同操作;嵌入版隐藏「返回对话」按钮,高度自适应为 `min(72vh, 660px)`。 - 两个入口改的是同一份 `store.json`,任一处改动另一处刷新即见。 ### F9 云端同步(v1.2,用户追加需求) - 默认「不绑定」:记忆只在本机,一个字节都不外发;DSH 账号本身不提供云存储(宿主没有给插件的云 KV 接口)。 - 三选一后端:`off` / `git`(任意 Git 远端,含 GitHub 私有仓库、Gitee、自建)/ `webdav`(坚果云、Nextcloud、群晖)。 - 合并粒度是**单条记忆**:按 `id` + `updatedAt` 取新的;删除写墓碑(`store.json` 的 `deleted[]`),不会在另一台设备上复活;文件夹取并集,冲突以本机为准。 - Git 后端:影子仓库在 `<数据目录>/.git-sync/repo`,`config.json` 与 `sessions.json` 不入库;先取远端 → 本地合并 → `reset --soft` 对齐远端 tip → 提交 → 推送,因此推送总是 fast-forward,不产生 git 文本冲突。 - WebDAV 后端:`MKCOL` 建目录 → `GET store.json`(记下 `ETag`)→ 合并 → `PUT` 带 `If-Match`;远端内容不是合法 JSON 时中止并报错,不覆盖;合并后与远端完全一致时跳过 `PUT`,不整份重传;合并期间远端被改(`412`)时中止并重试一次,避免两台设备并发时后到的整份覆盖静默丢条目;弱 `ETag`(`W/` 前缀)不放进 `If-Match`(RFC 7232 要求强比较,带上去只会 412 死循环),退回无条件 `PUT` 并接受 last-write-wins。 - 每次手动 / 自动同步都记录 `lastSyncAt` / `lastSyncStatus` / `lastSyncError`:`lastSyncAt` 只在成功时推进(它是增量合并的 `since`,失败时前进会漏合并),失败也写状态与错误并落盘,所以自动同步静默失败也能在面板看到。 - 同步是单飞的:一次同步进行中又产生改动时,不会把这次请求并进正在跑的那次(那样改动既不上传也不补跑),而是记下「还有新改动」,等这次结束后 0.5 秒自动补跑一次。 - `autoPull`(打开面板时拉取)/ `autoPush`(改动后 12 秒自动推,防抖:连续改动只推最后一次;连续编辑不断时最多攒 60 秒必推一次,不会因为一直在改就永远不推)都可关;另有「立即同步」。会话结束时若还有排队的推送,就地补推一次。 - 同步设置存在 `<数据目录>/config.json` 的 `sync` 字段(WebDAV 账号密码为明文,属本机文件);但接口与每个 `state` 快照都只回 `passwordSet`、不回密码原文(否则每个 `/op` 响应都明文传一次密码),面板里密码留空即保持原密码不变。 ### F10 首装引导(v1.3,用户追加需求) - 数据目录里还没有任何记忆(`store.json` 为空或不存在)时,首次加载播一份样例:3 条记忆 + 1 个「项目约定」文件夹。样例 id 是固定的(`m-seed-1` / `m-seed-2` / `m-seed-3`、文件夹 `g-seed`),两台设备各自首装后 id 相同,再开同步合并时按 id 对齐,不会把样例变成两份。 - 播种只发生一次:`config.json` 里还没有 `seeded: true` 时才播,播过立刻写 `seeded: true`;因此用户把条目和文件夹全删干净后重启,样例不会复活。这个字段只写本机 config,不参与同步。 - 绑定入口独立于这条提醒:记忆面板工具栏、设置 → 记忆库 顶部始终有「云端同步」按钮;删掉提醒不影响随时回去绑定。 ### F11 会话自动归档(v1.4,用户追加需求) - 同一个会话里累计写到第 2 条记忆时,插件自动建一个「与会话标题同名」的文件夹,把这批候选记忆一起放进去;状态仍是 `pending`,仍要用户转正才生效。 - 第 1 条仍留在「未归类」;第 2 条起自动连同前面的同会话条目一起归档,用户不用手动拖。 - 会话标题来源:宿主 `session.header.title`(若有)→ 本轮第一条用户消息(压空白、超 24 字截断加 `…`,与 GUI 侧栏标题同源)→ 会话 ID 前 8 位兜底。 - 文件夹已存在同名时直接复用;用户在面板里手动改过 `groupId` 的条目不覆盖。 - 每条记忆新增 `sessionId` 字段用于计数;该字段只在本机存储与同步合并中使用。 ### F12 锚点(v1.5,用户追加需求;v1.9 起自动初判) - 每条记忆可选「锚点」:`off`(不参与)/ `full`(完全锚点)/ `weight`(仅权重)。 - **新建时自动初判**(v1.9):按标题/正文/标签里的措辞确定性判定——命中「必须/禁止/只写/切记」等硬性措辞 → 完全锚点;命中「喜欢/尽量/统一/约定/默认」等偏好措辞 → 权重锚点;都没有 → 不设锚点。硬性优先于软性,安全语境(密码/密钥/凭据/隐私,以及 `api key`/`apikey`/`api_key`/`access key`/`secret`/`password`,大小写不敏感 + 不要/禁止/不能/never/do not)按硬性处理。描述性否定(「不能超过 260 字符」「不能正常启动」)只作事实,只有与「以后/每次/一律/都要/凡是/只要/记得/记住/永远/始终/任何时候」这类立规矩词同现才升级为完全锚点;「默认/一般/通常/所有/任何/全部/统一」属技术常用词,不作立规矩信号。不联网、不调模型,结果可复现、可解释。 - **初判可解释**:每条记录 `anchorSource`(`inferred` 自动 / `user` 手动)与 `anchorWhy`(命中的措辞),面板卡片徽标的 tooltip 与「更多… → 锚点」里都能看到。 - **手动优先**:用户手动设过锚点后记为 `user`,之后改正文不会被自动覆盖;`inferred` 的条目在正文/标签变了以后自动重判。菜单里有「↻ 按内容重新判断」可强制重判单条,批量栏有「重判锚点」。 - 老数据迁移:没有 `anchorSource` 的条目,锚点非 `off` 的视为手动设置(不覆盖),全是 `off` 的标记为可重判。 - 注入时在条目行前加 `〔完全锚点〕` / `〔权重锚点〕` 标记,并在顶部写明:完全锚点必须完全照此执行/表达、不得偏离;权重锚点只作为输出倾向的权重,影响措辞与优先级、可偏离。 - 新建/编辑弹窗增加「锚点(可选)」下拉;条目卡片显示对应徽标,便于扫读。 - 字段随 `store.json` 存储与同步;模型工具可传 `anchor` 显式指定(一般是自动初判),最终强度仍由用户在面板里改。 ### F13 条目管理增强(v1.6,用户追加需求) - **回收站**:删除只做软删除(`deletedAt`),数据仍留在 `store.json`;回收站可单条/批量恢复、彻底删除、清空。彻底删除才写墓碑用于跨设备同步;软删除的条目不参与注入、搜索、导出与计数。 - **批量二次确认**:任何一次影响多条的动作(批量删除、全部转正、清空回收站)先弹确认框,写明影响条数与能否撤销;单条删除同样走确认框。 - **Markdown 实时预览**:编辑弹窗内「编辑 / 预览」切换,支持标题、列表、加粗、行内代码、围栏代码块、引用、链接、分割线;渲染用 React 元素拼装,不使用 `innerHTML`。 - **批量打标**:选中多条后可追加标签(并入,不覆盖已有标签)。 - **底部批量操作条**:勾选后吸底显示「已选 N 条」与批量动作。 - **排序与视图**:工具栏单个下拉,四个选项(默认顺序 / 按首字母顺序 / 按修改时间顺序 / 按创建时间顺序);置顶条目无论升降序都恒在最前(方向只作用于同一置顶分组内部);不再有独立的升降序按钮,避免「按了没反应」的死控件。视图支持卡片 / 列表两种。 - **搜索高亮与空状态**:命中关键词高亮;空文件夹、待确认为空、回收站为空、无搜索结果分别显示对应文案与操作按钮。 - **锚点配色区分**:完全锚点橙红(硬性)、权重锚点蓝绿(软性),并带实心圆点。 - **不引入第二套前端框架**:宿主页面由 React 渲染,插件只在自身槽位内用 React 状态更新,避免与模板框架抢 DOM。 ### F14 记忆治理(v1.7,用户追加需求) - **注入预算与压缩线**:硬上限 `DSH_MEMORY_MAX_CHARS`(默认 12000 字符);压缩阈值默认 0.8(即 80%),记忆总量超过阈值即压缩:按 完全锚点 → 权重锚点 → 置顶 → 越新越先 装载,装不下的条目退化为「只有标题」索引并提示 `memory_search`。索引清单同样从压缩线里装(标题按码点裁到 60 字、标签裁到 40 字,装不下的只报「共 N 条」),因此注入总量始终受压缩线约束,阈值滑杆真实生效。 - **阈值可在面板里调**:容量弹窗提供 10%–100%(步进 5%)的拉条 + 「应用 / 恢复 80%」,写入 `config.json` 的 `injectRatio`,立即生效、无需重启;优先级为 环境变量 `DSH_MEMORY_COMPRESS_RATIO` > 面板保存值 > 默认 0.8,设了环境变量时拉条只读并说明原因。 - **条目不截断**:压缩以整条为单位,绝不把一条记忆从中间切开;索引清单按剩余预算逐条装、标题先按码点裁到 60 字(超长补省略号),装不下的只报「共 N 条」,所以正常情况不会走到硬截断兜底。 - **单条规模护栏**:一条记忆正文最多 20000 字、标题最多 200 字,`item.create` / `item.update` / `item.merge` 都检查,超限直接报错并提示「拆成几条 / 放进归档」;挡住的是 `store.json` 被单条巨型正文撑大(同步整份上传、git 历史各留一份)。空 patch 的 `item.update` 直接返回快照,不再推进 `updatedAt`,免得「什么都没改」在合并时赢过别的设备。 - **墓碑自动清理**:删除留下的墓碑保留 90 天(`TOMBSTONE_KEEP_DAYS`),加载时自动清掉过期墓碑、以及过期的回收站条目,不需要手动调 `deleted.prune`。 - **容量画像**:面板可查看当前总量、占压缩线的比例、各文件夹字符数、最长的 8 条(每行可「定位」到所在文件夹或直接「归档」),并在超过压缩线时提示。 - **归档层**:`archivedAt` 标记的条目退出注入与常规列表,进入「归档」页;可批量还原。归档是压缩记忆量的主要手段,不删除数据。 - **合并压缩**:可把多条旧记忆合并成一条精炼记忆,原条目全部转入归档并记录 `mergedInto`,不写墓碑。 - **回收站保留期**:软删除条目默认保留 30 天,到期在下次加载时彻底删除并写墓碑。保留天数可在回收站底栏直接选(不自动清理 / 7 / 30 / 90 / 180 天),写入 `config.json` 的 `recycleDays` 并立即生效;优先级为 环境变量 `DSH_MEMORY_RECYCLE_DAYS` > 面板保存值 > 30 天,设了环境变量时下拉只读。回收站底栏同时显示占用字符数。 - **批量改字段白名单**:`item.batch` 只接受 `enabled / pinned / status / groupId / anchorMode / tags / addTags / removeTags`;遇到其它字段(如 `title`)直接报错而不是静默忽略——批量改正文是事故入口。 ### F15 检索(v1.8,用户追加需求:搜索要能用) - **分词**:中文按二元组 + 拉丁词/数字切分,忽略标点空白;查询与条目用同一套分词。 - **打分**:BM25(k1=1.2, b=0.75),字段权重 标题 ×3、标签 ×2、正文 ×1;结果按分数降序,同分按置顶、更新时间。 - **同义词扩展**:查询词命中内置同义组时,组内其它说法以 0.5 权重参与召回;用户可在 `config.json` 的 `synonyms` 里追加自己的组,与内置组并存;面板「设置 → 记忆库 → 同义词…」可直接编辑(`synonyms.set`),保存即清索引、下一次搜索生效,不需要重启。 - **命中片段**:每条结果返回围绕命中的一段原文(约 120 字,命中词两侧截断),面板据此高亮,`memory_search` 也用它做摘要。 - **一致性**:面板搜索框、`memory_search`、写入前查重共用同一实现(`lib/retrieval.js`)。 - **作用域**:默认排除回收站、已归档、已停用条目;`includeArchived` 可显式包含归档。 ### F16 新会话默认与预算旋钮(v1.10,用户追加需求:别每次都问、上限要能调) - **新会话默认加载策略** `sessionDefault`:`ask`(默认,每个新会话弹卡片问一次)/ `none` / `all` / `custom`(指定 `groupIds`)。写在 `config.json`,面板工具栏下拉与 chip 弹层「设为新会话默认」都能改。 - **优先级**:单个会话自己的选择(`sessions.json`)永远压过 `sessionDefault`;清除本会话选择后回落到默认。设为非 `ask` 时不再弹询问卡片,`sessionView.decided` 由继承算作已决定(`inherited: true`)。 - **注入上限可调**:`budget.set { maxChars }` 改硬上限(1000–200000),容量弹窗拉条 4000–50000、步进 1000;改完立即重算压缩线并写 `config.json` 的 `maxChars`。`budget.set` 同一次可只传 `ratio`、只传 `maxChars`,或两个都传。 - **环境变量仍最优先**:`DSH_MEMORY_MAX_CHARS` / `DSH_MEMORY_COMPRESS_RATIO` 任一存在时,对应拉条只读(`maxCharsLocked` / `ratioLocked`)。 - **口径说明**:上限按字符算,不是 token;中文与英文同等字符数信息量不同,面板文案里写明了这一点。 ### F17 同主题新旧标注(v1.11,用户追加需求:别让旧结论继续生效) - **判定**:只在 `active + enabled + 未删除 + 未归档` 的条目之间两两比较,用标题/正文的词集合(`lib/retrieval.js` 的 tokenize)算 Jaccard;标题 ≥ 0.5 或「正文共享词 ≥ 5 且 Jaccard ≥ 0.4」才算同主题。偏保守——漏判成两条独立记忆无害,误判会让模型以为旧结论被覆盖。 - **分组与最新**:同一组内按 `updatedAt` 降序,第一条为 `latestId`;组内时间跨度小于 60 秒的视为同一批写入,不标注新旧(`byId` 里不出现)。 - **注入呈现**:非最新条目在标题行下加 `〔旧版,<日期> 有过更新;同主题以「<最新标题>」为准〕`;整段头部多一条统一说明;`stats.stale` 记被标旧版的条数,`stats.topics` 记分组数。 - **口径一致**:`costOf(item, topic)` 把这一行也算进字符成本,容量画像与注入预算共用。 - **检索**:`search` op 的命中项带 `topic`(`isLatest/latestTitle/latestAt/total`);`memory_search` 在旧版行尾追加同款说明。 - **面板**:条目上黄色「旧版」徽标(悬停显示最新标题与日期);左栏底部「同主题 N 组 · 旧版 M 条 → 点此选中」。 - **缓存**:`topicsOf(state)` 按 `state.updatedAt + 条目数 + 各条 updatedAt` 缓存,同一请求里 `capacityOf` 与 `composeInjection` 不重复算。 - **性能与容量(v1.14)**:`tokenSets(item)` 每条只分词一次;候选对用倒排表枚举,只比较「至少共享一个词」的对(共享 0 词按定义不可能同主题,跳过不改变结果);文档频率 > `max(50, 0.4n)` 的超高频词不进倒排表(仍留在判定集合里),避免单个「记忆」生成 n² 的桶。并查集 `find` 带路径压缩、`union` 按大小合并——缺这两条时大量相似条目会连成长链,5000 条实测 72 秒,修好后 1.1 秒。容量上限 `MAX_TOPIC_ITEMS = 3000`:超过时只对**最近更新**的 3000 条做识别、其余不标注(1 万条实测 5.2 秒、峰值 +500MB → 截断后 0.3 秒、+35MB);这是有损的,取舍理由见 `lib/topics.js` 文件头。`dev/topics-perf.test.mjs` 守住「新旧实现结果逐字节一致」「分词次数 O(n)」「800 条秒回」,`dev/topics-stress.mjs --full` 用于手工压极限。 ### F18 同步与运维的五处兜底(v1.12,用户从「建议」里挑的五项) - **同步冲突副本**:自动拉取按条目合并后、写回本地之前,先把本机 `store.json` 复制成 `<数据目录>/backups/conflict-<时间>-.json`(只保留最近 5 份,不计入 corrupt/pre-switch/pre-purge 的配额),`syncInfo.conflicts` 记次数并在面板显示「已保留本机改动 N 次」。理由:条目级合并取新者赢,输掉的那一版不能只活在内存里。 - **注入总开关(两级)**:`injection.set` 支持两种范围—— - 缺省 / `scope:'all'`:全局,写 `config.json` 的 `paused`,所有会话都不注入(`stats.paused=true`); - `scope:'sessions'` + `sessionIds`:只暂停这些会话,把原选择存进 `sessions[id].paused = { at, previous }` 并把该会话置为 `mode:'none'`;恢复时原样还原,原本「未决定」的会话则删除记录(回到会弹询问卡片)。 面板工具栏按钮在未暂停时先弹选择「暂停所有会话 / 只暂停当前会话」,按钮文案与左栏提示条跟随当前状态(全局 / 本会话);`snapshot.pausedSessions` 列出仍在暂停的会话。 - **待确认积压提醒**:左栏「待确认」条目数 ≥ 3 时,面板顶部出现提示条「有 N 条记忆等你转正」并带跳转;不阻断任何操作。 - **删除记录回收**:墓碑默认保留 `DSH_MEMORY_TOMBSTONE_DAYS`(90)天;`deleted.prune` 清理早于 N 天的记录(`dryRun` 支持预览),`deleted.prune { all: true }` 一次清空全部记录;回收站底栏显示 `recycle.tombstones` 条数,提供「清理 N 天前」与「清空删除记录」两个按钮(都要二次确认)。 - **检索回归集**:`dev/search-regression.test.mjs`——22 条真实感记忆 + 22 个自然语言查询 + 4 个必然无结果的查询,断言目标条目进前 3、噪声查询零命中。改分词/BM25/同义词必须跑它;已知局限(中文二元组无法理解否定)写在测试文件注释里。 ### F19 增量注入(v0.10.13,用户追加需求:别每轮重发全量) - **两种注入**:每会话第一次注入全量,头部写 `〔记忆快照 #N〕`(N = 当前 `revision`);之后若上一轮的标记还在当前消息里、且版本链连续,只发变化,头部写 `〔记忆更新 #N〕`,正文按「新增 / 改动 / 移除」分块,并说明是哪一条。 - **版本链判定**:从消息数组里读出所有 `〔记忆(快照|更新) #N〕` 标记,要求快照标记仍在、最新标记的 N 等于当前 `revision`、`revision - snapshotRev <= 512`、且区间内每一版都出现过。任何一条不满足(典型是宿主压缩把老消息删了、或刚重启)就退回全量。 - **不变量**:完全锚点正文必须给全文(最多 4000 字,`DSH_MEMORY_ANCHOR_MAX` 可调;超了写「没读到全文之前不要照它执行」);`anchorMode` / 置顶的状态变化只用声明表达;删除/移除一定会出现。普通条目在增量里单条正文最多 400 字(`DSH_MEMORY_DELTA_BODY` 可调),超了明确写「还有 N 字没展开」;只改标签/分组的条目只给标题。 - **回退条件**:增量总量上限 `max(600, 上次快照字符数 × 0.4)`,超了本轮退回全量;带锚点全文的增量不受此限。暂停注入或本会话选择为 `none` 时不发增量(避免把「没选中的条目」误判成移除)。 - **可观测**:`snapshot.lastInject = { kind: 'full'|'delta', chars, at, rev, saving }`,面板底部显示「增量 · 上一轮 153 字(省 98%)」或「全量 · 上一轮 6.2k 字」。`DSH_MEMORY_RECALL_DRY=1` 只记日志、不真的注入,用于上线前干跑。 - **测试**:`dev/recall.test.mjs` 守全量→增量→无变化→锚点全文→超长截断→压缩后回退全量;`dev/bench-replay.mjs` 用真实会话回放估算节省比例。 ## 4. 验收标准 | # | 验收标准 | 验证方式 | |---|---|---| | AC1 | 侧栏出现「记忆」文字入口,点击进入全宽主面板;空库显示引导态;设置页内「记忆库」可打开同一面板 | 手动 + 截图 | | AC2 | 文件夹可新建/重命名/删除/排序;条目可跨组移动;删组时可选迁入未归类 | 手动 | | AC3 | 条目可新建/编辑/删除,正文 Markdown 渲染;改动立即落盘,重启 DSH 后仍在 | 手动 + 检查 store.json | | AC4 | 条目启用/停用生效;停用条目不出现在任何注入内容与 `memory_search` 结果中 | 注入预览比对 | | AC5 | 新建会话出现加载询问卡片;选「不加载」后模型上下文完全不含记忆 | 注入预览 + 询问模型"你知道我什么" | | AC6 | 注入预览文本与实际发送给模型的一致,且标注文件夹与条数 | 手动比对 | | AC7 | 会话中途追加文件夹后,下一轮回复能引用该记忆,chip 计数更新 | 手动 | | AC8 | 模型调用 `memory_write` 后进入「待确认」,面板无需刷新即可见;确认后转正 | 手动 | | AC9 | 导出全部为 JSON 与 Markdown;导入按「标题+正文」去重,先预览将新增/跳过条数 | 手动 | | AC10 | 数据目录由自动探测决定,不依赖任何固定盘符:无 `DSH_MEMORY_DIR`、无配置文件、无老数据时落到 `/memory`(本机为 E 盘数据根下的 memory);记忆文件只出现在该目录内 | `node dev/paths.test.mjs` + `Get-ChildItem` | | AC11 | 设置页出现「记忆库」页,能完成与侧栏面板相同的增删改/文件夹/导入导出,且与侧栏共享同一份数据 | 手动:两处各改一条,互看结果 | | AC12 | 默认不开启同步时,面板显示「云端同步」但没有任何外发请求;开启 Git/WebDAV 后,两台设备各自改动不同条目再同步,两边都保留;删掉的条目不会回流 | `node dev/sync-git.test.mjs` + `node dev/sync.test.mjs`(后者用本地假 WebDAV 服务器) | | AC13 | 全新数据目录首次加载后,「待确认」出现「跨设备记忆需要开同步」,且不参与注入;删掉该提醒后,面板工具栏的「云端同步」入口仍在并可完成绑定 | `node dev/host-smoke.mjs` + 手动 | | AC14 | 同一会话写到第 2 条记忆时,自动出现以会话标题命名的文件夹,两条都在其中且仍是待确认;另一个会话各写一条则都不建文件夹 | `node dev/memory-write.test.mjs` + 手动 | | AC15 | 给一条记忆设「仅权重」后,注入预览带〔权重锚点〕与锚点规则说明;把锚点改回「不启用」后该条不再带标记,完全锚点不受影响 | `node dev/anchor.test.mjs` + 手动 | | AC16 | 删除一条记忆后它从列表与注入预览消失、但仍留在 `store.json` 并可恢复;回收站里「彻底删除」后才从文件移除且写入墓碑;清空回收站可一次清完 | `node dev/recycle.test.mjs` | | AC17 | 批量打标只往里加标签、不覆盖已有标签;同一标签不会重复出现 | `node dev/recycle.test.mjs` | | AC18 | 批量删除 / 全部转正 / 清空回收站都会先弹确认框,写明影响条数;取消后不产生任何改动 | 手动 + `node dev/smoke-client.mjs`(bulk-confirm 变体) | | AC19 | 编辑弹窗可在「编辑 / 预览」间切换并正确渲染 Markdown;排序与卡片/列表视图切换即时生效(三种排序切换后条目顺序确实改变,置顶恒在最前);搜不到的条目会显示空状态 | `node dev/smoke-client.mjs`(list-view / sort-updated / sort-updated-asc / recycle 变体)+ 手动 | | AC20 | 压缩线默认为上限的 80%(12000 → 9600),超过即压缩:进正文的条目标记 `compressed=true`,装不下的只给标题;`DSH_MEMORY_COMPRESS_RATIO=0.5` 时压缩线随之变为上限的 50%;预算放大到足够时 `compressed=false` | `node dev/budget.test.mjs` | | AC21 | 面板拖条改阈值后立即生效(无需重启)、写入 `config.json` 的 `injectRatio`、刷新页面后仍是新值;点「恢复 80%」回到默认;存在 `DSH_MEMORY_COMPRESS_RATIO` 时拖条只读并说明原因 | `node dev/host-smoke.mjs` + 手动 | | AC22 | 检索按相关度排序:查询「存哪」能找到正文写着「存储位置」的条目(同义词扩展),标题命中排在正文命中之前;完全无关的词返回空 | `node dev/search.test.mjs` | | AC23 | 面板搜索框、`memory_search`、写入前查重结果一致;`synonyms` 里追加 `["存哪","存储位置"]` 后,原来搜不到的说法也能命中 | `node dev/search.test.mjs` + 手动 | | AC24 | 压缩以整条为单位:进正文的条目正文必须完整出现,不允许出现半条;任何情况下注入文本不超过硬上限 | `node dev/budget.test.mjs` | | AC25 | 归档的条目不参与注入、搜索与计数,可在「归档」页批量还原;合并后原条目全部转入归档并记录 `mergedInto` | `node dev/governance.test.mjs`(如存在)+ 手动 | | AC26 | 回收站保留天数可在面板底栏改动(写入 `config.json` 的 `recycleDays`、立即生效)、显示回收站占用字符数;越界值被拒;设了 `DSH_MEMORY_RECYCLE_DAYS` 时下拉只读;改小天数导致待清理时为 `dryRun` 预览 + 二次确认,正式清理前写 `<数据目录>/backups/pre-purge-<时间>.json` 整库备份 | `node dev/recycle.test.mjs` | | AC27 | 三类自动备份(`corrupt` / `pre-switch` / `pre-purge`)统一落在 `<数据目录>/backups/`,每种只保留最近 5 份;老版本散在数据目录根上的 `store.json.corrupt-*` 在下次加载时自动迁入;`backups.prune` / `backups.clear` 只删这三类文件,不碰 `store.json` / `sessions.json` / `config.json` | `node dev/backups.test.mjs` | | AC28 | `DSH_MEMORY_MAX_CHARS` 可在容量弹窗里用拉条改(4000–50000,步进 1000),应用后注入上限与压缩线立即重算并写入 `config.json` 的 `maxChars`,刷新/重启后仍生效;设了该环境变量时拉条只读并说明原因;越界值被拒 | `node dev/session-default.test.mjs` + 手动 | | AC29 | 面板工具栏的「新会话默认」可选「每次都问 / 不加载 / 全部加载 / 当前会话选的文件夹」,写入 `config.json` 的 `sessionDefault`;设成非 `ask` 后新会话不再弹询问卡片且按默认注入,单个会话自己的选择覆盖默认,「清除本会话选择」后退回默认 | `node dev/session-default.test.mjs` | | AC30 | 两条说法冲突的记忆(同为启用、未归档)被判为同主题:注入时旧版带〔旧版…以「<最新标题>」为准〕、头部有统一说明、`stats.stale=1`;检索命中旧版时行尾带同款提示;面板条目出现「旧版」徽标、左栏出现「同主题 N 组 · 旧版 M 条」;归档/停用条目不参与;同一分钟内写入的不标新旧 | `node dev/topic.test.mjs` + `node dev/smoke-client.mjs` | | AC31 | 导出文件 `version` 高于当前支持的 `EXPORT_FORMAT_VERSION` 时拒绝导入并提示升级,不降级解析;只增字段的变更不升 version(规则见 F18 与格式增补表) | `node dev/import-anchor.test.mjs` | | AC31 | 删除记录可一次清空:`deleted.prune { all: true }` 清掉全部墓碑并返回剩余 0,默认调用只清超过保留天数的那些,正常条目与 `store.json` 结构不受影响 | `node dev/recycle.test.mjs` | | AC31 | 同一会话内 `memory_write` 最多 5 条待确认,第 6 条被拒且不落库 | `node dev/memory-write.test.mjs` | | AC32 | 后台失败(备份/清理/同步/回收站清理)写入 `<数据目录>/errors.jsonl` 并保留最近 100 条,设置页顶部显示最近 6 条;无失败时不显示该区域 | `node dev/smoke-client.mjs` | | AC33 | 记忆正文里的 markdown 链接只渲染 `http`/`https`/`mailto`,其它协议(含 `file:`)降级为纯文本 | `node dev/smoke-client.mjs` | | AC34 | `state` 快照的 `items` / `archived` / `deleted` 三类列表都只带 `excerpt`(正文前 120 字,按码点截断,emoji 不会被劈成半个代理对,并且只扫到第 120 个码点就停、不对整段正文做码点迭代)与 `bodyChars`,不带 `body`;归档与回收站条目仍可用 `item.get` 取全文,`archiveView` 的 `chars` 仍按原文长度算 | `node dev/snapshot-shape.test.mjs` | | AC35 | 压缩线是注入总量的预算(索引清单也从压缩线里装):阈值拖到 0.3 时注入长度跟着压到 0.3 × 上限以内,不再出现「怎么拖都是 11900」 | `node dev/budget.test.mjs` | | AC36 | WebDAV 上传带 `If-Match`:合并期间远端被改(412)时中止并重试一次;弱 `ETag`(`W/` 开头)不放进 `If-Match`;同步进行中产生的新改动会在本次结束后补跑一次,不会被吞掉 | `node dev/sync.test.mjs` + 手动 | | AC37 | 自动推送是「12 秒防抖 + 最长 60 秒必推」:连续编辑不断时最多 60 秒必推一次,不会永远不推;同步接口与每个 `state` 快照都不回密码原文,只回 `passwordSet` | `node dev/snapshot-shape.test.mjs` + 手动 | ### F18 导出格式演进规则(v0.9.9,评审建议) 导出文件是**外部产物**:一旦发出去,历史副本会散落在用户的网盘、备份盘、聊天记录里,插件收不回来。 所以格式演进只做加法,并且把规则写死: - `version` 表示「结构基线」,**不是**每次发版递增的插件版本号。 - **只增字段,不改语义**:新增字段不升 `version`。老导入端忽略未知字段;新导入端遇缺失字段走默认值或按内容重判(例如锚点缺失时走 `resolveAnchor`)。 - **禁止删字段、改字段名、改字段类型**:删了不会报错,只会静默丢数据。 - **语义变更才升 `version`**:字段含义改变、字段删除、字段类型改变。 - `version` 高于当前支持值时**拒绝导入**并提示升级插件,不尝试降级解析——读不了是小事,静默丢字段是数据事故。 配套护栏在 `parseImportPayload`:`const formatVersion = Number(parsed.version) || 1`,大于 `EXPORT_FORMAT_VERSION` 直接 `fail`。 #### 格式增补表(append-only,只加行不改行) | 格式版本 | 插件版本 | 日期 | 变更 | |---|---|---|---| | v1 | v0.9.5 及更早 | 2026-10-23 | 基线:title / body / tags / group / enabled / pinned / status / createdAt / updatedAt | | v1 | v0.9.7 | 2026-10-24 | + anchorMode / anchorSource / anchorWhy(纯增字段,不升 version) | | v1 | v0.9.8 | 2026-10-24 | 导入端尊重 status:pending 候选不会被转正;Markdown 导出改用过滤后的条目集合 | | v1 | v0.9.9 | 2026-10-24 | `version` 从死字段变成护栏(高于当前基线则拒绝导入) | ## 5. 非目标(v1 不做) - 不做云同步、不做 Hindsight 双向同步。 - 不做多级文件夹、不做向量检索(先做关键词/子串匹配)。 - 不做记忆自动过期策略(仅记录时间,供后续版本)。