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 +1 -1
- package/README.md +83 -41
- package/lib/auto-import.js +227 -0
- package/lib/auto-log.js +30 -0
- package/lib/auto-memory.js +186 -0
- package/lib/auto-sources.js +106 -0
- package/lib/automation-config.js +66 -0
- package/lib/automation-state.js +43 -0
- package/lib/classify.js +11 -0
- package/lib/client.js +154 -50
- package/lib/client.src.js +73 -8
- package/lib/config-write.js +74 -0
- package/lib/config.js +10 -4
- package/lib/dedupe.js +2 -2
- package/lib/file-import.js +4 -3
- package/lib/import.js +8 -5
- package/lib/index.js +29 -7
- package/lib/noise.js +20 -0
- package/lib/recall.js +67 -28
- package/lib/render/canvas.js +81 -42
- package/lib/render/render.mjs +1 -1
- package/lib/routes.js +9 -3
- package/lib/session-text.js +44 -0
- package/lib/store.js +2 -2
- package/package.json +6 -3
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -2,19 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
DSH 插件的本地记忆引擎,带神经网络可视化。它让 agent 保存、召回和管理跨会话记忆,也能把你已有的记忆文件导进来。
|
|
4
4
|
|
|
5
|
-
当前版本:0.7.0
|
|
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
|
-
|
|
|
12
|
-
|
|
|
13
|
-
|
|
|
14
|
-
|
|
|
15
|
-
|
|
|
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 插槽 | 图谱按钮退到
|
|
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
|
|
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` |
|
|
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": "说明" }
|
|
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
|
-
```
|
|
144
|
-
{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
+
beta.6 默认自动抽取。下面是额外让 agent 主动调用 `memory_save` 的可选用法,不是自动记忆的前提。
|
|
169
209
|
|
|
170
|
-
|
|
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
|
-
**检索是按字面词匹配,不做语义理解。**
|
|
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":"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/lib/auto-log.js
ADDED
|
@@ -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
|
+
}
|