@huai_xin/dsh-memory 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,243 @@
1
+ # AGENTS.md
2
+
3
+ 给在这个仓库里写代码的人(和 AI)看的。用户文档在 [README.md](./README.md),这里讲结构、不变量和踩过的坑。
4
+
5
+ ## 1. 这是什么
6
+
7
+ DeepSeek Harness 的跨会话长期记忆插件。记忆是磁盘上的 YAML 文件(标题树),插件把它注入提示词、给模型三个工具读写、并在别的会话改动后发一条不唤醒的通知。另有一份按天分文件的**日志**(短期记忆)。
8
+
9
+ - 做:提示词注入、读写工具、跨会话通知、按日日志、设置面板。
10
+ - 不做:自动召回、删除记忆目录、跨层级移动章节、删除日志。
11
+ - 依赖只有 `yaml`。ESM,无构建步骤,`"type": "module"`。
12
+
13
+ 用户画像:聊天、扮演、轻量编程;记忆存量预期不大,塞得进提示词。
14
+
15
+ - **不做自动召回是故意的**,不是没做完。整份注入是主路径——检索排错了比不排更糟(一段"看起来相关其实是别的场景"的记忆会把模型带偏),而存量不大的时候先筛一遍只是白丢上下文。以后可能加**基于关键词的轻量召回**,定位是存量变大后的兜底,不替换整份注入。改注入流程时别把它改成 RAG。
16
+ - **头号设计目标是"结构可自定义"**,不是"功能多"。插件不预设文件清单、章节模板或字段语义,一切结构都在文件自身的 `file_name` / `desc` / `format` / 标题树 / `inject_ignore` 里。所以:不要为了某个用例往代码里塞硬编码的结构约定;加能力优先想"这件事能不能由用户自己用现有字段表达"。
17
+ - 由此推出一条实现偏好:**注入顺序和 `inject_ignore` 都按文件生效**,用户靠"分文件"控制粒度(一个文件一份用途)。这也是 `fileOrder` 只影响注入、面板拖动即排序的原因。
18
+
19
+ ## 2. 跑起来
20
+
21
+ ```bash
22
+ # 五份离线测试,不需要宿主(这是主要的验证手段)
23
+ node --test test/core.test.mjs test/store.test.mjs test/schema.test.mjs test/log.test.mjs test/settings.test.mjs
24
+
25
+ # 需要在真实宿主里跑:逐条验证三个工具的成功与错误路径
26
+ node test/live.mjs
27
+
28
+ # 把样例记忆渲染出来人工看一眼
29
+ node test/dump.mjs
30
+ ```
31
+
32
+ 本机默认 `node` 可能被沙箱拒绝,可用的 bundled node:
33
+ `E:\用户\.dsh\dsh-runtimes\dsh-plugin-desktop-primary-runtime\dependencies\node\bin\node.exe`。
34
+ **没有 `react-dom` / `react-test-renderer`**,浏览器侧代码只能在 Node 里用 hooks shim 冒烟,没法真挂载 DOM。
35
+
36
+ ## 3. 分层与模块
37
+
38
+ 依赖单向:`tools → runtime → store/snapshot/notice/render/edit → document/model/tree → text/path`。`errors` 谁都能 import,它谁也不 import。底层不 import 上层。
39
+
40
+ | 模块 | 职责 | 对外 |
41
+ | --- | --- | --- |
42
+ | `index.js` | 入口:读配置、挂 hook、注册工具 | `name` `inject` `Config` `apply` |
43
+ | `lib/runtime.js` | 协调:可见范围、注入文本、投递、通知、数据访问、快照生命周期 | `MemoryRuntime` |
44
+ | `lib/tools.js` | 三个工具的定义与参数校验 | `TOOL_NAMES` `readTool` `editTool` `manageTool` `renderEditDescription` |
45
+ | `lib/store.js` | 磁盘门面,把 cache/watch/writer 拼起来 | `MemoryStore` |
46
+ | `lib/cache.js` | 目录加载、缓存索引、按文件名定位 | `MemoryCache` `FILE_EXTENSION` `logRelative` `isLogRelative` |
47
+ | `lib/watch.js` | 文件监听与 200ms 防抖重载 | `MemoryWatcher` |
48
+ | `lib/writer.js` | 写入队列、hash 乐观锁、重名判定 | `MemoryWriter` |
49
+ | `lib/atomic.js` | 原子落盘(temp + rename + Windows EPERM 重试) | `writeAtomic` |
50
+ | `lib/snapshot.js` | 会话快照(三份)与标题级比对 | `headingHashes` `snapshotEntry` `diffSections` `diffEntries` `readSnapshot` `writeSnapshot` `copySnapshot` `removeSnapshot` `isStale` `pruneSnapshots` |
51
+ | `lib/notice.js` | 比对结果 → 通知文案(空列表返回 `undefined`) | `NOTICE_SOURCE_KIND` `renderNotice` `renderBrokenNotice` |
52
+ | `lib/render.js` | 文档 → 文本(注入、读取、日志、文件清单、format 段) | `renderInjection` `renderRead` `renderLog` `renderLogRange` `renderFileList` `renderFormats` |
53
+ | `lib/edit.js` | 一次编辑请求的全部语义 | `applyEdit` `parseHeadingLine` |
54
+ | `lib/log.js` | 日志形状、日期与时间查询、固定规则文案 | `LOG_DIR_NAME` `LOG_MAX_DEPTH` `DEFAULT_SUMMARY_MAX_CHARS` `LOG_DESCS` `logRules` `logReadRules` 等 |
55
+ | `lib/document.js` | YAML ↔ 文档对象 | `parseDocument` `serializeDocument` |
56
+ | `lib/model.js` | 常量、字段校验、节点与文档构造 | `MAX_DEPTH` `RESERVED_KEYS` `INJECT_IGNORE_OPTIONS` `INJECT_IGNORE_LEVELS` `SCOPES` `createNode` `createDocument` `cloneDocument` 等 |
57
+ | `lib/tree.js` | 标题树查找与改写 | `walk` `findNode` `findSiblings` `touchPath` `renameHeading` |
58
+ | `lib/text.js` | 文本规范化 | `normalizeBody` `normalizeFragment` `normalizeMetaLine` `normalizeMetaBlock` |
59
+ | `lib/path.js` | 标题路径 | `PATH_SEPARATOR` `normalizeHeadingPath` `headingLabel` |
60
+ | `lib/hash.js` | 哈希 | `hashText` `shortHash` |
61
+ | `lib/paths.js` | 目录布局(唯一的路径来源) | `resolveLayout` |
62
+ | `lib/errors.js` | 全库唯一的错误类型 | `DocumentError` |
63
+ | `lib/config-schema.js` | 配置项的单一事实来源 | `CONFIG_FIELDS` `CONFIG_FIELD_BY_KEY` `defaultSettings` `readField` `formatScalar` |
64
+ | `lib/config.js` | 配置文件读写(就地改行、保留注释) | `CONFIG_FILE_NAME` `loadConfigFile` `saveConfigValue` |
65
+ | `lib/settings-route.js` | 设置面板的 HTTP 端点 | `SETTINGS_ROUTE` `createSettingsHandler` |
66
+ | `client.js` | 浏览器侧「记忆」标签页:装配、i18n、样式、状态与 effect | 模块工厂,注册进 `conversation.view` |
67
+ | `client.settings.js` | 设置表单渲染(chunk) | `renderSettings` |
68
+ | `client.browse.js` | 左栏列表渲染(chunk) | `renderBrowser` |
69
+ | `client.file.js` | 右栏详情与标题卡渲染(chunk) | `renderFile` |
70
+
71
+ `lib/store.js` 只是门面,三块互不认识:`cache.js` 不认识文件系统事件,`watch.js` 不认识 YAML,`writer.js` 不认识监听。它们之间用 `cache.onLoaded` 回调挂钩,所以 cache 不用 import watcher,也就没有循环依赖。
72
+
73
+ ## 4. 不变量(改代码时别破坏)
74
+
75
+ 1. **注入顺序必须稳定**。同一批文件、同一批配置,每次组装出的文本逐字节相同。不按 `update_at` 排、不按修改时间排——注入是 system prompt 的前缀,顺序一漂,整段缓存全废。
76
+ 2. **一个会话的前缀在整个生命周期内不变**。注入文本和 `memory_edit` 描述按会话冻结(§6),只有快照过期重建时才重算。
77
+ 3. **标题最多 4 级**,由 `lib/model.js` 的 `LEVELS` 派生(`MAX_DEPTH`、`RESERVED_KEYS`、`inject_ignore` 合法值都跟着走)。加一层只改 `LEVELS`。
78
+ 4. **`desc` / `keyword` / `update_at` 是标题的保留字**,任何层级都不能当标题名。
79
+ 5. **同级不能重名**;`file_name` 在"一个会话能看到的目录"里唯一(不同工作区各自同名是允许的)。
80
+ 6. **序列化往返稳定**:`serializeDocument` → `parseDocument` 必须得到同一份文档;空字段不写进文件。
81
+ 7. **正文开头不能是 `#`**(会被读成标题行);中间和后面几行可以。日志的 `old_string` / `new_string` 同理,只在开头这一条上拒绝。
82
+ 8. **错误绝不外抛**:注入组装、agent 创建、工具管线里的异常都可能毁掉无关请求。每个 hook 都包住,最坏降级成空注入 / `return []` / 一条 warning。
83
+ 9. **通知只在"这个会话还没被告知过"时发**,唯一实现点是 `MemoryRuntime.notifyAgent()`。
84
+ 10. **面板不另做一套渲染**:「原文」和「注入效果」都走 `runtime.fileText()`,跟 `memory_read` / `renderInjection` 同源。
85
+
86
+ ## 5. 提示词注入
87
+
88
+ `ctx.systemPrompt.section({ name:'memory', order, text })`,`order` 取宿主 `getSectionOrder('DEPLOYMENT_PERSONA_PREFIX') - 1`——排在 harness identity 之后、persona 之前。**不开放配置**:记忆要先于任务描述被读到;做成选项只会被人调到工具说明中间去。
89
+
90
+ 格式:一句 `The following is your own long-term memory.`(在标签外)+ `<memory>` 包住若干 `---` 分隔的文件块。文件头带 `file_name` / `desc` / `scope`;每个标题下是 `- Keyword:`、`- Update at:` 行,然后正文。
91
+
92
+ - 那句英文是为了解决人称错位:记忆用「我」的口吻写,提示词其余部分是英文第二人称。
93
+ - `format` 不注入(只进 `memory_edit` 的描述)。
94
+ - `inject_ignore` 生效:`t3` 忽略整棵三级子树,`desc` 忽略所有正文,`t2.desc` 只忽略二级的正文。唯一判断点是 `render.js` 的 `isSuppressed()`。
95
+ - 顺序:全部全局文件 → 全部工作区文件,段内按 `localeCompare(…, 'zh')`;`fileOrder` 把列出的按给定先后排在前面。`overview()` 与 `injectedDocuments()` 共用 `orderByConfig()`,所以面板列表的顺序**就是**注入顺序。
96
+ - 一个文件都没有时返回空串,不注入空块。
97
+
98
+ ## 6. 会话快照(三份,一个文件)
99
+
100
+ `~/.dsh/dsh-memory/state/sessions/<sessionId>.json`,`version: 4`:
101
+
102
+ ```json
103
+ {
104
+ "version": 4,
105
+ "updatedAt": "…", // 模型最后一条消息的结束时间(agent/turn-stopping 时写)
106
+ "injected": { "text": "…", "logKeys": ["<dir>\u0000<fileName>"] },
107
+ "tools": "…", // 冻结的 memory_edit 描述全文
108
+ "files": { "<dir>\u0000<fileName>": { "hash": "…", "sections": { "<path>": "…" } } }
109
+ }
110
+ ```
111
+
112
+ | 字段 | 作用 | 何时变 |
113
+ | --- | --- | --- |
114
+ | `injected` | 提示词里那段记忆就注入这份文本 | 冻结,只有过期重建时重算 |
115
+ | `tools` | `memory_edit` 的工具描述 | 冻结,同上 |
116
+ | `files` | 检测"哪几个标题变了" | 每次文件更新都推进 |
117
+
118
+ - **为什么冻结前两份**:它们都在请求最前面,改一个字符就切断供应商的前缀缓存。文件一改就重渲染,等于每次编辑都把该会话的缓存作废——而它本来已经看过那批文件了。变化改走通知。
119
+ - **`tools` 怎么按会话冻结**:全局注册的 `memory_edit` 描述是 getter,每次装配重算(列着每个文件的 format)。`index.js` 的 `freezeEditTool()` 在 `agent/created` 里用 `agent.ctx.tools.register()` 注册**同名副本**,描述换成冻结文本——宿主工具层按"自己的层覆盖全局层"解析同名工具。子 agent 不做这件事(它的 `memory_edit` 刚被 `tools.restrict` 拒掉,再注册会绕回去)。
120
+ - **`files` 里为什么收日志**:注入冻结后,别的会话往当天日志追加的内容不会再从实时注入看到,通知是唯一出路。冻结那刻纳入的日志 key 存在 `injected.logKeys`,所以**改注入天数不影响旧会话**,只有重建的新会话遵守新配置。
121
+ - **`updatedAt` 是模型最后一条消息的结束时间**,不是写入时间:一直在说话的会话必须保持同一前缀,只有真闲下来才算数。`snapshotTtlMinutes`(默认 60,`0` 关闭)控制窗口;启动清一次,之后按窗口一半周期清(夹在 1–30 分钟)。`sectionText()` 里也判一次,闲过头的会话丢掉冻结部分重渲染。
122
+ - **分叉复制父快照**:`agent.session.header.parentSession` 指向父会话,`copySnapshot()` 拷整份文件。父快照可能已被清掉,或者它自己就过期了——那就当没有基线,按当前状态重建。
123
+ - **版本不符 / 文件损坏 / 缺失**一律当"没有基线":会话直接采纳当前状态,不回放历史。新会话同理——否则它一开口就会收到一条"所有文件都是新增"的通知。
124
+ - **`sections` 的键是完整标题路径**,所以改父标题会让整棵子树的键都变。`diffSections()` 先做一次改名识别:一对"消失"和"出现"的路径如果等长、只差一处、且内容指纹相同(或子树集合完全一致),合并成一条 `renamed`。这一识别只此一处。
125
+ - `sections` 看不见"只改 `meta_data`"或"同级重排",那时 `diffEntries()` 退回文件级 `{ path: [], kind: 'file' }`——只要整份文件 `hash` 变了就一定有话说。整份文件被删时 `describeChanges()` 反向遍历基线里多出来的键,报 `deleted`。
126
+ - **`writeSnapshot` 兼容"只传 files"**(旧调用与测试用),内部包成 `{ files }`;`injected` / `tools` 只在有值时写入,避免推进基线时把冻结部分冲掉。
127
+
128
+ ## 7. 投递与通知
129
+
130
+ - `agent.inject(message)` 塞进收件箱,**不唤醒** agent。消息 `source` 带 `{ kind: 'memory', snapshot }`。
131
+ - 入队即记为"已推进"(`delivered` + `inFlight`),闲置很久的会话不会重复收同一批变化。
132
+ - 被丢弃(`agent/inbox/discarded`)→ `release()` 把基线退回去,下次还会通知。
133
+ - 被消费(`agent/inbox/claimed`)→ `claim()` 落实快照并用新状态重算。
134
+ - 文案:`<memory_update>` + 一行说明 + 每条变化一行;五种说法 `added` / `updated` / `removed` / `renamed`(带原路径)/ `file`(元数据、更新时间或同级顺序变了)/ `deleted`(整份文件没了)。列表为空时**什么都不发**(`renderNotice()` 返回 `undefined`)。
135
+ - 文件解析失败另发 `<memory_broken>`,同一签名只发一次;坏文件不参与"被删了"的比对。
136
+
137
+ ## 8. 日志
138
+
139
+ 一天一个文件,`log/<yyyy-mm-dd>.yaml`,最多 2 级(`LOG_MAX_DEPTH`)。一级标题固定是当天日期(= `file_name`),二级是 `HH:MM 摘要`,条目的 `desc` 就是不限长度的细节。摘要写在标题里,是为了"一次 `memory_edit` 记完一件事",不用先写摘要再寻址到 `detail`。
140
+
141
+ - `meta_data` 只有 `file_name` 和按 `scope` 写死的 `desc`(`LOG_DESCS`);多一个键就解析失败。
142
+ - 写:`heading_path` 必须写全 `# 2026-10-27 > ## 12:35 摘要`,少一层多一层都报错;条目按时间插入;只能写当天;不能改名、不能删除。
143
+ - 读:`file_name` 是日期(`2026-10-26..2026-10-27` 是范围),`heading_path` 用 `12:00..14:00` 收窄;范围分隔符是 `..`,路径分隔符是 ` > `,混用会明确报错,不猜。非法范围**显式抛错**,不会静默把当天条目全丢掉。
144
+ - 同一时刻可以有多条条目(`19:20 去了` / `19:20 回来`),路径的键是完整标题不是时分;摘要不参与匹配。
145
+ - 当天文件在 `start()` 和每次 `attach()` 补建;零点 +5 秒切天,定时器 `unref()`。
146
+ - 注入(`injectLog`,默认关):每作用域一份文件头(`kind: log`、`desc`、`scope`),最新的那天带正文,更早的只剩标题;预算四个数见 `CONFIG_FIELDS`;某天没条目就不占预算。`renderLog()` 只渲染一天,`renderInjection()` 负责分组加头。
147
+
148
+ ## 9. 存储层
149
+
150
+ - 直接 `node:fs`,不走 `ctx.fs`:记忆是插件私有数据,走组合文件系统会触发沙箱策略、审批提示和 fs 观察事件。
151
+ - 缓存按「目录 → 磁盘相对路径」分层(文件可能被外部改名,模型的寻址键是 `meta_data.file_name`)。
152
+ - 一个目录下挂 `*.yaml` 和 `log/*.yaml` 两层,只扫一层;目录之间、目录内文件之间都并发加载。
153
+ - `ensureDirectory()` 首次加载 + 起 watcher。**在飞的加载记在 `loading` 表**,同目录并发调用共享同一次加载——先塞空缓存再慢慢填,会让第二个调用者看到"已加载"却拿到空列表,文件就像突然不见了。
154
+ - 写入串行化(每路径一条队列,写完自清),temp + rename 落盘;`writeAtomic` 在 Windows 上对 `EPERM`/`EBUSY`/`EACCES` 退避重试,temp 名带进程 id + 时间 + 递增计数(同一毫秒并发写同一路径会撞名)。
155
+ - 乐观锁读的是**原路径**(`baseRelative`):面板改名时新路径上还没文件,只按新路径算哈希会得到 `undefined`,锁就被跳过了。文件在中间被别人删掉是**报错**而不是静默重建;改名后旧文件删不掉也报错("请手动删掉,否则会同时存在两份")。
156
+ - 外部改动 → `reloadFile` → `onChange` → `runtime.handleExternalChange` → 给能看见该目录的会话算通知。
157
+
158
+ ## 10. 工具
159
+
160
+ 参数用 **compiled JSON Schema** 手写,不用作者 DSL(`ctx.tools.register()` 原样透传 `parameters`)。可用子集:`type / oneOf / properties / required / additionalProperties / items / enum / const` + `description / title / default / examples`。
161
+
162
+ `defineTool()` 统一提供:未知参数报错、缺必填报错、`exec.agent` 为空报错、`DocumentError` 转成人能读的文案。`memory_edit` / `memory_manage` 在子 agent 里被 `tools.restrict({ deny })` 关掉。
163
+
164
+ `memory_edit` 的语义(`applyEdit`):
165
+
166
+ - `heading_path` 指向不存在的标题 → 新建(父级必须已存在)。
167
+ - 只有 `new_string` → 追加到正文末尾;`old_string` + `new_string` → 正文里唯一匹配替换。
168
+ - `new_string` 为空串 → 删除该标题及整棵子树。
169
+ - `old_string` 是标题行(`## 旧标题`)→ 改名,`#` 个数必须等于层级,子标题保留。
170
+ - `keyword` 先删后加,同一行只留一份;每次编辑更新目标标题与所有祖先的 `update_at`。
171
+ - 编辑在 `cloneDocument` 副本上做,写盘失败不会让缓存显示一份没落盘的文本。
172
+ - 写前必须读过:`rememberRead()` 记下哈希,编辑时比对;`store.write()` 再比一次磁盘哈希。
173
+
174
+ ## 11. 面板与 chunk 契约
175
+
176
+ `client.js` 主包 ~2100 行(状态、effect、i18n、样式、跨区 helper),三个 `client.*.js` 是 chunk,由主包用 `require.async()` 按需加载。**文件名必须匹配宿主的 `client.*.js` 正则**,且与 `client.js` 同目录。
177
+
178
+ - chunk 里**只有**"拿 props 返回 vnode"的纯函数:没有 hooks、没有 state、不 import 彼此的辅助函数。样式常量、`t`、全部 state 与 setter、以及跨区共用的工厂(`placeholder` / `chips` / `spawnAdd` / `saveFile` …)都由主包当 props 传下去。状态只有一份,chunk 只负责画。
179
+ - 用 `useState` + `useEffect` 加载 chunk,不用 `React.lazy`:不依赖宿主有没有包 Suspense 边界。没加载完之前右栏是 skeleton、左栏和设置表单显示"加载中"。
180
+ - 面板自己定高:`ResizeObserver`(面板 + 滚动宿主)+ 宿主 DOM 的 `MutationObserver` + `window.resize` 触发,`requestAnimationFrame` 合帧。**别改回轮询**。
181
+ - 控件一律用宿主 `@deepseek-ai/dsh-client-ui-primitives` 的(`Button` `Input` `InputNumber` `SegmentedControl` `Switch` `Tag` `InlineAlert` `MarkdownText` `Icon*`);颜色一律用主题别名(`--dsw-alias-*`),不写死十六进制。图标不透传 `style`,颜色交给外层元素。
182
+ - 不用 `window.prompt` / `window.confirm`(Electron 里前者不支持、后者同步阻塞会让窗口收不到指针事件),新建/删除都在栏内展开。
183
+ - 保存只替换**刚保存的那一半**草稿,另一半(还没保存的正文或描述)留在原处——把整份草稿重建成磁盘状态等于抹掉用户正在写的东西。
184
+ - 保存按钮用 `inFlight` ref 挡重复点击:回车和按钮能在同一次渲染里各触发一次,`busy` 那时还是 `false`。
185
+ - 切作用域 / 切货架时清空选中项**连同草稿**(待删标题、待建卡片、待加关键词),否则在 A 上标了要删的标题、切到 B 一保存就会把 A 的路径发出去。
186
+
187
+ ## 12. 设置路由
188
+
189
+ ```
190
+ GET /plugins/dsh-memory/settings?session=<id>
191
+ → { fields, settings, scopes, injectIgnoreOptions, injectIgnoreLevels }
192
+ GET …&scope=Global&file=<名>&kind=log
193
+ → 上面那份 + { file: { fileName, scope, kind, source, text, injected, meta, tree } }
194
+ POST … ← { logSummaryMaxChars: 400 } 改配置
195
+ POST …?session=<id> ← { file: { scope, fileName, kind, newFileName?, desc?, format?, injectIgnore?, nodes, newNodes?, removed? } }
196
+ POST …?session=<id> ← { create: { scope, kind, fileName } }
197
+ POST …?session=<id> ← { remove: { scope, kind, fileName } }
198
+ ```
199
+
200
+ - POST 带 `file` 走 `runtime.patchFile()`(落盘前用读到的哈希做乐观锁);带 `create` / `remove` 走 `createFile()` / `deleteFile()`。
201
+ - `source` 是磁盘原文,`text` 是 `memory_read` 的返回,`injected` 是提示词里的形状,`meta` + `tree` 是画卡片的结构(浏览器不解析 YAML)。
202
+ - 设置写回走 `saveConfigValue()`:按 `key:` 逐行改,注释和其他键原样保留(不 parse 再序列化)。
203
+ - 请求体坏了、字段不对、方法不对,路由自己回 JSON 错误(400/405/500),不往上抛。
204
+ - 没有 Web server 的 profile(headless)就没有这条路由,不算失败。
205
+
206
+ ## 13. 配置
207
+
208
+ `lib/config-schema.js` 的 `CONFIG_FIELDS` 是**唯一事实来源**,同时喂给三处:`index.js` 的 Schemastery `Config`、设置路由的校验、`client.js` 的控件。加一个配置项只有两步——`CONFIG_FIELDS` 加一条,`client.js` 的 `DICTIONARIES` 补 `field.<key>` / `hint.<key>` 两句文案。
209
+
210
+ 优先级:`config.yaml` > profile 插件配置 > 内置默认值。合并前只挑字段表认识的键(手写的旧键不会把整份配置带崩),合并后再用同一份 schema 校验一次,不合法就 warning + 默认值。
211
+
212
+ **目录不可配置**:`resolveLayout()` 只读环境(`DSH_HOME`,没有就是 `~/.dsh`),Global 在 `<home>/dsh-memory/.memory`,工作区在 `<workspace>/.memory`。早期这几个是可配置的,删掉是因为一个写错的路径会让插件安静地读到一份空记忆。要换位置就改 `DSH_HOME`。
213
+
214
+ ## 14. 扩展点
215
+
216
+ - **加标题层级**:改 `lib/model.js` 的 `LEVELS`(`MAX_DEPTH`、`RESERVED_KEYS`、`inject_ignore` 合法值全跟着走;`renderNode` 用 `depth` 递归,不用改)。
217
+ - **加标题字段**(比如 `tag`):`ASPECTS` 加一项 → `model.js` 读取校验 → `document.js` 读写 → `render.js` 一段渲染。`inject_ignore` 合法值自动扩展。
218
+ - **换存储**:只动 `store.js`(含 `cache.js` / `watch.js` / `writer.js`)和 `paths.js`。
219
+ - **换通知策略**:`runtime.js` 的通知都汇到 `notifyAgent()`,比对在 `describeChanges()`,文案在 `notice.js`。
220
+ - **加工具**:`defineTool()` 已含参数校验、agent 检查、错误包装、`presentCall`,新工具只写 `name/description/properties/run/present`。
221
+ - **改日志形状**:`log.js` 的 `LOG_MAX_DEPTH` / `parseLogEntryTitle` / `assertLogTitle` 是全部形状规则;`isLogDate()` 是唯一日期判定点,`parseLogDateQuery()` 是唯一日期查询解析点。
222
+
223
+ 规划中但没做的三件事(用户已在 [README](./README.md#近期目标) 里定了优先级,别擅自提前实现):
224
+
225
+ 1. **关键词召回** —— 它和不变量 2(前缀按会话冻结)**天然冲突**:召回结果随话题变,注入文本一改缓存全废。要做得在会话开始 / 快照重建那一刻召一次、结果跟快照一起冻结,不是每轮召回。落地前先把"省下的 token vs 打掉缓存的钱"算清楚。
226
+ 2. **人设预设** —— 会话级前缀,走同一套冻结。难点是接入口:新建会话是宿主动作,插件目前只有 `conversation.view` 一个落点。
227
+ 3. **定时/定频注入** —— 倾向追加到对话(像通知那样不唤醒),不要改系统提示词,否则又是在动前缀。
228
+
229
+ ## 15. 约定与坑
230
+
231
+ - **改文本文件只能用 read / edit / write 工具。** 别用 PowerShell 的 `Get-Content -Raw | Set-Content -Encoding UTF8`:它会把 UTF-8 当 GBK 解码,整份文件变乱码(本项目真被这个毁过一次)。用 Node 脚本做行区间手术是安全的(`readFileSync(...,'utf8')` → `writeFileSync(...,'utf8')`),但仍要跑 `node --check` 并复读。
232
+ - **别给"测试正在等"的定时器加 `unref()`。** `unref()` 让事件循环不再等它,顶层 `await` 就永远不 settle,Node 判定进程无事可做直接退出(`test/store.test.mjs` 的文件监听用例真被这个打挂过)。`watch.js` 的防抖定时器**不能** unref;`runtime.js` 的午夜切天与快照清扫不参与测试等待,可以 unref。
233
+ - 仓库没有 `.git`,所以没有别的副本;`.backup/` 里是几个历史快照。
234
+ - 中文文案统一用中文全角标点;报错要告诉模型"哪一份、哪个标题、为什么",不要只说"失败"。
235
+ - 新增行为优先补测试(五份 suite 是唯一自动验证);`test/live.mjs` 只在真实宿主里跑。
236
+ - `locale/zh.json` / `locale/en.json` 是插件的元信息(面板外),面板内的文案在 `client.js` 的 `DICTIONARIES`。
237
+
238
+ ## 16. 文档
239
+
240
+ - [README.md](./README.md) —— 用户文档(安装、配置、面板、文件格式)。
241
+ - `docs/` —— 落盘与返回形状的参照,不参与运行;形状变了要顺手改,测试不替它们兜底:
242
+ [记忆文件格式](docs/记忆文件格式.md)、[日志文件格式](docs/日志文件格式.md)、
243
+ [通用返回格式](docs/通用返回格式.md)、[日志返回格式](docs/日志返回格式.md)。
package/README.md CHANGED
@@ -1,3 +1,204 @@
1
- # Temporary Holding Version
1
+ # dsh-memory
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 给 [DeepSeek Harness](https://github.com/deepseek-ai) 用的跨会话长期记忆插件。
4
+
5
+ 记忆就是磁盘上的 YAML 文件,按标题树组织。插件把这些文件注入提示词,给模型三个工具去读写,并在别的会话改动之后给本会话发一条**不唤醒**的变更通知。另有一份**日志**(短期记忆):一天一个文件,一条一句话,用来记"今天发生了什么"。
6
+
7
+ - 记忆在会话之间共享,重启、换窗口、换工作区都还在。
8
+ - 模型改记忆前必须先读,写的时候校验版本,不会覆盖掉别人的改动。
9
+ - 别的会话动了记忆,这边只收到"哪几个标题变了",要细节时再自己读。
10
+ - 注入的文本和 `memory_edit` 的工具描述**按会话冻结**:会话开着的时候记忆文件再怎么变,这个会话拼出来的提示词前缀都不动,供应商的前缀缓存才能一直命中;变化走通知。闲过一个保留窗口(默认 60 分钟)才算另起一段,重新生成。
11
+
12
+ ## 定位
13
+
14
+ 主要给**聊天、扮演、轻量编程**当跨会话长期记忆用。它有两个有意为之的取舍:
15
+
16
+ **没有自动召回。** 每次组装提示词就把记忆文件整份注入,不检索、不算相关性、不排序。原因有两条:一是没找到足够好的召回方案,召回错了比不召回更糟,模型会被一段"看起来相关其实是别的场景"的记忆带偏;二是记忆存量本来就不大,塞得进提示词就没有先筛一遍的必要。
17
+
18
+ 以后也许会加**基于关键词的轻量召回**([近期目标](#近期目标)第一条),但那只是存量变大之后的兜底,不改变"整份注入"这条主路径。
19
+
20
+ **真正想做的是可自定义的记忆结构。** 插件不规定你该建哪些文件、该有哪些标题:`file_name`、`desc`、`format`、标题树、`inject_ignore` 全都写在文件自己身上,所以调整结构不用改插件、也不用动配置。
21
+
22
+ 几种常见的搭法:
23
+
24
+ | 想记什么 | 大概长这样 |
25
+ | --- | --- |
26
+ | 长短期记忆 | 一个 `长期记忆` 存稳定的事实和偏好;当天的事先记日志,隔几天再摘要进长期 |
27
+ | 行为准则 | 一个 `行为准则`,一级标题就是场景(`回答风格`、`拒绝什么`、`扮演谁`),正文写具体要求 |
28
+ | 角色设定 | 一个 `角色`,或每个角色一份文件;`format` 里写清"只记这个角色说过做过什么,别混进别的角色" |
29
+ | 学习感悟 | 一个 `感悟`,一条一个二级标题,`keyword` 打标签,以后按标签翻 |
30
+ | 备忘录 | 一个 `待办`,`inject_ignore` 跳过 `update_at` 和 `keyword`,只留正文 |
31
+
32
+ 有个经验:**一个文件当一份用途**。因为注入顺序(`fileOrder`,面板里拖动)和 `inject_ignore` 都是按文件走的——"每轮都必须看到"的准则单独一个文件放在最前,"偶尔翻一眼"的备忘录另起一个,互不干扰。同一个文件里再按标题树分章节。
33
+
34
+ ---
35
+
36
+ ## 安装
37
+
38
+ ```bash
39
+ npm install @huai_xin/dsh-memory
40
+ ```
41
+
42
+ 插件以 bundle 形式挂到 profile 上。装好之后,在 profile 的配置里加载它的 `cordis.patch.yml`:
43
+
44
+ ```yaml
45
+ # cordis.patch.yml —— 只挂载插件,不写配置值
46
+ - insert:
47
+ - id: memory
48
+ name: '@huai_xin/dsh-memory'
49
+ ```
50
+
51
+ 想跟着仓库源码走,就把 `node_modules` 里那份换成指到本地的链接(`npm install ../dsh-memory` 或 `link:` 依赖都行),改动不用重新发版。
52
+
53
+ 依赖只有 `yaml`。
54
+
55
+ ## 配置
56
+
57
+ 配置写在插件的数据目录里,和它配置的东西挨着:
58
+
59
+ ```
60
+ ~/.dsh/dsh-memory/config.yaml # 配置,在面板里改当场生效;手改文件要重启 Harness
61
+ ~/.dsh/dsh-memory/.memory/*.yaml # Global 记忆文件
62
+ ~/.dsh/dsh-memory/state/sessions/ # 会话快照
63
+ <workspace>/.memory/*.yaml # Workspace 记忆文件
64
+ ```
65
+
66
+ 优先级:`config.yaml` > profile 里的插件配置 > 内置默认值。文件不存在就用默认值,YAML 写坏了整份忽略并打一条 warning。
67
+
68
+ | 键 | 默认 | 说明 |
69
+ | --- | --- | --- |
70
+ | `snapshotTtlMinutes` | `60` | 会话快照保留分钟数;闲这么久就丢掉,下次发消息重新生成。前缀缓存一般 5–60 分钟,按供应商的规则调;`0` 关闭清理 |
71
+ | `logSummaryMaxChars` | `200` | 一条日志摘要(写在标题里)的字数上限 |
72
+ | `injectLog` | `false` | 可选:把最近几天的日志也放进提示词 |
73
+ | `logInjectEntries` | `20` | 注入时每天最多带多少条日志 |
74
+ | `logInjectGlobalDays` | `3` | 全局日志带最近几天 |
75
+ | `logInjectGlobalDetailDays` | `1` | 其中最新几天带正文,其余只剩标题 |
76
+ | `logInjectWorkspaceDays` | `2` | 工作区日志带最近几天 |
77
+ | `logInjectWorkspaceDetailDays` | `1` | 其中最新几天带正文,其余只剩标题 |
78
+ | `fileOrder` | `[]` | 注入时记忆文件的先后;列出的按给的顺序在前,没列出的按文件名跟在后面。只影响注入。不在设置页里填——在面板左侧拖动文件即可 |
79
+
80
+ **目录不可配置**:Global 记忆固定在 `<DSH_HOME 或 ~/.dsh>/dsh-memory/.memory`,工作区记忆固定在 `<workspace>/.memory`,想换位置就改 `DSH_HOME`。这样一个写错的路径不会让插件安静地读到别处去。
81
+
82
+ ## 面板
83
+
84
+ 对话区顶部标签页里点「记忆」,里面有「全局」「工作区」「设置」三个标签。
85
+
86
+ - **前两个是浏览器**:左边一列是该作用域现有的记忆文件(顶上分「记忆文件」和「日志」两个货架),点一个,右边分成「原文」和「注入效果」两栏。
87
+ - **「原文」把文件画成卡片**,不是丢一整段 YAML 给人看:顶上一张元数据卡(文件名、描述、更新格式、不注入的值),下面是标题树,一级标题一张卡片,卡里是「更新时间 / 关键词」、正文、以及缩进排开的子标题。卡片标题可以点开收起。
88
+ - **卡片上的字就是编辑器**:描述、更新格式、每个标题的正文和关键词都能直接改,改完按卡片右上角的「保存」落盘(没改过按钮是灰的,`update_at` 由插件维护、不给改)。元数据卡和内容卡各有一个「保存」,只保存各自那一半。
89
+ - **「不注入的值」**后面跟一个 `+`,展开一个两层菜单(顶层 / 一级标题 / 二级标题 … × 更新时间 / 关键词 / 正文),点一个加一个。关键词是自由词,`+` 点开直接打字回车。
90
+ - **「注入效果」**是它出现在提示词里的样子,右上角能在原始文本和预览(Markdown 渲染)之间切。
91
+ - 左边每一行右边的 `×` 删整份文件(删之前在该行下面展开一条确认条);记忆货架顶上还有一个虚线按钮新建文件。都在栏内完成,不弹系统对话框——新建失败时输入框不关、名字不清,改个名字再回车即可。日志货架只有删除,没有新建:日志的一天由写入产生。
92
+ - 按住一行拖到别的位置,就改了注入顺序(列表里的先后**就是**注入的先后;日志那一栏按日期排,不参与)。
93
+ - **「工作区」跟着当前对话走**:换一个会话,它显示的就是那个会话的工作区。
94
+ - **「设置」**把上面每个键做成一栏,填好保存即可;写回同一个 `config.yaml`(只改那几行,注释不动),当场生效,不用重启。
95
+
96
+ ## 数据放在哪
97
+
98
+ ```
99
+ ~/.dsh/dsh-memory/.memory/*.yaml Global 记忆文件(所有工作区可见)
100
+ ~/.dsh/dsh-memory/.memory/log/*.yaml Global 日志(一天一个)
101
+ ~/.dsh/dsh-memory/state/sessions/*.json 每个会话一份快照
102
+ <workspace>/.memory/*.yaml Workspace 记忆文件(只对当前工作区可见)
103
+ <workspace>/.memory/log/*.yaml Workspace 日志
104
+ ```
105
+
106
+ 目录在第一次用到时自动创建。记忆文件是普通 YAML,可以直接用编辑器改——改完插件会重新加载并通知正在跑的会话。
107
+
108
+ ## 记忆文件长什么样
109
+
110
+ ```yaml
111
+ meta_data:
112
+ file_name: 我的记忆
113
+ desc: 记录我和用户之间的一些回忆,以及我想要记住的事
114
+ format: |
115
+ 写这个文件放什么、什么时候该更新、更新成什么样。
116
+ 这段只出现在 memory_edit 的工具描述里,不进提示词。
117
+ scope: Global
118
+ inject_ignore: # 不写就是全都注入;下面两项是新建时预填的默认省法
119
+ - update_at
120
+ - keyword
121
+
122
+ data:
123
+ 我们经历过的一些事:
124
+ update_at: 2026-10-27T03:18:36+08:00
125
+ desc: 一级标题的正文
126
+ keyword: [回忆]
127
+ 我们之间的互动:
128
+ desc: 二级标题的正文
129
+ ```
130
+
131
+ 字段说明:
132
+
133
+ - `file_name` — 模型用它寻址,同一会话能看到的目录里必须唯一。
134
+ - `desc` — 这个文件放什么。**会注入提示词**。
135
+ - `format` — 怎么写这个文件的说明。**不注入提示词**,只进 `memory_edit` 的工具描述。
136
+ - `scope` — `Global` 或 `Workspace`。
137
+ - `inject_ignore` — 注入时跳过哪些内容。可选 `t1` `t2` `t3` `t4`、`desc` `keyword` `update_at`,以及 `t2.desc` 这种组合。新建文件不给这项时,落的默认就是 `update_at`、`keyword` 两条;想注入回来就把对应那行删掉,写 `[]` 就是全都注入。已有文件里不写就是全都注入。读取不受它影响。跳过标题时会连同子标题一起跳过,比如跳过 `t1` 就是跳过一级标题,以及其下所有内容,也就是不注入这个文件。
138
+ - `update_at` — 工具自动写,不要手填。
139
+
140
+ 正文**开头**不能是 `#`(会被读成标题);`#` 出现在正文中间或后面几行都可以。标题最多 4 级,同级不能重名。
141
+
142
+ 落盘的完整形状见 [docs/记忆文件格式.md](docs/记忆文件格式.md)。
143
+
144
+ ## 日志长什么样
145
+
146
+ 一天一个文件,放在 `log/` 下,文件名就是日期。`meta_data` 只有 `file_name` 和写死的 `desc`,其余规则是固定的,写在 `memory_edit` 的工具描述里:
147
+
148
+ ```yaml
149
+ meta_data:
150
+ file_name: 2026-10-27
151
+ desc: 用来记录和“他”之间的互动 # 全局;工作区是「用来记录项目相关操作」
152
+
153
+ data:
154
+ 2026-10-27: # 一级标题固定是当天日期,和 file_name 相同
155
+ update_at: 2026-10-27T12:35:10+08:00
156
+ 12:35 讨论了日志设计: # 二级标题是「时分 + 摘要」
157
+ update_at: 2026-10-27T12:35:10+08:00
158
+ desc: 用户否掉了自动生成时间标题,说让模型自己写;正文就是不限长度的细节。
159
+ ```
160
+
161
+ - `desc` 由插件按 `scope` 写死,模型改不了(`memory_manage` 拒绝改日志的元数据)。
162
+ - 一级标题只有一个,固定是当天日期;写的时候 `heading_path` 必须写全 `# yyyy-mm-dd > ## hh:mm 摘要` 这一种形状。
163
+ - 二级标题是 `HH:MM 摘要`(本地时间,`09:05` 不是 `9-5`),摘要受 `logSummaryMaxChars` 限制,条目的 `desc` 就是不限长度的细节。
164
+ - 条目按时间排序,插入时自动排好。
165
+ - 正文开头不能是 `#`:标题只由 `heading_path` 决定,日志没法从正文里新建或改名;`#` 出现在中间是可以的。
166
+ - 当天文件在启动和零点自动创建;只写得了当天,不能改名、不能删除。
167
+ - 关键词挂在二级标题上(一级标题没有),可以和写正文放在同一次调用里。
168
+ - 日志默认不进提示词,要按日期主动读;`injectLog` 打开后按上面的天数规则带最近几天。
169
+ - 落盘的完整形状见 [docs/日志文件格式.md](docs/日志文件格式.md),读出来的样子见 [docs/日志返回格式.md](docs/日志返回格式.md)。
170
+
171
+ ## 三个工具
172
+
173
+ `memory_read` — 读。`scope` 决定查哪个目录(`Global` 或 `Workspace`):传了 `file_name` 就必须给 `scope`;只看清单时可以不给,给了就只列那一层,不给就列全部。省略 `file_name` 返回文件清单,省略 `heading_path` 返回整个文件。读取结果不受 `inject_ignore` 影响。`file_name` 写成日期就是读日志:`2026-10-27` 读一天,`2026-10-26..2026-10-27` 读连续几天;`heading_path` 用 `12:00..14:00` 在选中的每一天里按时间收窄。范围用 `..`,路径用 ` > `,两种符号不混用。读出来的形状见 [docs/通用返回格式.md](docs/通用返回格式.md) 和 [docs/日志返回格式.md](docs/日志返回格式.md)。
174
+
175
+ `memory_edit` — 改正文、关键词;改标题名;删标题子树。`scope` 必填,指定改全局那份还是工作区那份。**调用前必须先 `memory_read`**,插件会校验读到的版本。`file_name` 写成日期就是写日志:`heading_path` 必须写成 `# 2026-10-27 > ## 12:35 讨论了日志设计`,`new_string` 就是这条的正文;关键词也挂在这一级上。返回一行摘要,开头带 `[Global]` 或 `[Workspace]`,例如 `[Global] 已更新 我的记忆 > 沟通方式`。
176
+
177
+ `memory_manage` — 新建记忆文件,或改文件的 `desc` / `format` / `inject_ignore`。返回 `[Global] 已保存记忆文件 <文件名>`。
178
+
179
+ 子 agent 只能 `memory_read`,不能改记忆。
180
+
181
+ ## 近期目标
182
+
183
+ 按优先级从高到低。都还没有做,写在这儿是为了先把形状和难点定下来。
184
+
185
+ **1. 基于关键词的自动召回。**
186
+ 现在是把记忆文件整份注入,存量真的变大就塞不下了。目标是用关键词先筛一遍,只注入相关的那部分——`keyword` 字段、标题里的词、`desc` 里的词都可以作为匹配依据。
187
+
188
+ 难点不在匹配,在**它和前缀冻结天然打架**:召回结果随话题变化,一召回注入文本就变,前缀缓存立刻失效。所以真做的时候大概率是「会话开始(或快照重建)时按当时的话题召一次,结果跟快照一起冻结」,而不是每轮重新召回。每轮召回省下的 token 与打掉缓存多花的钱要先算清楚,可能并不划算。
189
+
190
+ **2. 自定义人设预设。**
191
+ 一份份可切换的长文本预设(人设卡、破甲人设这类),新建会话时挑一份注入提示词。它属于会话级前缀,定了就不再变,和冻结机制是同一套逻辑。
192
+
193
+ 待定的是**接入口**:新建会话是宿主的动作,插件目前唯一的面板落点是 `conversation.view`,要挂到"新建会话"那一步得另找位置。
194
+
195
+ **3. 固定频率注入的自定义消息。**
196
+ 按条数或按时间间隔,往对话里固定插入一条内容:一句话,或某个记忆文件的全部内容。可以做定时提醒,也可以做"心情随时间变化"这类效果。
197
+
198
+ 实现上倾向**追加到对话里**(像变更通知那样发一条、不唤醒),而不是改系统提示词——改提示词就是改前缀,而且一条提醒本来也不该常驻。
199
+
200
+ ## 文档
201
+
202
+ `docs/` 下是形状参照,不参与运行:[记忆文件格式](docs/记忆文件格式.md)、[日志文件格式](docs/日志文件格式.md)、[通用返回格式](docs/通用返回格式.md)、[日志返回格式](docs/日志返回格式.md)。
203
+
204
+ 开发、架构与改动指南见 [AGENTS.md](./AGENTS.md)。