dsh-memory-engine 0.7.0-beta.2b → 0.7.0-beta.6b

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  DSH 插件的本地记忆引擎,带神经网络可视化。它让 agent 保存、召回和管理跨会话记忆,也能把你已有的记忆文件导进来。
4
4
 
5
- 当前版本:0.7.0-beta.2b。Node ≥22,零新增依赖。恢复应用内点击神经元的只读详情卡片,Canvas 与离线 HTML 继续共用唯一绘制实现。
5
+ 当前版本:0.7.0-beta.6b。Node ≥22,零新增依赖。自动导入默认只收用户消息,增加流程话过滤、规则分类、错误原因和一次性重扫;保留 beta.5 检索、记忆管理、角色卡和 Canvas 渲染。
6
6
 
7
7
  ## 0.7.0 分步交付
8
8
 
@@ -11,14 +11,15 @@ DSH 插件的本地记忆引擎,带神经网络可视化。它让 agent 保存
11
11
  | 第 1 步 · 已完成 | 宿主路由、共享图谱计算、同源 iframe | 原独立端口无监听,按钮能打开图,JSON 接口返回当前记忆 |
12
12
  | 第 2 步 · 已完成 | React 内直接绘制 Canvas | 浮层无 iframe;旋转、缩放、选中可用,关闭后动画停止 |
13
13
  | 渲染合并 · 已完成 | canvas.js 是唯一绘制源码,界面构建与离线 HTML 共用 | 画面不变,构建幂等,未构建的源码改动会使测试失败 |
14
- | 点击回归 · 本版完成 | 恢复右侧只读详情卡片 | 可见字段、切换、关闭、高亮和复制;长正文可滚动 |
15
- | 第 3 步 · 待交付 | 记忆列表、搜索、编辑、删除、导出 | 可浏览、修改和删除;写操作先备份 |
14
+ | 点击回归 · 已完成 | 恢复右侧只读详情卡片 | 可见字段、切换、关闭、高亮和复制;长正文可滚动 |
15
+ | 第 3 步 · 已完成 | 记忆列表、搜索、编辑、删除、导出和清空 | 分页、筛选、确认与错误提示;写操作先备份 |
16
+ | 中心角色卡 · 本版完成 | 固定角色核心、分层球壳、慢速自转 | 角色卡全文与复制;拖拽暂停,松手 2 秒恢复;缺省退回原布局 |
16
17
  | 引导小修 · 已完成 | 空库和手动回看共用三步卡片 | 编号步骤、复制提示词、刷新和关闭卡片可用 |
17
18
 
18
19
  - [x] 引导回看:右上角「?」、中央卡片、复制提示词、知道了/Esc 关闭。
19
20
  - [x] 按钮开关:hideButton、showGuideOnEmpty;配置请求失败时保留按钮并警告。
20
21
 
21
- 界面已改为应用内 canvas 渲染,不再使用 iframe;`/memory-engine/` 整页 HTML 保留给 CLI 与浏览器直接访问。七个 agent 工具继续可用,记忆管理留给后续独立交付。
22
+ 界面使用应用内 canvas 渲染;`/memory-engine/` 整页 HTML 保留给 CLI 与浏览器直接访问。七个 agent 工具继续可用,记忆管理在应用内的「记忆」「设置」页完成。
22
23
 
23
24
  ## 支持的 DSH 版本
24
25
 
@@ -38,15 +39,21 @@ peerDependencies 已放宽为 >=0.1.0-rc.8 <0.3.0 并标为 optional——宿主
38
39
 
39
40
  0.7.0 的 GUI 还要求宿主提供 `webServer.register({ kind, path, handler })`。服务端声明 `inject = ['tools', 'webServer']`,插件生命周期结束时注销路由。原有 `ctx.effect` 和 `defineTool` 兼容逻辑保留;直接调用初始化时若路由注册失败,会记录提示,已注册的工具仍可用。
40
41
 
42
+ 本机 0.2.1-rc.5 的 Cordis 把 `inject` 中的所有服务视为必需依赖,没有可选注入声明。因此 `llm` 和 `sessionQuery` 通过 `ctx.get()` 按需获取:缺少前者只停用自动抽取,缺少后者只跳过会话来源。把这两个名字加入必需注入数组会使整个插件在缺服务时无法启动。实际 `session/event` 回调签名为 `(session, event)`。
43
+
41
44
  ## 界面
42
45
 
43
46
  安装并启用插件后,完全退出 DSH 再打开。进入任意会话,头部第一行会出现 **🧠 记忆图谱** 按钮。
44
47
 
45
- 点击后全屏显示图谱,右上角 **关闭 ×** 退出。每次打开都会读取当前记忆库,在内存中计算当前图谱,不写入离线 HTML。
48
+ 点击后全屏显示图谱,顶部有 **图谱 / 记忆 / 设置** 三个 tab,右上角 **关闭 ×** 退出。每次打开或从其他 tab 返回图谱都会读取当前记忆库,在内存中计算当前图谱,不写入离线 HTML。离开图谱 tab 时停止 Canvas 动画。
49
+
50
+ **记忆 tab** 按正文或标题搜索(250ms 防抖),可筛选知识、印象、对话、事件、日记,按最近、最老、快忘了或连接最多排序。每页 50 条,底部显示过滤后的总数和「加载更多」。点一条展开全文,再点「编辑」修改正文、分类、重要度或相关人;保存失败保留草稿并显示服务端错误,取消不发送写请求。删除必须再次确认。
51
+
52
+ **设置 tab** 显示数据目录、条数、文件大小,支持复制路径和下载全部记忆。导出文件结构为 `{ ok: true, data: { total, items } }`,保留原始条目的全部字段。清空必须输入大写 `DELETE`,输入错误不会发请求;成功后回到空库引导。界面不重复提供导入,仍使用 `memory_import` 工具。
46
53
 
47
54
  点击神经元,右侧滑入约 350px 的只读详情卡片,显示分类、标题、重要度、留存、连接数、本地日期和正文;有情绪标签时才显示情绪行。空标题取正文前 20 字,长标题换行、长正文在卡片内滚动。点另一个节点只更新内容,不重复滑入。
48
55
 
49
- 卡片的 **在图里高亮它** 调用现有激活效果;**复制正文** 复制全文,成功后显示「已复制 ✓」,1.8 秒后恢复。点图上空白处或卡片 × 关闭详情,图谱继续显示。关闭整个浮层或刷新图谱会清空选中状态。本版不提供编辑、删除或记忆列表。
56
+ 卡片的 **在图里高亮它** 调用现有激活效果;**复制正文** 复制全文,成功后显示「已复制 ✓」,1.8 秒后恢复。点图上空白处或卡片 × 关闭详情,图谱继续显示。关闭整个浮层、切换 tab 或刷新图谱会清空选中状态;编辑和删除在「记忆」tab 完成。
50
57
 
51
58
  **记忆库为空时,默认自动弹出中央引导卡片**;已经有记忆时,点右上角、关闭按钮左侧的 **?** 随时回看。同一张卡片显示三步说明、提示词和数据目录;「复制这段提示词」成功后显示「已复制 ✓」,「刷新图谱」重新载入当前记忆。
52
59
 
@@ -69,6 +76,24 @@ peerDependencies 已放宽为 >=0.1.0-rc.8 <0.3.0 并标为 optional——宿主
69
76
 
70
77
  拖拽旋转、滚轮缩放(0.3~4.5),点击神经元激活并查看详情。详情直接使用节点已有数据,不请求新接口,也不写入记忆。客户端使用相对路径 `/memory-engine/api/map` 获取数据,由 React 管理 Canvas;关闭浮层或卸载组件会停止动画、取消请求并清理监听。桌面端经 DSH 的原生协议转发,无需知道宿主端口。旧 `config.json` 中的 `uiPort` 不再使用,可保留或删除。还可以运行 `node <插件目录>/scripts/render-map.mjs --open` 生成并打开离线图谱,整页 HTML 仍可点击查看原文。
71
78
 
79
+ ### 中心角色卡与自转
80
+
81
+ 在现有 `config.json` 对象里合并以下字段(路径使用你自己的角色卡):
82
+
83
+ ```json
84
+ {
85
+ "personaFile": "C:/path/to/role.md",
86
+ "personaTitle": "阿鲸",
87
+ "spinSpeed": 0.35
88
+ }
89
+ ```
90
+
91
+ 也可使用 `personaText` 直接配置正文,最多 4000 个 Unicode 字符;非空 `personaText` 优先于文件。文件只读、取前 4000 字,读取失败记录警告并退回原布局。标题默认「角色卡」,速度范围 0.05~1.5,默认 0.35(正常帧率下约 90 秒一圈)。配置后重新打开图谱即可读取;升级插件本身需要重新加载或重启 DSH。
92
+
93
+ 角色卡只作为图谱里的虚拟中心,不写入记忆库,也不参与评分、关联、复习、列表或导出。它固定在原点,点击显示 `[人格] 标题`、可滚动正文和「复制全文」。印象、知识、事件位于内层球壳,日记、对话和其他类别在外层;旧记忆更靠外。同一份数据的布局确定不变;没有角色卡时保留原来的脑状布局。
94
+
95
+ 打开后自动慢速旋转,拖拽时暂停,松手或取消拖拽 2 秒后恢复。关闭图谱或切换 tab 停止动画。离线 HTML 使用同一份布局和 Canvas,支持中心角色卡与全文复制。
96
+
72
97
  ### 客户端构建
73
98
 
74
99
  `lib/render/canvas.js` 是唯一绘制源码,导出 `createRenderer(canvas, data, options)`,每个实例独立维护相机、节点缓存、脉冲和动画生命周期。它不导入模块,也不依赖 ModuleLoader。
@@ -77,19 +102,29 @@ peerDependencies 已放宽为 >=0.1.0-rc.8 <0.3.0 并标为 optional——宿主
77
102
 
78
103
  `lib/render/render.mjs` 只保留 HTML、标题、图例、复习清单和原有详情外壳,读取同一份 Canvas 源码并内联。两个消费者共用 `scripts/lib/strip-export.mjs` 去掉导出声明,发布客户端和离线 HTML 都不需要运行时模块加载。离线 HTML 可直接双击打开。
79
104
 
80
- `pnpm test`(或 `npm test`)包含 `build-freshness` 检查:改了源码却忘记构建会失败,并提示「跑 pnpm build」;测试不会自动修复产物。`prepack` 保证 `pnpm pack` / `npm pack` 发布前重新构建。记忆管理面板仍留给后续阶段。
105
+ `pnpm test`(或 `npm test`)包含 `build-freshness` 检查:改了源码却忘记构建会失败,并提示「跑 pnpm build」;测试不会自动修复产物。`prepack` 保证 `pnpm pack` / `npm pack` 发布前重新构建。
81
106
 
82
- 本版的只读接口:
107
+ 同源接口:
83
108
 
84
109
  | 方法 | 路径 | 返回内容 |
85
110
  | --- | --- | --- |
86
111
  | GET | `/memory-engine/` | 独立浏览器可直接访问的整页 HTML 图谱;应用内界面不再加载此页面 |
87
- | GET | `/memory-engine/api/map` | `{ nodes, links, review, stats, generatedAt }`;nodes 已含原布局计算结果 |
112
+ | GET | `/memory-engine/api/map` | `{ nodes, links, review, stats, generatedAt, core, spinSpeed }`;配置角色卡时 nodes[0] 为 isCore,core 含 id/title/preview,否则为 null;stats 统计图谱节点 |
88
113
  | GET | `/memory-engine/api/stats` | 记忆总数、分类、衰减状态及存储位置 |
89
114
  | GET | `/memory-engine/api/onboard` | `{ empty, prompt, steps, dataDir }`,空库和手动帮助共用 |
90
- | GET | `/memory-engine/api/config` | 仅 `{ hideButton, showGuideOnEmpty }`,不暴露其他配置 |
115
+ | GET | `/memory-engine/api/config` | 既有字段,加 `autoMemory: { enabled, maxPerTurn }`、`autoImport: { enabled, sourceCount }` 和 `lastError: { at, stage, message }`(无错误时为 null);不返回 raw、来源路径或模型配置 |
116
+ | GET | `/memory-engine/api/list?q=&kind=&sort=&limit=&offset=` | `{ total, allTotal, limit, offset, items }`;包含留存、连接数和 60 字预览 |
117
+ | GET | `/memory-engine/api/item/:id` | 完整条目、留存和连接数;不存在返回 404 |
118
+ | PUT | `/memory-engine/api/item/:id` | 仅更新传入的 text/kind/heat/who;返回更新条目和 backup |
119
+ | DELETE | `/memory-engine/api/item/:id` | 删除一条,返回 `{ deleted, backup }` |
120
+ | GET | `/memory-engine/api/export` | `{ total, items }`,条目原样导出 |
121
+ | POST | `/memory-engine/api/clear` | 请求 `{ confirm: "DELETE" }`,返回 `{ removed, backup }` |
91
122
 
92
- JSON 成功返回 `{ "ok": true, "data": ... }`,失败返回 `{ "ok": false, "error": "说明" }`。接口仅接受本机 Host,严格限制路径,拒绝路径穿越;本阶段只开放 GET,其他方法返回 405。只读接口不接收请求体,已声明超过 1MB 的请求体返回 413,其余非空或分块请求体直接拒绝,不在内存中收集。写入接口和对应的 JSON 解析、备份操作在第 3 步加入。
123
+ JSON 成功返回 `{ "ok": true, "data": ... }`,失败返回 `{ "ok": false, "error": "说明" }`。本版仅给 map/config 增加角色卡字段,保留已有字段;stats/list/export 仍只统计实际记忆。接口仅接受本机 Host,严格限制路径,拒绝路径穿越;不允许的方法返回 405。只读接口不接收请求体,PUT 和 POST 只接收 JSON 对象,声明大小和实际分块累计大小都限制在 1MB。
124
+
125
+ 列表默认最近优先、每页 50 条,limit 上限 200;搜索为不区分大小写的包含匹配,kind 精确匹配。留存复用 `decay.retention()`,连接数复用全库 `scoreMemories()`,筛选不会改变连接数。编辑只允许 text(1~2000 字符且非全空白)、kind(1~20 字符)、heat(整数 1/2/3)、who(0~40 字符);不会改动 id、时间、强化次数或触发相似合并。
126
+
127
+ 管理写操作统一先调用 `MemoryStore.backup()`,再调用已有 `write()` 临时文件加 rename 原子落盘。`memories.json.bak-<毫秒时间戳>` 操作备份保留最新五份,同毫秒时自动加序号。备份失败不写入,原子替换失败保留原文件;同步任务的 `bak-sync-*` 备份保留原有策略。切回图谱重新取数即可看到编辑或删除的结果。
93
128
 
94
129
  也可以让 agent 调用 `memory_map()`,生成可双击、离线打开的单文件 HTML:
95
130
 
@@ -101,40 +136,60 @@ memory_map(out="D:\\memory-map.html")
101
136
 
102
137
  默认输出 `{DSH_HOME}/memory-engine/starmap.html`;未设置 DSH_HOME 时为 `~/.dsh/memory-engine/starmap.html`。`open` 默认 false,显式 true 才启动系统默认浏览器。
103
138
 
104
- ## 装完是空库(重要)
139
+ ## 自动记忆和自动导入
105
140
 
106
- 插件**不会自动读取任何东西**。装完那一刻记忆库是空的,图谱里一个神经元都没有。
141
+ 两项能力默认开启。自动记忆在每轮结束后异步抽取最多两条事实,不依赖 agent 调用工具;自动导入在启动 8 秒后读取 DSH 已有会话,之后每小时续导。新装空库也会逐步获得历史记录。
107
142
 
108
- 它只会读写自己的目录:
143
+ 在现有 `config.json` 中合并:
109
144
 
110
- ```text
111
- {DSH_HOME}/memory-engine/
112
- memories.json 记忆库
113
- config.json 配置(可选)
114
- starmap.html 生成的图
145
+ ```json
146
+ {
147
+ "autoMemory": {
148
+ "enabled": true,
149
+ "provider": "deepseek-account",
150
+ "model": "deepseek-flash",
151
+ "maxPerTurn": 2,
152
+ "minChars": 12,
153
+ "dailyCap": 40,
154
+ "cooldownSec": 30,
155
+ "excludePatterns": ["密码", "身份证", "银行卡", "手机号", "验证码"]
156
+ },
157
+ "autoImport": {
158
+ "enabled": true,
159
+ "importAssistant": false,
160
+ "noisePatterns": [],
161
+ "sources": [{ "type": "dsh-sessions" }],
162
+ "intervalMinutes": 60,
163
+ "maxPerRun": 200
164
+ }
165
+ }
115
166
  ```
116
167
 
117
- 不会扫描硬盘、不会猜路径、不会去读你的聊天记录或其他机器人的数据。`DEFAULT_BRIDGE_ROOT`、`DEFAULT_DIARY_DIR` 默认都是空字符串——不配置就完全不读。
168
+ 未显式配置 `sources` 时,默认先导入 `dsh-sessions`,再追加已有 `bridgeRoot` 和 `diaryDir`。显式配置的数组会替换这个来源列表。路径来源支持 `bridge`、`diary`、`json`、`markdown`、`text`,填写绝对 `path`。只接受真实根路径,拒绝逃出目录的软链接;所有源文件只读。两项 `enabled` 可独立关闭,下轮任务会读取新配置。
118
169
 
119
- 记忆只有三个来源:
170
+ 自动抽取只看本轮 user/assistant 的文本块,最近最多 4000 字;排除工具、推理、寒暄和敏感词。模型请求直接走宿主 `llm.stream`,15 秒超时,不写回会话。每天新增上限 40 条,同会话冷却 30 秒,连续失败三次暂停 10 分钟。自动去重阈值 0.85,重复只强化旧条目,超过每日上限仍允许强化。计数按本地日期写入 `auto-log.json`。自动任务的备份失败只警告并继续,既有管理接口的备份失败中止规则不变。
120
171
 
121
- 1. **机器人自己存**:agent 调用 `memory_save`。DSH 里聊几句就有了。
122
- 2. **你手动导入**:`memory_import(source="json"|"markdown"|"text", path="...")`,见下一节。
123
- 3. **QQ 桥接同步**:只有配置了 `bridgeRoot` 的私有适配器才走这条,普通用户用不到。
172
+ 宿主支持 `resolveModelInfo` 时,按实际模型能力选择最低思考档,保留 400-token 输出上限;避免本机默认 high 思考占满预算。任务书抽取提示词原文保留,附加说明“助手说已记过不等于查重结果”,让重复事实交给程序强化;没有能力查询接口的宿主仍使用原来的请求参数。
124
173
 
125
- 所以新用户装完的方向是:
174
+ 会话导入仅通过 `sessionQuery.listSessions/readSession`,不直接读取或解压会话文件,不激活会话。默认 `importAssistant=false`,只收 `user/message`;改为 true 才额外收 `assistant/message`,两者都过滤流程话。此开关不影响自定义路径中的日记等正文。保留事件发生时间;连续重复、寒暄、纯代码块和敏感词仍被过滤,超过 2000 字的消息拆段保存。每会话每轮最多 200 条,全局默认最多 200 条,批间让出 20 毫秒。`import-ledger.json` 保存 seq、文件 size/mtime、JSONL 行数/字节位置及未完成批次,超限下轮继续。重复导入不强化旧记忆。
126
175
 
127
- ```text
128
- 装完 → 空图 → 机器人边聊边存 → 几天后点开,图里是它自己的记忆
129
- ```
176
+ 流程话过滤采用固定词表,可在 `autoImport.noisePatterns` 追加字面短语。命中两个不同特征即拦截;只命中一个时,仅拦截不足 150 字的文本,长内容保留。开头 12 字内出现「已回:」「已回复:」「已发送:」「已处理:」「已确认:」「已读:」「已标记:」「回过了:」「发过了:」的工作汇报,无论长度都拦截;中间提到「他说已回复了」不算前缀。自动记忆的抽取结果也通过同一过滤器。规则只作用于后续入库,不追删旧条目。
177
+
178
+ 新导入的会话按顺序粗分:日期命名的 Markdown 日记 → 日记;价格或解释 → 知识;过敏、忌口或偏好 → 印象;约定或计划 → 事件;有日期的 Markdown 标题 → 日记;其余 → 对话。印象默认 heat=1,其余为 2。自定义来源已有明确分类时保留原分类,日期日记优先。**导入的分类是启发式猜测,可能分错,可以在管理面板手动编辑。** 过滤和分类都不调用 LLM。
179
+
180
+ 需要重新检查旧会话时,在现有 `autoImport` 中加 `"rescanFrom": "session-实际ID"`,或 `"rescanAll": true`。下一轮导入先清除所选会话的 seq 水位并优先处理,随后自动移除配置中的一次性开关;超过限额时从未完成处续跑。要立即执行,可保存配置后正常重启 DSH,等待启动导入。`enabled=false` 时暂不消费重扫请求。重扫不清库,不重分类或强化已有记忆,重复内容由去重拦截;文件来源的增量水位不重置。
181
+
182
+ `auto-log.json` 保留每日 added/skipped/failed,额外记录 `lastError` 和最近最多五条 `recentErrors`。失败阶段是 `llm`(模型调用)、`parse`(解析输出)、`dedupe`(查重)或 `store`(读写存储);`lastError.raw` 最多 200 个 Unicode 字符,recentErrors 不含 raw。修复配置后后续轮次可恢复,历史错误保留供排查;连续三次失败仍按原规则暂停十分钟。公开 config 接口只给出最后错误的时间、阶段和原因,不含原始输出。
183
+
184
+ 自定义来源复用 `collectBridge` 和 `collectFile` 解析器;JSONL 只读新字节范围,Markdown 变化时整篇重导并去重。历史很多时,每轮会继续补齐尚未处理的部分,因此“下一轮仍新增”并不表示重复导入。缺文件只警告一次,失败不推进该批次水位。
130
185
 
131
- 旧的历史数据不会自己进来,需要按下一节导入。
186
+ 手动 `memory_save`、`memory_import` 和已有 QQ 夜间同步仍可用;七个工具的参数与文案保持原样。旧版工具空库提示和界面引导仍描述手动入库流程,自动行为以本节为准。
132
187
 
133
188
  ## 怎么让机器人自己记记忆
134
189
 
135
- 插件只会存取,**不会自己判断什么值得记**。要不要调 `memory_save`,由 agent 决定;它得知道该记什么。
190
+ beta.6 默认自动抽取。下面是额外让 agent 主动调用 `memory_save` 的可选用法,不是自动记忆的前提。
136
191
 
137
- 想让记忆真的长起来,在 agent 的提示词/角色卡里加一段(可直接复制):
192
+ 需要 agent 主动保存时,可以在它的提示词/角色卡中加入:
138
193
 
139
194
  ```text
140
195
  聊天时遇到这些,用 memory_save 记下来:
@@ -148,7 +203,7 @@ memory_map(out="D:\\memory-map.html")
148
203
  存之前先 memory_recall 查一下,避免重复。
149
204
  ```
150
205
 
151
- **为什么必须写这段**:工具描述只说明「能存」,不会告诉模型「该存什么」。不写提示词的结果是——装了一周,图还是空的。
206
+ 这段只影响 agent 主动使用工具;自动记忆通过独立后台调用完成,不需要修改角色卡。
152
207
 
153
208
  这个插件的作者给自家机器人的做法是:除了上面的提示词,还额外跑一个夜间任务,从当天聊天记录里自动提炼印象(那部分不在本插件里,属于她的私有机器人适配器)。
154
209
 
@@ -237,13 +292,15 @@ path 必须是本地绝对路径。单条正文超过 2000 字、日期无效等
237
292
 
238
293
  记忆库默认是 `~/.dsh/memory-engine/memories.json`,图是 `~/.dsh/memory-engine/starmap.html`,配置是 `~/.dsh/memory-engine/config.json`。
239
294
 
240
- **记忆存储、导入和图谱生成不上传记忆,不访问外部网络,不将数据发往远程服务器。** GUI 仅访问本机 DSH 宿主的同源路由。除本地配置/记忆目录、你明确指定的导入路径和配置的可选 QQ 来源之外,插件不会扫描其他个人文件。通用导入的格式适配可能使用系统临时目录,完成后自动清理。agent 使用外部模型时,调用上下文和工具结果的发送仍由 DSH 自身配置管理。
295
+ 记忆存储、导入和图谱生成在本机完成。**自动记忆会将本轮最多 4000 字对话发送给配置的 DSH 模型服务**,默认 deepseek-account / deepseek-flash;关闭 `autoMemory.enabled` 即停止这类调用。自动导入只读宿主会话服务和配置来源,GUI 访问本机同源路由。通用导入可能使用系统临时目录,完成后清理。
241
296
 
242
297
  | 文件 | 内容 |
243
298
  |---|---|
244
299
  | memories.json | 记忆库 |
245
300
  | starmap.html | 最新离线图谱 |
246
301
  | config.json | 本机可选配置 |
302
+ | auto-log.json | 本地日期的自动新增、跳过、失败计数,以及最后错误和最近五条错误摘要 |
303
+ | import-ledger.json | 会话 seq 与文件增量水位 |
247
304
  | sync.log | QQ 增量同步日志 |
248
305
  | memories.json.bak-* | 导入/去重前的备份 |
249
306
 
@@ -289,7 +346,7 @@ node scripts/sync-qq.mjs --dry
289
346
  node --test test/*.test.mjs
290
347
  ```
291
348
 
292
- 通过包安装生成命令后也可运行 `dsh-memory-map`。测试使用隔离的临时记忆库。本版保留原有 56 项回归,新增配置默认值与白名单、非空库引导数据、异步按钮开关、配置失败和超时回退测试。
349
+ 通过包安装生成命令后也可运行 `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`。
293
350
 
294
351
  本地开发安装:将本包作为目标 DSH profile 的 link 依赖,并启用同名 bundle;客户端入口已声明为 web/immediately。完全退出再打开 DSH。不要复制宿主 SDK 到包里。
295
352
 
@@ -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
+ }