dsh-memory-engine 0.7.0-beta.4 → 0.7.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 阿木
3
+ Copyright (c) 2026 dsh-memory-engine contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -2,19 +2,32 @@
2
2
 
3
3
  DSH 插件的本地记忆引擎,带神经网络可视化。它让 agent 保存、召回和管理跨会话记忆,也能把你已有的记忆文件导进来。
4
4
 
5
- 当前版本:0.7.0-beta.4。Node ≥22,零新增依赖。新增中心角色卡、确定性球壳布局和可配置慢速自转;保留记忆管理和详情卡片,Canvas 与离线 HTML 继续共用唯一绘制实现。
5
+ 当前版本:0.7.0。Node ≥22,零新增依赖。自动记忆默认关闭,由用户在图谱头部或设置页手动打开;开启后每轮对话会额外调用一次模型,可能产生费用。自动导入默认只收用户消息,支持流程话过滤、规则分类、错误原因和一次性重扫。
6
6
 
7
- ## 0.7.0 分步交付
7
+ ## 从 0.6.1 升级到 0.7.0 的变化
8
8
 
9
- | 阶段 | 内容 | 独立验收 |
9
+ - 不再占用 18886 端口,改用 DSH 宿主的同源路由。
10
+ - 图谱从 iframe 改为直接绘制的 Canvas,保留旋转、缩放和节点详情。
11
+ - 新增中心角色卡、球体布局、记忆管理面板、自动记忆和自动导入。
12
+ - 记忆数据格式不变,可以直接升级,无需迁移命令。已有配置明确设置的启用状态会保留;未配置自动记忆时默认关闭。
13
+
14
+ ## 已知限制
15
+
16
+ - 检索按字面匹配,不理解同义词,例如查“自行车”不能自动匹配只写“单车”的记忆。
17
+ - 单机使用,没有跨设备同步。
18
+ - 数据保存为明文 JSON,没有加密。
19
+ - 只支持 DSH 宿主。
20
+ - 自动记忆开启后会产生额外模型调用费用,实际金额取决于模型和计费方案。
21
+
22
+ ## 0.7.0 功能
23
+
24
+ | 功能 | 内容 | 使用方式 |
10
25
  | --- | --- | --- |
11
- | 第 1 步 · 已完成 | 宿主路由、共享图谱计算、同源 iframe | 原独立端口无监听,按钮能打开图,JSON 接口返回当前记忆 |
12
- | 第 2 步 · 已完成 | React 内直接绘制 Canvas | 浮层无 iframe;旋转、缩放、选中可用,关闭后动画停止 |
13
- | 渲染合并 · 已完成 | canvas.js 是唯一绘制源码,界面构建与离线 HTML 共用 | 画面不变,构建幂等,未构建的源码改动会使测试失败 |
14
- | 点击回归 · 已完成 | 恢复右侧只读详情卡片 | 可见字段、切换、关闭、高亮和复制;长正文可滚动 |
15
- | 第 3 步 · 已完成 | 记忆列表、搜索、编辑、删除、导出和清空 | 分页、筛选、确认与错误提示;写操作先备份 |
16
- | 中心角色卡 · 本版完成 | 固定角色核心、分层球壳、慢速自转 | 角色卡全文与复制;拖拽暂停,松手 2 秒恢复;缺省退回原布局 |
17
- | 引导小修 · 已完成 | 空库和手动回看共用三步卡片 | 编号步骤、复制提示词、刷新和关闭卡片可用 |
26
+ | 原生图谱 | 同源路由、React Canvas,界面与离线 HTML 共用绘制源码 | 旋转、缩放、点击详情;离开页面停止动画 |
27
+ | 记忆管理 | 列表、搜索、编辑、删除、导出和清空 | 分页、筛选、确认与错误提示;写操作先备份 |
28
+ | 中心角色卡 | 固定角色核心、分层球壳、慢速自转 | 点击查看全文;拖拽暂停,松手 2 秒恢复 |
29
+ | 自动记忆开关 | 默认关闭,头部与设置页同步 | 首次开启提示成本;关闭即取消排队抽取并阻止迟到结果写库 |
30
+ | 使用引导 | 空库和手动回看共用三步卡片 | 复制提示词、刷新、知道了/Esc 关闭 |
18
31
 
19
32
  - [x] 引导回看:右上角「?」、中央卡片、复制提示词、知道了/Esc 关闭。
20
33
  - [x] 按钮开关:hideButton、showGuideOnEmpty;配置请求失败时保留按钮并警告。
@@ -32,18 +45,20 @@ DSH 插件的本地记忆引擎,带神经网络可视化。它让 agent 保存
32
45
  | 有 ctx.effect | 正常注册,随插件卸载一起清理 |
33
46
  | 没有 ctx.effect | 直接注册工具,功能不减少,日志留一条提示 |
34
47
  | 解析不到 @deepseek-ai/dsh-tools | 用内置直通 defineTool,工具照常可用 |
35
- | 没有 header.actions 插槽 | 图谱按钮退到 iews.actions(在「更多视图」菜单里) |
48
+ | 没有 header.actions 插槽 | 图谱按钮退到 `views.actions`(在「更多视图」菜单里) |
36
49
  | 完全没有 slots 服务 | 跳过按钮,记忆工具不受影响 |
37
50
 
38
51
  peerDependencies 已放宽为 >=0.1.0-rc.8 <0.3.0 并标为 optional——宿主自带这份 SDK,不该因为版本号不匹配而装不上。
39
52
 
40
53
  0.7.0 的 GUI 还要求宿主提供 `webServer.register({ kind, path, handler })`。服务端声明 `inject = ['tools', 'webServer']`,插件生命周期结束时注销路由。原有 `ctx.effect` 和 `defineTool` 兼容逻辑保留;直接调用初始化时若路由注册失败,会记录提示,已注册的工具仍可用。
41
54
 
55
+ 本机 0.2.1-rc.5 的 Cordis 把 `inject` 中的所有服务视为必需依赖,没有可选注入声明。因此 `llm` 和 `sessionQuery` 通过 `ctx.get()` 按需获取:缺少前者只停用自动抽取,缺少后者只跳过会话来源。把这两个名字加入必需注入数组会使整个插件在缺服务时无法启动。实际 `session/event` 回调签名为 `(session, event)`。
56
+
42
57
  ## 界面
43
58
 
44
59
  安装并启用插件后,完全退出 DSH 再打开。进入任意会话,头部第一行会出现 **🧠 记忆图谱** 按钮。
45
60
 
46
- 点击后全屏显示图谱,顶部有 **图谱 / 记忆 / 设置** 三个 tab,右上角 **关闭 ×** 退出。每次打开或从其他 tab 返回图谱都会读取当前记忆库,在内存中计算当前图谱,不写入离线 HTML。离开图谱 tab 时停止 Canvas 动画。
61
+ 点击后全屏显示图谱,顶部有 **图谱 / 记忆 / 设置** 三个 tab,**?** 左侧是 **○ 自动记忆 / ● 自动记忆** 开关,右上角 **关闭 ×** 退出。鼠标悬停可看当前状态和抽取上限。设置页也有同一个开关及成本说明,两处同步、刷新后保持状态。第一次开启会提示模型费用;保存失败恢复原状态,并显示红色原因。每次打开或从其他 tab 返回图谱都会读取当前记忆库,在内存中计算当前图谱,不写入离线 HTML。离开图谱 tab 时停止 Canvas 动画。
47
62
 
48
63
  **记忆 tab** 按正文或标题搜索(250ms 防抖),可筛选知识、印象、对话、事件、日记,按最近、最老、快忘了或连接最多排序。每页 50 条,底部显示过滤后的总数和「加载更多」。点一条展开全文,再点「编辑」修改正文、分类、重要度或相关人;保存失败保留草稿并显示服务端错误,取消不发送写请求。删除必须再次确认。
49
64
 
@@ -81,7 +96,7 @@ peerDependencies 已放宽为 >=0.1.0-rc.8 <0.3.0 并标为 optional——宿主
81
96
  ```json
82
97
  {
83
98
  "personaFile": "C:/path/to/role.md",
84
- "personaTitle": "阿鲸",
99
+ "personaTitle": "小夏",
85
100
  "spinSpeed": 0.35
86
101
  }
87
102
  ```
@@ -110,7 +125,8 @@ peerDependencies 已放宽为 >=0.1.0-rc.8 <0.3.0 并标为 optional——宿主
110
125
  | GET | `/memory-engine/api/map` | `{ nodes, links, review, stats, generatedAt, core, spinSpeed }`;配置角色卡时 nodes[0] 为 isCore,core 含 id/title/preview,否则为 null;stats 统计图谱节点 |
111
126
  | GET | `/memory-engine/api/stats` | 记忆总数、分类、衰减状态及存储位置 |
112
127
  | GET | `/memory-engine/api/onboard` | `{ empty, prompt, steps, dataDir }`,空库和手动帮助共用 |
113
- | GET | `/memory-engine/api/config` | 仅 `{ hideButton, showGuideOnEmpty, hasPersona, personaTitle, spinSpeed }`,不暴露正文或路径 |
128
+ | GET | `/memory-engine/api/config` | 保留既有字段,`autoMemory` 增加 `dailyCap`,另增加 `showAutoMemoryCostHint`;仍返回 `autoImport: { enabled, sourceCount }` 和 `lastError: { at, stage, message }`,不返回 raw、来源路径或模型配置 |
129
+ | PUT | `/memory-engine/api/config` | 合并合法白名单字段,返回公开配置和 `backup` 文件名 |
114
130
  | GET | `/memory-engine/api/list?q=&kind=&sort=&limit=&offset=` | `{ total, allTotal, limit, offset, items }`;包含留存、连接数和 60 字预览 |
115
131
  | GET | `/memory-engine/api/item/:id` | 完整条目、留存和连接数;不存在返回 404 |
116
132
  | PUT | `/memory-engine/api/item/:id` | 仅更新传入的 text/kind/heat/who;返回更新条目和 backup |
@@ -118,7 +134,11 @@ peerDependencies 已放宽为 >=0.1.0-rc.8 <0.3.0 并标为 optional——宿主
118
134
  | GET | `/memory-engine/api/export` | `{ total, items }`,条目原样导出 |
119
135
  | POST | `/memory-engine/api/clear` | 请求 `{ confirm: "DELETE" }`,返回 `{ removed, backup }` |
120
136
 
121
- JSON 成功返回 `{ "ok": true, "data": ... }`,失败返回 `{ "ok": false, "error": "说明" }`。本版仅给 map/config 增加角色卡字段,保留已有字段;stats/list/export 仍只统计实际记忆。接口仅接受本机 Host,严格限制路径,拒绝路径穿越;不允许的方法返回 405。只读接口不接收请求体,PUT 和 POST 只接收 JSON 对象,声明大小和实际分块累计大小都限制在 1MB。
137
+ JSON 成功返回 `{ "ok": true, "data": ... }`,失败返回 `{ "ok": false, "error": "说明" }`。既有接口返回结构保留;stats/list/export 仍只统计实际记忆。接口仅接受本机 Host,严格限制路径,拒绝路径穿越;不允许的方法返回 405。只读接口不接收请求体,PUT 和 POST 只接收 JSON 对象,声明大小和实际分块累计大小都限制在 1MB。
138
+
139
+ 配置 PUT 使用嵌套对象,例如 `{ "autoMemory": { "enabled": false } }`。只允许 `autoMemory.enabled`、`autoImport.enabled`、`autoMemory.maxPerTurn`(1~5 整数)、`autoMemory.dailyCap`(0~500 整数)、`spinSpeed`(0.05~1.5 数字)、`showGuideOnEmpty` 和 `hideButton`;所有启用/显示开关只接受布尔值。任何未知字段或类型错误会整次返回 400,不改任何字段。角色卡、来源路径、模型等不能通过此接口修改。
140
+
141
+ 配置写入前生成 `config.json.bak-<时间戳>`,同毫秒加序号,保留最近五份;先写临时文件再 rename。备份或写入失败保留原配置。首次成功启用时,服务端自行保存 `showAutoMemoryCostHint: false`,不向客户端额外开放这个字段的写权限。关闭会清空当前收集的对话、取消排队和进行中的抽取,重新开启时重置冷却和失败退避;自动导入独立运行,若要冻结所有自动入库,需同时关闭它。
122
142
 
123
143
  列表默认最近优先、每页 50 条,limit 上限 200;搜索为不区分大小写的包含匹配,kind 精确匹配。留存复用 `decay.retention()`,连接数复用全库 `scoreMemories()`,筛选不会改变连接数。编辑只允许 text(1~2000 字符且非全空白)、kind(1~20 字符)、heat(整数 1/2/3)、who(0~40 字符);不会改动 id、时间、强化次数或触发相似合并。
124
144
 
@@ -134,40 +154,60 @@ memory_map(out="D:\\memory-map.html")
134
154
 
135
155
  默认输出 `{DSH_HOME}/memory-engine/starmap.html`;未设置 DSH_HOME 时为 `~/.dsh/memory-engine/starmap.html`。`open` 默认 false,显式 true 才启动系统默认浏览器。
136
156
 
137
- ## 装完是空库(重要)
157
+ ## 自动记忆和自动导入
138
158
 
139
- 插件**不会自动读取任何东西**。装完那一刻记忆库是空的,图谱里一个神经元都没有。
159
+ 自动记忆默认关闭;在图谱头部「○ 自动记忆」或设置页可以一键打开,首次开启会显示成本提示。开启后每轮对话会多一次模型调用,异步抽取最多两条事实,不依赖 agent 调用工具。自动导入默认开启,在启动 8 秒后读取 DSH 已有会话,之后每小时续导;导入本身不调用模型。新装空库也会逐步获得历史记录。
140
160
 
141
- 它只会读写自己的目录:
161
+ 在现有 `config.json` 中合并:
142
162
 
143
- ```text
144
- {DSH_HOME}/memory-engine/
145
- memories.json 记忆库
146
- config.json 配置(可选)
147
- starmap.html 生成的图
163
+ ```json
164
+ {
165
+ "autoMemory": {
166
+ "enabled": true,
167
+ "provider": "deepseek-account",
168
+ "model": "deepseek-flash",
169
+ "maxPerTurn": 2,
170
+ "minChars": 12,
171
+ "dailyCap": 40,
172
+ "cooldownSec": 30,
173
+ "excludePatterns": ["密码", "身份证", "银行卡", "手机号", "验证码"]
174
+ },
175
+ "autoImport": {
176
+ "enabled": true,
177
+ "importAssistant": false,
178
+ "noisePatterns": [],
179
+ "sources": [{ "type": "dsh-sessions" }],
180
+ "intervalMinutes": 60,
181
+ "maxPerRun": 200
182
+ }
183
+ }
148
184
  ```
149
185
 
150
- 不会扫描硬盘、不会猜路径、不会去读你的聊天记录或其他机器人的数据。`DEFAULT_BRIDGE_ROOT`、`DEFAULT_DIARY_DIR` 默认都是空字符串——不配置就完全不读。
186
+ 未显式配置 `sources` 时,默认先导入 `dsh-sessions`,再追加已有 `bridgeRoot` 和 `diaryDir`。显式配置的数组会替换这个来源列表。路径来源支持 `bridge`、`diary`、`json`、`markdown`、`text`,填写绝对 `path`。只接受真实根路径,拒绝逃出目录的软链接;所有源文件只读。两项 `enabled` 可独立关闭,下轮任务会读取新配置。
151
187
 
152
- 记忆只有三个来源:
188
+ 自动抽取只看本轮 user/assistant 的文本块,最近最多 4000 字;排除工具、推理、寒暄和敏感词。模型请求直接走宿主 `llm.stream`,15 秒超时,不写回会话。每天新增上限 40 条,同会话冷却 30 秒,连续失败三次暂停 10 分钟。自动去重阈值 0.85,重复只强化旧条目,超过每日上限仍允许强化。计数按本地日期写入 `auto-log.json`。自动任务的备份失败只警告并继续,既有管理接口的备份失败中止规则不变。
153
189
 
154
- 1. **机器人自己存**:agent 调用 `memory_save`。DSH 里聊几句就有了。
155
- 2. **你手动导入**:`memory_import(source="json"|"markdown"|"text", path="...")`,见下一节。
156
- 3. **QQ 桥接同步**:只有配置了 `bridgeRoot` 的私有适配器才走这条,普通用户用不到。
190
+ 宿主支持 `resolveModelInfo` 时,按实际模型能力选择最低思考档,保留 400-token 输出上限;避免本机默认 high 思考占满预算。任务书抽取提示词原文保留,附加说明“助手说已记过不等于查重结果”,让重复事实交给程序强化;没有能力查询接口的宿主仍使用原来的请求参数。
157
191
 
158
- 所以新用户装完的方向是:
192
+ 会话导入仅通过 `sessionQuery.listSessions/readSession`,不直接读取或解压会话文件,不激活会话。默认 `importAssistant=false`,只收 `user/message`;改为 true 才额外收 `assistant/message`,两者都过滤流程话。此开关不影响自定义路径中的日记等正文。保留事件发生时间;连续重复、寒暄、纯代码块和敏感词仍被过滤,超过 2000 字的消息拆段保存。每会话每轮最多 200 条,全局默认最多 200 条,批间让出 20 毫秒。`import-ledger.json` 保存 seq、文件 size/mtime、JSONL 行数/字节位置及未完成批次,超限下轮继续。重复导入不强化旧记忆。
159
193
 
160
- ```text
161
- 装完 → 空图 → 机器人边聊边存 → 几天后点开,图里是它自己的记忆
162
- ```
194
+ 流程话过滤采用固定词表,可在 `autoImport.noisePatterns` 追加字面短语。命中两个不同特征即拦截;只命中一个时,仅拦截不足 150 字的文本,长内容保留。开头 12 字内出现「已回:」「已回复:」「已发送:」「已处理:」「已确认:」「已读:」「已标记:」「回过了:」「发过了:」的工作汇报,无论长度都拦截;中间提到「他说已回复了」不算前缀。自动记忆的抽取结果也通过同一过滤器。规则只作用于后续入库,不追删旧条目。
195
+
196
+ 新导入的会话按顺序粗分:日期命名的 Markdown 日记 → 日记;价格或解释 → 知识;过敏、忌口或偏好 → 印象;约定或计划 → 事件;有日期的 Markdown 标题 → 日记;其余 → 对话。印象默认 heat=1,其余为 2。自定义来源已有明确分类时保留原分类,日期日记优先。**导入的分类是启发式猜测,可能分错,可以在管理面板手动编辑。** 过滤和分类都不调用 LLM。
197
+
198
+ 需要重新检查旧会话时,在现有 `autoImport` 中加 `"rescanFrom": "session-实际ID"`,或 `"rescanAll": true`。下一轮导入先清除所选会话的 seq 水位并优先处理,随后自动移除配置中的一次性开关;超过限额时从未完成处续跑。要立即执行,可保存配置后正常重启 DSH,等待启动导入。`enabled=false` 时暂不消费重扫请求。重扫不清库,不重分类或强化已有记忆,重复内容由去重拦截;文件来源的增量水位不重置。
199
+
200
+ `auto-log.json` 保留每日 added/skipped/failed,额外记录 `lastError` 和最近最多五条 `recentErrors`。失败阶段是 `llm`(模型调用)、`parse`(解析输出)、`dedupe`(查重)或 `store`(读写存储);`lastError.raw` 最多 200 个 Unicode 字符,recentErrors 不含 raw。修复配置后后续轮次可恢复,历史错误保留供排查;连续三次失败仍按原规则暂停十分钟。公开 config 接口只给出最后错误的时间、阶段和原因,不含原始输出。
201
+
202
+ 自定义来源复用 `collectBridge` 和 `collectFile` 解析器;JSONL 只读新字节范围,Markdown 变化时整篇重导并去重。历史很多时,每轮会继续补齐尚未处理的部分,因此“下一轮仍新增”并不表示重复导入。缺文件只警告一次,失败不推进该批次水位。
163
203
 
164
- 旧的历史数据不会自己进来,需要按下一节导入。
204
+ 手动 `memory_save`、`memory_import` 和已有 QQ 夜间同步仍可用;七个工具的参数与文案保持原样。旧版工具空库提示和界面引导仍描述手动入库流程,自动行为以本节为准。
165
205
 
166
206
  ## 怎么让机器人自己记记忆
167
207
 
168
- 插件只会存取,**不会自己判断什么值得记**。要不要调 `memory_save`,由 agent 决定;它得知道该记什么。
208
+ beta.6 默认自动抽取。下面是额外让 agent 主动调用 `memory_save` 的可选用法,不是自动记忆的前提。
169
209
 
170
- 想让记忆真的长起来,在 agent 的提示词/角色卡里加一段(可直接复制):
210
+ 需要 agent 主动保存时,可以在它的提示词/角色卡中加入:
171
211
 
172
212
  ```text
173
213
  聊天时遇到这些,用 memory_save 记下来:
@@ -181,7 +221,7 @@ memory_map(out="D:\\memory-map.html")
181
221
  存之前先 memory_recall 查一下,避免重复。
182
222
  ```
183
223
 
184
- **为什么必须写这段**:工具描述只说明「能存」,不会告诉模型「该存什么」。不写提示词的结果是——装了一周,图还是空的。
224
+ 这段只影响 agent 主动使用工具;自动记忆通过独立后台调用完成,不需要修改角色卡。
185
225
 
186
226
  这个插件的作者给自家机器人的做法是:除了上面的提示词,还额外跑一个夜间任务,从当天聊天记录里自动提炼印象(那部分不在本插件里,属于她的私有机器人适配器)。
187
227
 
@@ -198,7 +238,7 @@ memory_recall(q="朋友", limit=5)
198
238
 
199
239
  召回结果区分直接命中和联想到的记忆。`memory_stats()` 查看总数和分类;`memory_dedupe()` 默认仅预览重复组。`memory_forget` 支持按 id 精确删除;关键词批量删除必须先预览,并经用户确认后传 `confirm=true`。
200
240
 
201
- **检索是按字面词匹配,不做语义理解。** 库里写的是「古茗」,`memory_recall(q="奶茶")` 就查不到——必须先有「奶茶」这两个字。想覆盖这种情况,存记忆时把常见说法一起写进去(例如「阿木喝奶茶只点古茗的超A芝士葡萄」)。
241
+ **检索是按字面词匹配,不做语义理解。** 库里只写“单车”,`memory_recall(q="自行车")` 就查不到。想覆盖不同说法,存记忆时可以同时写出常用名称,例如“小夏把自行车也叫作单车”。
202
242
 
203
243
  **联想到的记忆**来自关键词共享:两条记忆提到同一个词,就会被连起来,查其中一条时另一条一起返回。它不是推理,是顺着关联往外摸。扩散默认一跳(`hops=1`),邻居要留存 > 0.4 才会被带出来。
204
244
 
@@ -213,7 +253,7 @@ JSON 数组文件(UTF-8,支持 BOM):
213
253
  ```json
214
254
  [
215
255
  {"text":"他说要一起养猫","ts":1793291223536,"kind":"对话","heat":1},
216
- {"text":"古茗超A芝士葡萄三分糖","kind":"知识"}
256
+ {"text":"手冲咖啡先按一比十五的粉水比试喝","kind":"知识"}
217
257
  ]
218
258
  ```
219
259
 
@@ -241,7 +281,7 @@ memory_import(source="markdown", path="D:\\memory-notes", dry_run=false)
241
281
 
242
282
  ```text
243
283
  他说把我当朋友
244
- 古茗超A芝士葡萄三分糖
284
+ 手冲咖啡先按一比十五的粉水比试喝
245
285
  ```
246
286
 
247
287
  ```text
@@ -270,13 +310,15 @@ path 必须是本地绝对路径。单条正文超过 2000 字、日期无效等
270
310
 
271
311
  记忆库默认是 `~/.dsh/memory-engine/memories.json`,图是 `~/.dsh/memory-engine/starmap.html`,配置是 `~/.dsh/memory-engine/config.json`。
272
312
 
273
- **记忆存储、导入和图谱生成不上传记忆,不访问外部网络,不将数据发往远程服务器。** GUI 仅访问本机 DSH 宿主的同源路由。除本地配置/记忆目录、你明确指定的导入路径和配置的可选 QQ 来源之外,插件不会扫描其他个人文件。通用导入的格式适配可能使用系统临时目录,完成后自动清理。agent 使用外部模型时,调用上下文和工具结果的发送仍由 DSH 自身配置管理。
313
+ 记忆存储、导入和图谱生成在本机完成。**自动记忆会将本轮最多 4000 字对话发送给配置的 DSH 模型服务**,默认 deepseek-account / deepseek-flash;关闭 `autoMemory.enabled` 即停止这类调用。自动导入只读宿主会话服务和配置来源,GUI 访问本机同源路由。通用导入可能使用系统临时目录,完成后清理。
274
314
 
275
315
  | 文件 | 内容 |
276
316
  |---|---|
277
317
  | memories.json | 记忆库 |
278
318
  | starmap.html | 最新离线图谱 |
279
319
  | config.json | 本机可选配置 |
320
+ | auto-log.json | 本地日期的自动新增、跳过、失败计数,以及最后错误和最近五条错误摘要 |
321
+ | import-ledger.json | 会话 seq 与文件增量水位 |
280
322
  | sync.log | QQ 增量同步日志 |
281
323
  | memories.json.bak-* | 导入/去重前的备份 |
282
324
 
@@ -322,7 +364,7 @@ node scripts/sync-qq.mjs --dry
322
364
  node --test test/*.test.mjs
323
365
  ```
324
366
 
325
- 通过包安装生成命令后也可运行 `dsh-memory-map`。测试使用隔离的临时记忆库。本版保留原有 56 项回归,新增配置默认值与白名单、非空库引导数据、异步按钮开关、配置失败和超时回退测试。
367
+ 通过包安装生成命令后也可运行 `dsh-memory-map`。测试使用隔离的临时记忆库。beta.6b 保留原有 153 项,新增 22 项导入质量回归。真实 87 条负样本默认读取本机 `~/.dsh/memory-engine/import-noise-review.json`;其他机器可用 `DSH_IMPORT_NOISE_REVIEW` 指定该清单路径,文件不存在时仅跳过这一项私有样本测试。单独验证本次改动可运行 `node --test test/import-quality.test.mjs`。
326
368
 
327
369
  本地开发安装:将本包作为目标 DSH profile 的 link 依赖,并启用同名 bundle;客户端入口已声明为 web/immediately。完全退出再打开 DSH。不要复制宿主 SDK 到包里。
328
370
 
@@ -0,0 +1,227 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ import { readConfig } from './config.js';
5
+ import { inferEmotion } from './decay.js';
6
+ import { findDuplicate, prepare } from './dedupe.js';
7
+ import { parseTime } from './import.js';
8
+ import { eventText, usefulText } from './session-text.js';
9
+ import { parseSourceFile, readJsonl, sourceFiles, verifySource } from './auto-sources.js';
10
+ import { backupForAutomation, readState, service, warningLogger, writeState } from './automation-state.js';
11
+ import { isNoise } from './noise.js';
12
+ import { classifyImport } from './classify.js';
13
+
14
+ const digest = text => createHash('sha256').update(text).digest('hex');
15
+
16
+ export function createAutoImport({ ctx, store, now = Date.now, warn = () => {}, getConfig,
17
+ sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)), startupDelayMs = 8000, schedule = true } = {}) {
18
+ const home = path.dirname(path.dirname(store.file));
19
+ const ledgerFile = path.join(path.dirname(store.file), 'import-ledger.json');
20
+ const warning = warningLogger(warn, now);
21
+ const config = getConfig ?? (() => readConfig(home, { warn: message => warning(message, { once: true }) }));
22
+ let stopped = false;
23
+ let running;
24
+ let timer;
25
+ let consumedRescan;
26
+
27
+ async function perform() {
28
+ const settings = config();
29
+ if (stopped || !settings.autoImport.enabled) return { added: 0, skipped: 0, processed: 0 };
30
+ const ledger = readState(ledgerFile, {});
31
+ if (!ledger || typeof ledger !== 'object' || Array.isArray(ledger)) throw new Error('import-ledger.json 必须是对象');
32
+ const request = settings.autoImport.rescanAll ? 'all' : settings.autoImport.rescanFrom?.trim();
33
+ const configFile = path.join(path.dirname(store.file), 'config.json');
34
+ const savedConfig = request ? readState(configFile, null) : null;
35
+ const savedRequest = savedConfig?.autoImport?.rescanAll ? 'all' : savedConfig?.autoImport?.rescanFrom?.trim();
36
+ if (!request) consumedRescan = undefined;
37
+ else if (request !== consumedRescan || request === savedRequest) {
38
+ const keys = request === 'all' ? Object.keys(ledger).filter(key => key.startsWith('session:')) : [`session:${request}`];
39
+ for (const key of keys) ledger[key] = { lastSeq: -1, rescan: true };
40
+ writeState(ledgerFile, ledger);
41
+ if (savedConfig?.autoImport) {
42
+ if (settings.autoImport.rescanAll === true && savedConfig.autoImport.rescanAll === true) delete savedConfig.autoImport.rescanAll;
43
+ if (savedConfig.autoImport.rescanFrom === settings.autoImport.rescanFrom) delete savedConfig.autoImport.rescanFrom;
44
+ writeState(configFile, savedConfig);
45
+ }
46
+ consumedRescan = request;
47
+ }
48
+ const totals = { added: 0, skipped: 0, processed: 0, noise: 0, assistant: 0 };
49
+ const maximum = settings.autoImport.maxPerRun;
50
+ const patterns = settings.autoMemory.excludePatterns;
51
+
52
+ function commit(entries, key, cursor) {
53
+ if (stopped) return;
54
+ if (entries.length) {
55
+ const rows = store.read();
56
+ const prepared = rows.map(row => prepare(row.text));
57
+ let added = 0;
58
+ for (const candidate of entries) {
59
+ if (isNoise(candidate.text, settings.autoImport.noisePatterns)) { totals.noise++; totals.skipped++; continue; }
60
+ if (findDuplicate(rows, candidate, prepared, 0.85)) { totals.skipped++; continue; }
61
+ const { category: _category, ...fields } = candidate;
62
+ rows.push({ ...fields, id: `m-${now()}-${randomBytes(3).toString('hex')}`, createdAt: now() });
63
+ prepared.push(prepare(candidate.text));
64
+ added++;
65
+ }
66
+ if (added) {
67
+ backupForAutomation(store, warning);
68
+ store.write(rows);
69
+ totals.added += added;
70
+ }
71
+ totals.processed += entries.length;
72
+ }
73
+ ledger[key] = { ...cursor, importedAt: now() };
74
+ writeState(ledgerFile, ledger);
75
+ }
76
+
77
+ async function importSession(query, id) {
78
+ const key = `session:${id}`;
79
+ const saved = ledger[key] ?? { lastSeq: -1 };
80
+ if (!Number.isSafeInteger(saved.lastSeq) || saved.lastSeq < -1) throw new Error(`${key} 水位无效`);
81
+ const read = await query.readSession(id);
82
+ if (stopped) return;
83
+ if (!Array.isArray(read?.events)) throw new Error('readSession 未返回 events 数组');
84
+ let cursor = { ...saved };
85
+ let batch = [];
86
+ let processed = 0;
87
+ let dirty = false;
88
+ let scanned = 0;
89
+ async function flush() {
90
+ if (dirty) commit(batch, key, cursor);
91
+ batch = [];
92
+ dirty = false;
93
+ await sleep(20);
94
+ }
95
+ for (const event of read.events) {
96
+ if (stopped) return;
97
+ if (++scanned % 200 === 0) await sleep(20);
98
+ if (!Number.isSafeInteger(event.seq) || event.seq <= cursor.lastSeq) continue;
99
+ if (event.type === 'assistant/message' && settings.autoImport.importAssistant !== true) {
100
+ cursor.lastSeq = event.seq;
101
+ delete cursor.pending;
102
+ dirty = true;
103
+ totals.assistant++;
104
+ continue;
105
+ }
106
+ const text = eventText(event);
107
+ const hash = text ? digest(text) : null;
108
+ const timestamp = parseTime(event.time);
109
+ const noise = isNoise(text, settings.autoImport.noisePatterns);
110
+ if (noise) totals.noise++;
111
+ if (!text || !usefulText(text) || noise || timestamp === null || hash === cursor.lastTextHash || patterns.some(pattern => text.includes(pattern))) {
112
+ cursor.lastSeq = event.seq;
113
+ delete cursor.pending;
114
+ if (hash) cursor.lastTextHash = hash;
115
+ dirty = true;
116
+ continue;
117
+ }
118
+ const characters = Array.from(text);
119
+ let offset = cursor.pending?.seq === event.seq ? cursor.pending.offset : 0;
120
+ while (offset < characters.length) {
121
+ if (processed >= 200 || totals.processed + batch.length >= maximum) { await flush(); return; }
122
+ const part = characters.slice(offset, offset + 2000).join('');
123
+ const inferred = inferEmotion(part);
124
+ batch.push({ text: part, ...classifyImport(text), ts: timestamp, emotion: inferred.emotion, boosts: 0,
125
+ origin: { source: 'dsh-sessions', sessionId: id, seq: event.seq, offset, role: event.type === 'user/message' ? 'user' : 'assistant' } });
126
+ processed++;
127
+ offset += 2000;
128
+ if (offset >= characters.length) {
129
+ cursor.lastSeq = event.seq;
130
+ cursor.lastTextHash = hash;
131
+ delete cursor.pending;
132
+ } else cursor.pending = { seq: event.seq, offset };
133
+ dirty = true;
134
+ if (batch.length >= 20) await flush();
135
+ if (stopped) return;
136
+ }
137
+ }
138
+ if (cursor.rescan) { delete cursor.rescan; dirty = true; }
139
+ await flush();
140
+ }
141
+
142
+ async function importFile(source, root, file) {
143
+ await verifySource(root, file);
144
+ const info = await fs.stat(file);
145
+ const saved = ledger[file];
146
+ const unchanged = saved?.size === info.size && saved?.mtime === info.mtimeMs;
147
+ const jsonl = source.type === 'bridge' && file.endsWith('.jsonl');
148
+ if (unchanged && !saved.pending && (!jsonl || saved.bytes >= info.size)) return;
149
+ const increment = jsonl ? await readJsonl(file, saved, info) : undefined;
150
+ const entries = await parseSourceFile(source, root, file, info, increment?.content, now());
151
+ for (const entry of entries) {
152
+ const classification = classifyImport(entry.text, file);
153
+ if (classification.kind === '日记' || !entry.kind || entry.kind === '记忆') Object.assign(entry, classification);
154
+ }
155
+ let offset = increment?.offset ?? (unchanged ? saved?.pending?.offset ?? 0 : 0);
156
+ while (!stopped) {
157
+ const room = maximum - totals.processed;
158
+ if (room <= 0) return;
159
+ const batch = entries.slice(offset, offset + Math.min(room, 20));
160
+ offset += batch.length;
161
+ const complete = offset >= entries.length;
162
+ const cursor = { size: info.size, mtime: info.mtimeMs };
163
+ if (increment) {
164
+ Object.assign(cursor, complete ? { bytes: increment.bytes, lines: increment.lines } : increment.base);
165
+ if (!complete) cursor.pending = { bytes: increment.bytes, lines: increment.lines, offset };
166
+ } else if (!complete) cursor.pending = { offset };
167
+ commit(batch, file, cursor);
168
+ await sleep(20);
169
+ if (complete) return;
170
+ }
171
+ }
172
+
173
+ for (const source of settings.autoImport.sources) {
174
+ if (stopped || totals.processed >= maximum) break;
175
+ try {
176
+ if (source.type === 'dsh-sessions') {
177
+ const query = service(ctx, 'sessionQuery');
178
+ if (typeof query?.listSessions !== 'function' || typeof query?.readSession !== 'function') {
179
+ warning('sessionQuery 服务不可用,跳过 dsh-sessions;自定义路径源仍正常导入', { once: true });
180
+ continue;
181
+ }
182
+ const sessions = await query.listSessions();
183
+ if (!Array.isArray(sessions)) throw new Error('listSessions 未返回数组');
184
+ const prioritized = [...sessions].sort((left, right) => Number(ledger[`session:${right.header?.id}`]?.rescan === true)
185
+ - Number(ledger[`session:${left.header?.id}`]?.rescan === true));
186
+ for (const session of prioritized) {
187
+ if (stopped || totals.processed >= maximum) break;
188
+ if (typeof session.header?.id !== 'string') continue;
189
+ try { await importSession(query, session.header.id); }
190
+ catch (error) { warning(`导入会话 ${session.header.id} 失败:${error.message}`); }
191
+ }
192
+ } else {
193
+ const { root, files } = await sourceFiles(source, warning);
194
+ for (const file of files) {
195
+ if (stopped || totals.processed >= maximum) break;
196
+ try { await importFile(source, root, file); }
197
+ catch (error) { warning(`导入 ${file} 失败:${error.message}`, { once: true }); }
198
+ }
199
+ }
200
+ } catch (error) { warning(`略过来源 ${source.path ?? source.type}:${error.message}`, { once: true }); }
201
+ }
202
+ return totals;
203
+ }
204
+
205
+ function runNow() {
206
+ if (running) return running;
207
+ running = perform().catch(error => { warning(`自动导入失败:${error.message}`); return { error: error.message }; })
208
+ .finally(() => { running = undefined; });
209
+ return running;
210
+ }
211
+
212
+ function next(delay) {
213
+ if (stopped) return;
214
+ timer = setTimeout(() => {
215
+ void runNow().finally(() => {
216
+ if (!stopped) {
217
+ let minutes = 60;
218
+ try { minutes = config().autoImport.intervalMinutes; } catch (error) { warning(error.message); }
219
+ next(minutes * 60000);
220
+ }
221
+ });
222
+ }, delay);
223
+ timer.unref?.();
224
+ }
225
+ if (schedule) next(startupDelayMs);
226
+ return { runNow, dispose() { stopped = true; clearTimeout(timer); } };
227
+ }
@@ -0,0 +1,30 @@
1
+ import { localDay, readState, writeState } from './automation-state.js';
2
+
3
+ const stages = new Set(['parse', 'llm', 'store', 'dedupe']);
4
+
5
+ export function errorSummary(error) {
6
+ if (!error || !Number.isFinite(error.at) || !stages.has(error.stage) || typeof error.message !== 'string') return null;
7
+ return { at: error.at, stage: error.stage, message: error.message };
8
+ }
9
+
10
+ export function updateAutoLog(file, change, now = Date.now(), failure) {
11
+ const day = localDay(now);
12
+ let log = readState(file, { day, added: 0, skipped: 0, failed: 0 });
13
+ if (!log || !['added', 'skipped', 'failed'].every(key => Number.isSafeInteger(log[key]) && log[key] >= 0)) throw new Error('auto-log.json 计数无效');
14
+ if (log.day !== day) log = { ...log, day, added: 0, skipped: 0, failed: 0 };
15
+ if (change) for (const [key, value] of Object.entries(change)) log[key] += value;
16
+ if (failure) {
17
+ const entry = { at: now, stage: failure.stage, message: String(failure.error?.message ?? failure.error ?? '未知错误') };
18
+ if (!stages.has(entry.stage)) throw new Error('自动记忆错误阶段无效');
19
+ log.lastError = { ...entry, raw: Array.from(String(failure.raw ?? '')).slice(0, 200).join('') };
20
+ const previous = Array.isArray(log.recentErrors) ? log.recentErrors.map(errorSummary).filter(Boolean) : [];
21
+ log.recentErrors = [...previous, entry].slice(-5);
22
+ }
23
+ if (change || failure) writeState(file, log);
24
+ return log;
25
+ }
26
+
27
+ export function readLastError(file, warn = () => {}) {
28
+ try { return errorSummary(readState(file, {}).lastError); }
29
+ catch (error) { try { warn(`无法读取自动记忆错误记录:${error.message}`); } catch {} return null; }
30
+ }