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

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
@@ -1,20 +1,33 @@
1
- # DSH 记忆引擎
1
+ # DSH 记忆引擎
2
2
 
3
3
  DSH 插件的本地记忆引擎,带神经网络可视化。它让 agent 保存、召回和管理跨会话记忆,也能把你已有的记忆文件导进来。
4
4
 
5
- 当前版本:0.7.0-beta.6b。Node ≥22,零新增依赖。自动导入默认只收用户消息,增加流程话过滤、规则分类、错误原因和一次性重扫;保留 beta.5 检索、记忆管理、角色卡和 Canvas 渲染。
5
+ 当前版本:0.7.1。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,7 +45,7 @@ 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,不该因为版本号不匹配而装不上。
@@ -45,7 +58,7 @@ peerDependencies 已放宽为 >=0.1.0-rc.8 <0.3.0 并标为 optional——宿主
45
58
 
46
59
  安装并启用插件后,完全退出 DSH 再打开。进入任意会话,头部第一行会出现 **🧠 记忆图谱** 按钮。
47
60
 
48
- 点击后全屏显示图谱,顶部有 **图谱 / 记忆 / 设置** 三个 tab,右上角 **关闭 ×** 退出。每次打开或从其他 tab 返回图谱都会读取当前记忆库,在内存中计算当前图谱,不写入离线 HTML。离开图谱 tab 时停止 Canvas 动画。
61
+ 点击后全屏显示图谱,顶部有 **图谱 / 记忆 / 设置** 三个 tab,**?** 左侧是 **○ 自动记忆 / ● 自动记忆** 开关,右上角 **关闭 ×** 退出。鼠标悬停可看当前状态和抽取上限。设置页也有同一个开关及成本说明,两处同步、刷新后保持状态。第一次开启会提示模型费用;保存失败恢复原状态,并显示红色原因。每次打开或从其他 tab 返回图谱都会读取当前记忆库,在内存中计算当前图谱,不写入离线 HTML。离开图谱 tab 时停止 Canvas 动画。
49
62
 
50
63
  **记忆 tab** 按正文或标题搜索(250ms 防抖),可筛选知识、印象、对话、事件、日记,按最近、最老、快忘了或连接最多排序。每页 50 条,底部显示过滤后的总数和「加载更多」。点一条展开全文,再点「编辑」修改正文、分类、重要度或相关人;保存失败保留草稿并显示服务端错误,取消不发送写请求。删除必须再次确认。
51
64
 
@@ -83,7 +96,7 @@ peerDependencies 已放宽为 >=0.1.0-rc.8 <0.3.0 并标为 optional——宿主
83
96
  ```json
84
97
  {
85
98
  "personaFile": "C:/path/to/role.md",
86
- "personaTitle": "阿鲸",
99
+ "personaTitle": "小夏",
87
100
  "spinSpeed": 0.35
88
101
  }
89
102
  ```
@@ -112,7 +125,8 @@ peerDependencies 已放宽为 >=0.1.0-rc.8 <0.3.0 并标为 optional——宿主
112
125
  | GET | `/memory-engine/api/map` | `{ nodes, links, review, stats, generatedAt, core, spinSpeed }`;配置角色卡时 nodes[0] 为 isCore,core 含 id/title/preview,否则为 null;stats 统计图谱节点 |
113
126
  | GET | `/memory-engine/api/stats` | 记忆总数、分类、衰减状态及存储位置 |
114
127
  | GET | `/memory-engine/api/onboard` | `{ empty, prompt, steps, dataDir }`,空库和手动帮助共用 |
115
- | GET | `/memory-engine/api/config` | 既有字段,加 `autoMemory: { enabled, maxPerTurn }`、`autoImport: { enabled, sourceCount }` 和 `lastError: { at, stage, message }`(无错误时为 null);不返回 raw、来源路径或模型配置 |
128
+ | GET | `/memory-engine/api/config` | 保留既有字段,`autoMemory` 增加 `dailyCap`,另增加 `showAutoMemoryCostHint`;仍返回 `autoImport: { enabled, sourceCount }` 和 `lastError: { at, stage, message }`,不返回 raw、来源路径或模型配置 |
129
+ | PUT | `/memory-engine/api/config` | 合并合法白名单字段,返回公开配置和 `backup` 文件名 |
116
130
  | GET | `/memory-engine/api/list?q=&kind=&sort=&limit=&offset=` | `{ total, allTotal, limit, offset, items }`;包含留存、连接数和 60 字预览 |
117
131
  | GET | `/memory-engine/api/item/:id` | 完整条目、留存和连接数;不存在返回 404 |
118
132
  | PUT | `/memory-engine/api/item/:id` | 仅更新传入的 text/kind/heat/who;返回更新条目和 backup |
@@ -120,7 +134,11 @@ peerDependencies 已放宽为 >=0.1.0-rc.8 <0.3.0 并标为 optional——宿主
120
134
  | GET | `/memory-engine/api/export` | `{ total, items }`,条目原样导出 |
121
135
  | POST | `/memory-engine/api/clear` | 请求 `{ confirm: "DELETE" }`,返回 `{ removed, backup }` |
122
136
 
123
- 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`,不向客户端额外开放这个字段的写权限。关闭会清空当前收集的对话、取消排队和进行中的抽取,重新开启时重置冷却和失败退避;自动导入独立运行,若要冻结所有自动入库,需同时关闭它。
124
142
 
125
143
  列表默认最近优先、每页 50 条,limit 上限 200;搜索为不区分大小写的包含匹配,kind 精确匹配。留存复用 `decay.retention()`,连接数复用全库 `scoreMemories()`,筛选不会改变连接数。编辑只允许 text(1~2000 字符且非全空白)、kind(1~20 字符)、heat(整数 1/2/3)、who(0~40 字符);不会改动 id、时间、强化次数或触发相似合并。
126
144
 
@@ -138,7 +156,7 @@ memory_map(out="D:\\memory-map.html")
138
156
 
139
157
  ## 自动记忆和自动导入
140
158
 
141
- 两项能力默认开启。自动记忆在每轮结束后异步抽取最多两条事实,不依赖 agent 调用工具;自动导入在启动 8 秒后读取 DSH 已有会话,之后每小时续导。新装空库也会逐步获得历史记录。
159
+ 自动记忆默认关闭;在图谱头部「○ 自动记忆」或设置页可以一键打开,首次开启会显示成本提示。开启后每轮对话会多一次模型调用,异步抽取最多两条事实,不依赖 agent 调用工具。自动导入默认开启,在启动 8 秒后读取 DSH 已有会话,之后每小时续导;导入本身不调用模型。新装空库也会逐步获得历史记录。
142
160
 
143
161
  在现有 `config.json` 中合并:
144
162
 
@@ -220,7 +238,7 @@ memory_recall(q="朋友", limit=5)
220
238
 
221
239
  召回结果区分直接命中和联想到的记忆。`memory_stats()` 查看总数和分类;`memory_dedupe()` 默认仅预览重复组。`memory_forget` 支持按 id 精确删除;关键词批量删除必须先预览,并经用户确认后传 `confirm=true`。
222
240
 
223
- **检索是按字面词匹配,不做语义理解。** 库里写的是「古茗」,`memory_recall(q="奶茶")` 就查不到——必须先有「奶茶」这两个字。想覆盖这种情况,存记忆时把常见说法一起写进去(例如「阿木喝奶茶只点古茗的超A芝士葡萄」)。
241
+ **检索是按字面词匹配,不做语义理解。** 库里只写“单车”,`memory_recall(q="自行车")` 就查不到。想覆盖不同说法,存记忆时可以同时写出常用名称,例如“小夏把自行车也叫作单车”。
224
242
 
225
243
  **联想到的记忆**来自关键词共享:两条记忆提到同一个词,就会被连起来,查其中一条时另一条一起返回。它不是推理,是顺着关联往外摸。扩散默认一跳(`hops=1`),邻居要留存 > 0.4 才会被带出来。
226
244
 
@@ -235,7 +253,7 @@ JSON 数组文件(UTF-8,支持 BOM):
235
253
  ```json
236
254
  [
237
255
  {"text":"他说要一起养猫","ts":1793291223536,"kind":"对话","heat":1},
238
- {"text":"古茗超A芝士葡萄三分糖","kind":"知识"}
256
+ {"text":"手冲咖啡先按一比十五的粉水比试喝","kind":"知识"}
239
257
  ]
240
258
  ```
241
259
 
@@ -263,7 +281,7 @@ memory_import(source="markdown", path="D:\\memory-notes", dry_run=false)
263
281
 
264
282
  ```text
265
283
  他说把我当朋友
266
- 古茗超A芝士葡萄三分糖
284
+ 手冲咖啡先按一比十五的粉水比试喝
267
285
  ```
268
286
 
269
287
  ```text
@@ -355,3 +373,4 @@ node --test test/*.test.mjs
355
373
  ## 许可
356
374
 
357
375
  MIT。可以商用、可以修改、可以再发布,保留版权声明即可(见 [LICENSE](./LICENSE))。
376
+
@@ -5,6 +5,7 @@ import { backupForAutomation, service, warningLogger } from './automation-state.
5
5
  import { createTurnCollector, usefulText } from './session-text.js';
6
6
  import { updateAutoLog } from './auto-log.js';
7
7
  import { isNoise } from './noise.js';
8
+ import { onConfigUpdate } from './config-write.js';
8
9
 
9
10
  export function extractionPrompt(text, maxPerTurn) {
10
11
  return `你是一个记忆抽取器。从下面的对话里,找出「值得长期记住」的事实,最多 ${maxPerTurn} 条。
@@ -45,12 +46,32 @@ export function createAutoMemory({ ctx, store, now = Date.now, warn = () => {},
45
46
  let stopped = false;
46
47
  let failures = 0;
47
48
  let backoffUntil = 0;
49
+ let generation = 0;
50
+ let previousEnabled = config().enabled;
48
51
 
49
- const updateLog = (change, failure) => updateAutoLog(logFile, change, now(), failure);
52
+ function reset() {
53
+ generation++;
54
+ collector.clear();
55
+ for (const controller of active.values()) controller.abort();
56
+ active.clear();
57
+ lastStarted.clear();
58
+ failures = 0;
59
+ backoffUntil = 0;
60
+ }
50
61
 
51
- async function runExtract(turn) {
62
+ function currentSettings() {
52
63
  const settings = config();
64
+ if (settings.enabled !== previousEnabled) { reset(); previousEnabled = settings.enabled; }
65
+ return settings;
66
+ }
67
+
68
+ const updateLog = (change, failure) => updateAutoLog(logFile, change, now(), failure);
69
+
70
+ async function runExtract(turn, submittedGeneration) {
71
+ const settings = currentSettings();
72
+ if (submittedGeneration !== undefined && submittedGeneration !== generation) return;
53
73
  if (stopped || !settings.enabled || now() < backoffUntil || active.has(turn.sessionId)) return;
74
+ const startedGeneration = generation;
54
75
  if (!usefulText(turn.userText.trim())) return;
55
76
  if (settings.excludePatterns.some(pattern => turn.userText.includes(pattern))) { updateLog({ skipped: 1 }); return; }
56
77
  const llm = service(ctx, 'llm');
@@ -70,6 +91,7 @@ export function createAutoMemory({ ctx, store, now = Date.now, warn = () => {},
70
91
  const info = await llm.resolveModelInfo(settings.provider, settings.model, signal);
71
92
  reasoningEffort = info?.reasoning?.efforts?.[0]?.id;
72
93
  }
94
+ signal.throwIfAborted();
73
95
  const stream = llm.stream({ provider: settings.provider, model: settings.model,
74
96
  messages: [{ role: 'user', content: [{ type: 'text', text: extractionPrompt(turn.text, settings.maxPerTurn) }] }],
75
97
  maxTokens: 400, temperature: 0.2, signal, ...(reasoningEffort ? { reasoningEffort } : {}) });
@@ -84,7 +106,7 @@ export function createAutoMemory({ ctx, store, now = Date.now, warn = () => {},
84
106
  if (chunk.type === 'finish' && chunk.reason?.kind === 'max-tokens') throw new Error('抽取达到输出上限,丢弃不完整结果');
85
107
  }
86
108
  if (signal.aborted) throw signal.reason;
87
- if (stopped || !config().enabled) return;
109
+ if (stopped || !currentSettings().enabled || startedGeneration !== generation) return;
88
110
  stage = 'parse';
89
111
  const candidates = JSON.parse(raw);
90
112
  if (!Array.isArray(candidates)) throw new Error('抽取结果不是 JSON 数组');
@@ -123,21 +145,28 @@ export function createAutoMemory({ ctx, store, now = Date.now, warn = () => {},
123
145
  updateLog({ failed: 1 }, { stage, error, raw });
124
146
  warning(`自动记忆抽取失败:${error.message}${!parsed && raw ? `;raw: ${Array.from(raw).slice(0, 200).join('')}` : ''}`);
125
147
  } finally {
126
- active.delete(turn.sessionId);
148
+ if (active.get(turn.sessionId) === controller) active.delete(turn.sessionId);
127
149
  }
128
150
  }
129
151
 
130
152
  function submit(turn) {
131
- const task = Promise.resolve().then(() => runExtract(turn));
153
+ const submittedGeneration = generation;
154
+ const task = Promise.resolve().then(() => runExtract(turn, submittedGeneration));
132
155
  tasks.add(task);
133
156
  void task.catch(error => warning(`自动记忆任务失败:${error.message}`)).finally(() => tasks.delete(task));
134
157
  }
135
158
 
136
159
  const collector = createTurnCollector(submit);
160
+ const removeConfigListener = onConfigUpdate(home, changes => {
161
+ if (changes.autoMemory && Object.hasOwn(changes.autoMemory, 'enabled')) {
162
+ reset();
163
+ previousEnabled = config().enabled;
164
+ }
165
+ });
137
166
  let removeListener;
138
167
  if (config().enabled && typeof service(ctx, 'llm')?.stream !== 'function') warning('llm 服务不可用,自动记忆已停用;现有工具不受影响', { once: true });
139
168
  if (typeof ctx?.on === 'function') removeListener = ctx.on('session/event', (session, event) => {
140
- try { if (!stopped) collector.accept(session, event); }
169
+ try { if (!stopped && currentSettings().enabled) collector.accept(session, event); }
141
170
  catch (error) { warning(`收集本轮对话失败:${error.message}`); }
142
171
  });
143
172
  else warning('session/event 不可用,自动记忆已停用', { once: true });
@@ -148,6 +177,7 @@ export function createAutoMemory({ ctx, store, now = Date.now, warn = () => {},
148
177
  dispose() {
149
178
  stopped = true;
150
179
  if (typeof removeListener === 'function') removeListener();
180
+ removeConfigListener();
151
181
  collector.clear();
152
182
  for (const controller of active.values()) controller.abort();
153
183
  lastStarted.clear();
@@ -1,4 +1,4 @@
1
- export const memoryDefaults = Object.freeze({ enabled: true, provider: 'deepseek-account', model: 'deepseek-flash',
1
+ export const memoryDefaults = Object.freeze({ enabled: false, provider: 'deepseek-account', model: 'deepseek-flash',
2
2
  maxPerTurn: 2, minChars: 12, dailyCap: 40, cooldownSec: 30,
3
3
  excludePatterns: Object.freeze(['密码', '身份证', '银行卡', '手机号', '验证码']) });
4
4
 
package/lib/client.js CHANGED
@@ -403,7 +403,7 @@ function frame(now) {
403
403
 
404
404
 
405
405
 
406
- var defaultSettings = { hideButton: false, showGuideOnEmpty: true };
406
+ var defaultSettings = { hideButton: false, showGuideOnEmpty: true, autoMemory: { enabled: false, maxPerTurn: 2, dailyCap: 40 }, showAutoMemoryCostHint: true };
407
407
  var actionStyle = { padding: "7px 12px", borderRadius: "8px", border: "1px solid #6999b8", background: "#102236", color: "#e2f4ff", font: "inherit", cursor: "pointer" };
408
408
  var fieldStyle = { boxSizing: "border-box", padding: "10px 12px", borderRadius: "8px", border: "1px solid #35536b", background: "#091422", color: "#e2f4ff", font: "inherit", minWidth: 0 };
409
409
  var kinds = ["知识", "印象", "对话", "事件", "日记"];
@@ -823,6 +823,17 @@ function frame(now) {
823
823
  onClick: function () { setOffset(page.items.length); }, style: Object.assign({}, actionStyle, { marginLeft: "12px", opacity: loading ? 0.5 : 1 }) }, "加载更多") : null));
824
824
  }
825
825
 
826
+ function AutoMemoryToggle(props) {
827
+ var enabled = props.settings.autoMemory && props.settings.autoMemory.enabled === true;
828
+ var maximum = props.settings.autoMemory && props.settings.autoMemory.maxPerTurn || 2;
829
+ return React.createElement("button", {
830
+ type: "button", role: "switch", "aria-checked": enabled, "aria-label": "自动记忆",
831
+ "data-memory-engine-auto-memory": props.location, disabled: props.busy, onClick: props.onToggle,
832
+ title: enabled ? "当前:开(每轮对话后会抽 0~" + maximum + " 条记忆)" : "当前:关(不会自动调用模型抽取记忆)",
833
+ style: Object.assign({}, actionStyle, { color: enabled ? "#e2f4ff" : "#8195aa", borderColor: enabled ? "#64bca0" : "#35536b", opacity: props.busy ? 0.6 : 1 }),
834
+ }, React.createElement("span", { "aria-hidden": "true", style: { color: enabled ? "#89f0bb" : "#8195aa", textShadow: enabled ? "0 0 8px #56d890" : "none" } }, enabled ? "●" : "○"), " 自动记忆");
835
+ }
836
+
826
837
  function MemorySettings(props) {
827
838
  var statsState = React.useState(null);
828
839
  var stats = statsState[0], setStats = statsState[1];
@@ -888,6 +899,10 @@ function frame(now) {
888
899
  return React.createElement("section", { "aria-label": "记忆设置", "data-memory-engine-settings": "true",
889
900
  style: { position: "absolute", inset: "76px 0 0", overflowY: "auto", padding: "0 24px 32px", color: "#dbeaf8", font: "14px/1.7 system-ui,sans-serif" } },
890
901
  React.createElement("div", { style: { maxWidth: "900px", margin: "0 auto", padding: "28px", border: "1px solid #263f54", borderRadius: "14px", background: "#0b1624" } },
902
+ React.createElement("section", { style: { marginBottom: "24px", paddingBottom: "20px", borderBottom: "1px solid #263f54" } },
903
+ React.createElement("h2", { style: { margin: "0 0 12px", fontSize: "20px", color: "#b7e9ff" } }, "自动记忆"),
904
+ React.createElement(AutoMemoryToggle, { settings: props.settings, busy: props.configBusy, onToggle: props.onToggle, location: "settings" }),
905
+ React.createElement("p", { style: { color: "#9fb7ce", marginBottom: 0 } }, "默认关闭。开启后每轮对话会额外调用一次模型,抽取值得记住的事实;通常最多 2 条,可随时关闭。大约每轮几分钱,实际费用以模型及计费方案为准。自动导入是独立设置,不受此开关控制。")),
891
906
  React.createElement("h2", { style: { margin: "0 0 20px", fontSize: "20px", color: "#b7e9ff" } }, "数据管理"),
892
907
  stats ? React.createElement(React.Fragment, null,
893
908
  React.createElement("div", { style: { display: "flex", alignItems: "center", flexWrap: "wrap", gap: "12px" } },
@@ -914,10 +929,18 @@ function frame(now) {
914
929
  }
915
930
 
916
931
  function MemoryButton(props) {
917
- var settings = props.settings;
918
932
  var state = React.useState(false);
919
933
  var open = state[0];
920
934
  var setOpen = state[1];
935
+ var settingsState = React.useState(Object.assign({}, defaultSettings, props.settings));
936
+ var settings = settingsState[0], setSettings = settingsState[1];
937
+ var configBusyState = React.useState(false);
938
+ var configBusy = configBusyState[0], setConfigBusy = configBusyState[1];
939
+ var configErrorState = React.useState("");
940
+ var configError = configErrorState[0], setConfigError = configErrorState[1];
941
+ var costHintState = React.useState(false);
942
+ var costHint = costHintState[0], setCostHint = costHintState[1];
943
+ var configRequest = React.useRef(null);
921
944
  var guideState = React.useState(false);
922
945
  var guideOpen = guideState[0];
923
946
  var setGuideOpen = guideState[1];
@@ -940,6 +963,43 @@ function frame(now) {
940
963
  var pointer = React.useRef(null);
941
964
  var help = React.useRef(null);
942
965
  var dismissed = React.useRef(false);
966
+ React.useEffect(function () {
967
+ return function () { if (configRequest.current) configRequest.current.abort(); };
968
+ }, []);
969
+ React.useEffect(function () {
970
+ if (!open) return undefined;
971
+ var controller = new AbortController();
972
+ setConfigBusy(true);
973
+ readApi("/memory-engine/api/config", controller.signal).then(function (data) {
974
+ if (!controller.signal.aborted) { setSettings(Object.assign({}, defaultSettings, data)); setConfigError(""); }
975
+ }).catch(function (failure) {
976
+ if (!controller.signal.aborted) setConfigError("读取配置失败:" + failure.message);
977
+ }).finally(function () { if (!controller.signal.aborted) setConfigBusy(false); });
978
+ return function () { controller.abort(); };
979
+ }, [open]);
980
+ function saveAutoMemory(enabled) {
981
+ if (configRequest.current) return;
982
+ var previous = settings;
983
+ var controller = new AbortController();
984
+ var timer = setTimeout(function () { controller.abort(); }, 10000);
985
+ configRequest.current = controller;
986
+ setConfigBusy(true); setConfigError(""); setCostHint(false);
987
+ setSettings(Object.assign({}, settings, { autoMemory: Object.assign({}, settings.autoMemory, { enabled: enabled }) }));
988
+ requestApi("/memory-engine/api/config", { method: "PUT", headers: { "Content-Type": "application/json" },
989
+ body: JSON.stringify({ autoMemory: { enabled: enabled } }), signal: controller.signal }).then(function (data) {
990
+ setSettings(Object.assign({}, defaultSettings, data));
991
+ }).catch(function (failure) {
992
+ setSettings(previous);
993
+ setConfigError("保存失败:" + (controller.signal.aborted ? "请求超时或已中断,请刷新确认状态" : failure.message));
994
+ }).finally(function () { clearTimeout(timer); configRequest.current = null; setConfigBusy(false); });
995
+ }
996
+ function toggleAutoMemory() {
997
+ if (configBusy) return;
998
+ var enabled = settings.autoMemory && settings.autoMemory.enabled === true;
999
+ setConfigError("");
1000
+ if (!enabled && settings.showAutoMemoryCostHint !== false) setCostHint(true);
1001
+ else saveAutoMemory(!enabled);
1002
+ }
943
1003
  function closeGuide() {
944
1004
  dismissed.current = true;
945
1005
  setGuideOpen(false);
@@ -952,7 +1012,7 @@ function frame(now) {
952
1012
  setTab("map");
953
1013
  setOpen(Date.now());
954
1014
  }
955
- function closeMap() { setSelected(null); setOpen(false); }
1015
+ function closeMap() { setSelected(null); setCostHint(false); setOpen(false); }
956
1016
  function startPointer(event) {
957
1017
  if (event.target.closest("[data-memory-engine-detail], [data-memory-engine-guide-layer], [data-memory-engine-controls]")) { pointer.current = null; return; }
958
1018
  pointer.current = { x: event.clientX, y: event.clientY, moved: 0, version: selectionVersion.current };
@@ -1016,7 +1076,8 @@ function frame(now) {
1016
1076
  style: { position: "fixed", inset: 0, maxWidth: "none", maxHeight: "none", zIndex: 9999, background: "#03060c" },
1017
1077
  }, tab === "map" ? React.createElement(MemoryCanvas, { key: open, rendererRef: renderer, selectionVersion: selectionVersion, onSelect: setSelected })
1018
1078
  : tab === "memories" ? React.createElement(MemoryList, { onChanged: function () { setRetry(function (value) { return value + 1; }); } })
1019
- : React.createElement(MemorySettings, { onCleared: function () { openMap(); setGuideOpen(true); } }),
1079
+ : React.createElement(MemorySettings, { settings: settings, configBusy: configBusy, onToggle: toggleAutoMemory,
1080
+ onCleared: function () { openMap(); setGuideOpen(true); } }),
1020
1081
  tab !== "map" ? React.createElement("strong", { style: { position: "absolute", top: "23px", left: "24px", font: "600 18px system-ui,sans-serif", color: "#b7e9ff" } }, "记忆管理") : null,
1021
1082
  React.createElement("style", null, "@keyframes dsh-memory-detail-in{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:translateX(0)}}@media(prefers-reduced-motion:reduce){[data-memory-engine-detail]{animation:none!important}}"),
1022
1083
  React.createElement("div", {
@@ -1027,7 +1088,7 @@ function frame(now) {
1027
1088
  return React.createElement("button", { key: entry[0], type: "button", role: "tab", "aria-selected": tab === entry[0], "data-memory-engine-tab": entry[0],
1028
1089
  onClick: function () { setSelected(null); setTab(entry[0]); },
1029
1090
  style: Object.assign({}, actionStyle, { background: tab === entry[0] ? "#204762" : "#091422", borderColor: tab === entry[0] ? "#8acded" : "#35536b" }) }, entry[1]);
1030
- })), React.createElement("button", {
1091
+ })), React.createElement(AutoMemoryToggle, { settings: settings, busy: configBusy, onToggle: toggleAutoMemory, location: "header" }), React.createElement("button", {
1031
1092
  ref: help, type: "button", onClick: function () { setGuideOpen(true); },
1032
1093
  "aria-label": "查看使用指南", title: "使用指南", style: actionStyle,
1033
1094
  }, "?"), React.createElement("button", {
@@ -1035,7 +1096,11 @@ function frame(now) {
1035
1096
  onClick: closeMap,
1036
1097
  "aria-label": "关闭记忆图谱",
1037
1098
  style: actionStyle,
1038
- }, "关闭 ×")), selected ? React.createElement(MemoryDetails, {
1099
+ }, "关闭 ×")), configError ? React.createElement("p", { role: "alert", "data-memory-engine-config-error": "true",
1100
+ style: { position: "absolute", top: "50px", right: "18px", zIndex: 2, color: "#ff9c9c", background: "#271923", padding: "8px 12px", borderRadius: "8px", maxWidth: "min(600px,90vw)" } }, configError) : null,
1101
+ costHint ? React.createElement(ConfirmationDialog, { title: "开启自动记忆", message: "自动记忆会在每轮对话后调用一次模型,大约每轮几分钱。可以随时在这里关掉。实际费用以模型及计费方案为准。",
1102
+ submitLabel: "知道了,开启", busy: configBusy, onClose: function () { setCostHint(false); }, onConfirm: function () { saveAutoMemory(true); } }) : null,
1103
+ selected ? React.createElement(MemoryDetails, {
1039
1104
  node: selected, onClose: function () { setSelected(null); },
1040
1105
  onHighlight: function () { if (renderer.current) renderer.current.fireNeuron(selected.id); },
1041
1106
  }) : null, guideOpen ? React.createElement(GuideCard, {
package/lib/client.src.js CHANGED
@@ -15,7 +15,7 @@ window.__ModuleLoader__.load({
15
15
 
16
16
  /* __CANVAS_RENDERER__ */
17
17
 
18
- var defaultSettings = { hideButton: false, showGuideOnEmpty: true };
18
+ var defaultSettings = { hideButton: false, showGuideOnEmpty: true, autoMemory: { enabled: false, maxPerTurn: 2, dailyCap: 40 }, showAutoMemoryCostHint: true };
19
19
  var actionStyle = { padding: "7px 12px", borderRadius: "8px", border: "1px solid #6999b8", background: "#102236", color: "#e2f4ff", font: "inherit", cursor: "pointer" };
20
20
  var fieldStyle = { boxSizing: "border-box", padding: "10px 12px", borderRadius: "8px", border: "1px solid #35536b", background: "#091422", color: "#e2f4ff", font: "inherit", minWidth: 0 };
21
21
  var kinds = ["知识", "印象", "对话", "事件", "日记"];
@@ -435,6 +435,17 @@ window.__ModuleLoader__.load({
435
435
  onClick: function () { setOffset(page.items.length); }, style: Object.assign({}, actionStyle, { marginLeft: "12px", opacity: loading ? 0.5 : 1 }) }, "加载更多") : null));
436
436
  }
437
437
 
438
+ function AutoMemoryToggle(props) {
439
+ var enabled = props.settings.autoMemory && props.settings.autoMemory.enabled === true;
440
+ var maximum = props.settings.autoMemory && props.settings.autoMemory.maxPerTurn || 2;
441
+ return React.createElement("button", {
442
+ type: "button", role: "switch", "aria-checked": enabled, "aria-label": "自动记忆",
443
+ "data-memory-engine-auto-memory": props.location, disabled: props.busy, onClick: props.onToggle,
444
+ title: enabled ? "当前:开(每轮对话后会抽 0~" + maximum + " 条记忆)" : "当前:关(不会自动调用模型抽取记忆)",
445
+ style: Object.assign({}, actionStyle, { color: enabled ? "#e2f4ff" : "#8195aa", borderColor: enabled ? "#64bca0" : "#35536b", opacity: props.busy ? 0.6 : 1 }),
446
+ }, React.createElement("span", { "aria-hidden": "true", style: { color: enabled ? "#89f0bb" : "#8195aa", textShadow: enabled ? "0 0 8px #56d890" : "none" } }, enabled ? "●" : "○"), " 自动记忆");
447
+ }
448
+
438
449
  function MemorySettings(props) {
439
450
  var statsState = React.useState(null);
440
451
  var stats = statsState[0], setStats = statsState[1];
@@ -500,6 +511,10 @@ window.__ModuleLoader__.load({
500
511
  return React.createElement("section", { "aria-label": "记忆设置", "data-memory-engine-settings": "true",
501
512
  style: { position: "absolute", inset: "76px 0 0", overflowY: "auto", padding: "0 24px 32px", color: "#dbeaf8", font: "14px/1.7 system-ui,sans-serif" } },
502
513
  React.createElement("div", { style: { maxWidth: "900px", margin: "0 auto", padding: "28px", border: "1px solid #263f54", borderRadius: "14px", background: "#0b1624" } },
514
+ React.createElement("section", { style: { marginBottom: "24px", paddingBottom: "20px", borderBottom: "1px solid #263f54" } },
515
+ React.createElement("h2", { style: { margin: "0 0 12px", fontSize: "20px", color: "#b7e9ff" } }, "自动记忆"),
516
+ React.createElement(AutoMemoryToggle, { settings: props.settings, busy: props.configBusy, onToggle: props.onToggle, location: "settings" }),
517
+ React.createElement("p", { style: { color: "#9fb7ce", marginBottom: 0 } }, "默认关闭。开启后每轮对话会额外调用一次模型,抽取值得记住的事实;通常最多 2 条,可随时关闭。大约每轮几分钱,实际费用以模型及计费方案为准。自动导入是独立设置,不受此开关控制。")),
503
518
  React.createElement("h2", { style: { margin: "0 0 20px", fontSize: "20px", color: "#b7e9ff" } }, "数据管理"),
504
519
  stats ? React.createElement(React.Fragment, null,
505
520
  React.createElement("div", { style: { display: "flex", alignItems: "center", flexWrap: "wrap", gap: "12px" } },
@@ -526,10 +541,18 @@ window.__ModuleLoader__.load({
526
541
  }
527
542
 
528
543
  function MemoryButton(props) {
529
- var settings = props.settings;
530
544
  var state = React.useState(false);
531
545
  var open = state[0];
532
546
  var setOpen = state[1];
547
+ var settingsState = React.useState(Object.assign({}, defaultSettings, props.settings));
548
+ var settings = settingsState[0], setSettings = settingsState[1];
549
+ var configBusyState = React.useState(false);
550
+ var configBusy = configBusyState[0], setConfigBusy = configBusyState[1];
551
+ var configErrorState = React.useState("");
552
+ var configError = configErrorState[0], setConfigError = configErrorState[1];
553
+ var costHintState = React.useState(false);
554
+ var costHint = costHintState[0], setCostHint = costHintState[1];
555
+ var configRequest = React.useRef(null);
533
556
  var guideState = React.useState(false);
534
557
  var guideOpen = guideState[0];
535
558
  var setGuideOpen = guideState[1];
@@ -552,6 +575,43 @@ window.__ModuleLoader__.load({
552
575
  var pointer = React.useRef(null);
553
576
  var help = React.useRef(null);
554
577
  var dismissed = React.useRef(false);
578
+ React.useEffect(function () {
579
+ return function () { if (configRequest.current) configRequest.current.abort(); };
580
+ }, []);
581
+ React.useEffect(function () {
582
+ if (!open) return undefined;
583
+ var controller = new AbortController();
584
+ setConfigBusy(true);
585
+ readApi("/memory-engine/api/config", controller.signal).then(function (data) {
586
+ if (!controller.signal.aborted) { setSettings(Object.assign({}, defaultSettings, data)); setConfigError(""); }
587
+ }).catch(function (failure) {
588
+ if (!controller.signal.aborted) setConfigError("读取配置失败:" + failure.message);
589
+ }).finally(function () { if (!controller.signal.aborted) setConfigBusy(false); });
590
+ return function () { controller.abort(); };
591
+ }, [open]);
592
+ function saveAutoMemory(enabled) {
593
+ if (configRequest.current) return;
594
+ var previous = settings;
595
+ var controller = new AbortController();
596
+ var timer = setTimeout(function () { controller.abort(); }, 10000);
597
+ configRequest.current = controller;
598
+ setConfigBusy(true); setConfigError(""); setCostHint(false);
599
+ setSettings(Object.assign({}, settings, { autoMemory: Object.assign({}, settings.autoMemory, { enabled: enabled }) }));
600
+ requestApi("/memory-engine/api/config", { method: "PUT", headers: { "Content-Type": "application/json" },
601
+ body: JSON.stringify({ autoMemory: { enabled: enabled } }), signal: controller.signal }).then(function (data) {
602
+ setSettings(Object.assign({}, defaultSettings, data));
603
+ }).catch(function (failure) {
604
+ setSettings(previous);
605
+ setConfigError("保存失败:" + (controller.signal.aborted ? "请求超时或已中断,请刷新确认状态" : failure.message));
606
+ }).finally(function () { clearTimeout(timer); configRequest.current = null; setConfigBusy(false); });
607
+ }
608
+ function toggleAutoMemory() {
609
+ if (configBusy) return;
610
+ var enabled = settings.autoMemory && settings.autoMemory.enabled === true;
611
+ setConfigError("");
612
+ if (!enabled && settings.showAutoMemoryCostHint !== false) setCostHint(true);
613
+ else saveAutoMemory(!enabled);
614
+ }
555
615
  function closeGuide() {
556
616
  dismissed.current = true;
557
617
  setGuideOpen(false);
@@ -564,7 +624,7 @@ window.__ModuleLoader__.load({
564
624
  setTab("map");
565
625
  setOpen(Date.now());
566
626
  }
567
- function closeMap() { setSelected(null); setOpen(false); }
627
+ function closeMap() { setSelected(null); setCostHint(false); setOpen(false); }
568
628
  function startPointer(event) {
569
629
  if (event.target.closest("[data-memory-engine-detail], [data-memory-engine-guide-layer], [data-memory-engine-controls]")) { pointer.current = null; return; }
570
630
  pointer.current = { x: event.clientX, y: event.clientY, moved: 0, version: selectionVersion.current };
@@ -628,7 +688,8 @@ window.__ModuleLoader__.load({
628
688
  style: { position: "fixed", inset: 0, maxWidth: "none", maxHeight: "none", zIndex: 9999, background: "#03060c" },
629
689
  }, tab === "map" ? React.createElement(MemoryCanvas, { key: open, rendererRef: renderer, selectionVersion: selectionVersion, onSelect: setSelected })
630
690
  : tab === "memories" ? React.createElement(MemoryList, { onChanged: function () { setRetry(function (value) { return value + 1; }); } })
631
- : React.createElement(MemorySettings, { onCleared: function () { openMap(); setGuideOpen(true); } }),
691
+ : React.createElement(MemorySettings, { settings: settings, configBusy: configBusy, onToggle: toggleAutoMemory,
692
+ onCleared: function () { openMap(); setGuideOpen(true); } }),
632
693
  tab !== "map" ? React.createElement("strong", { style: { position: "absolute", top: "23px", left: "24px", font: "600 18px system-ui,sans-serif", color: "#b7e9ff" } }, "记忆管理") : null,
633
694
  React.createElement("style", null, "@keyframes dsh-memory-detail-in{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:translateX(0)}}@media(prefers-reduced-motion:reduce){[data-memory-engine-detail]{animation:none!important}}"),
634
695
  React.createElement("div", {
@@ -639,7 +700,7 @@ window.__ModuleLoader__.load({
639
700
  return React.createElement("button", { key: entry[0], type: "button", role: "tab", "aria-selected": tab === entry[0], "data-memory-engine-tab": entry[0],
640
701
  onClick: function () { setSelected(null); setTab(entry[0]); },
641
702
  style: Object.assign({}, actionStyle, { background: tab === entry[0] ? "#204762" : "#091422", borderColor: tab === entry[0] ? "#8acded" : "#35536b" }) }, entry[1]);
642
- })), React.createElement("button", {
703
+ })), React.createElement(AutoMemoryToggle, { settings: settings, busy: configBusy, onToggle: toggleAutoMemory, location: "header" }), React.createElement("button", {
643
704
  ref: help, type: "button", onClick: function () { setGuideOpen(true); },
644
705
  "aria-label": "查看使用指南", title: "使用指南", style: actionStyle,
645
706
  }, "?"), React.createElement("button", {
@@ -647,7 +708,11 @@ window.__ModuleLoader__.load({
647
708
  onClick: closeMap,
648
709
  "aria-label": "关闭记忆图谱",
649
710
  style: actionStyle,
650
- }, "关闭 ×")), selected ? React.createElement(MemoryDetails, {
711
+ }, "关闭 ×")), configError ? React.createElement("p", { role: "alert", "data-memory-engine-config-error": "true",
712
+ style: { position: "absolute", top: "50px", right: "18px", zIndex: 2, color: "#ff9c9c", background: "#271923", padding: "8px 12px", borderRadius: "8px", maxWidth: "min(600px,90vw)" } }, configError) : null,
713
+ costHint ? React.createElement(ConfirmationDialog, { title: "开启自动记忆", message: "自动记忆会在每轮对话后调用一次模型,大约每轮几分钱。可以随时在这里关掉。实际费用以模型及计费方案为准。",
714
+ submitLabel: "知道了,开启", busy: configBusy, onClose: function () { setCostHint(false); }, onConfirm: function () { saveAutoMemory(true); } }) : null,
715
+ selected ? React.createElement(MemoryDetails, {
651
716
  node: selected, onClose: function () { setSelected(null); },
652
717
  onHighlight: function () { if (renderer.current) renderer.current.fireNeuron(selected.id); },
653
718
  }) : null, guideOpen ? React.createElement(GuideCard, {
@@ -0,0 +1,74 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { readState, writeState } from './automation-state.js';
4
+ import { readUiConfig } from './config.js';
5
+
6
+ const listeners = new Map();
7
+ const writable = new Map([
8
+ ['autoMemory.enabled', value => typeof value === 'boolean'],
9
+ ['autoImport.enabled', value => typeof value === 'boolean'],
10
+ ['autoMemory.maxPerTurn', value => Number.isSafeInteger(value) && value >= 1 && value <= 5],
11
+ ['autoMemory.dailyCap', value => Number.isSafeInteger(value) && value >= 0 && value <= 500],
12
+ ['spinSpeed', value => Number.isFinite(value) && value >= 0.05 && value <= 1.5],
13
+ ['showGuideOnEmpty', value => typeof value === 'boolean'],
14
+ ['hideButton', value => typeof value === 'boolean'],
15
+ ]);
16
+ const keyFor = home => path.resolve(home).replaceAll('\\', '/').toLowerCase();
17
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
18
+
19
+ export function onConfigUpdate(home, listener) {
20
+ const key = keyFor(home);
21
+ if (!listeners.has(key)) listeners.set(key, new Set());
22
+ const group = listeners.get(key);
23
+ group.add(listener);
24
+ return () => { group.delete(listener); if (!group.size) listeners.delete(key); };
25
+ }
26
+
27
+ export function updateConfig(home, changes, { now = Date.now, warn = () => {} } = {}) {
28
+ const invalid = message => { throw Object.assign(new Error(message), { status: 400 }); };
29
+ if (!object(changes)) invalid('配置修改必须是 JSON 对象');
30
+ const fields = [];
31
+ for (const [key, value] of Object.entries(changes)) {
32
+ if (key === 'autoMemory' || key === 'autoImport') {
33
+ if (!object(value) || !Object.keys(value).length) invalid(`${key} 必须包含可修改字段`);
34
+ for (const [name, item] of Object.entries(value)) fields.push([`${key}.${name}`, item]);
35
+ } else fields.push([key, value]);
36
+ }
37
+ if (!fields.length) invalid('至少提供一个可修改字段');
38
+ for (const [key, value] of fields) {
39
+ if (!writable.has(key) || Object.hasOwn(changes, key) && key.includes('.')) invalid(`不支持修改 ${key}`);
40
+ if (!writable.get(key)(value)) invalid(`${key} 类型或范围错误`);
41
+ }
42
+ const directory = path.join(home, 'memory-engine');
43
+ const file = path.join(directory, 'config.json');
44
+ const current = readState(file, {});
45
+ if (!object(current)) throw new Error('现有配置必须是 JSON 对象');
46
+ const next = { ...current };
47
+ for (const key of Object.keys(changes)) {
48
+ if (key === 'autoMemory' || key === 'autoImport') next[key] = { ...current[key], ...changes[key] };
49
+ else next[key] = changes[key];
50
+ }
51
+ if (changes.autoMemory?.enabled === true) next.showAutoMemoryCostHint = false;
52
+ fs.mkdirSync(directory, { recursive: true });
53
+ const prefix = `config.json.bak-${now()}`;
54
+ const suffixes = fs.readdirSync(directory).filter(name => name.startsWith(prefix + '-'))
55
+ .map(name => Number(name.slice(prefix.length + 1))).filter(Number.isSafeInteger);
56
+ let suffix = suffixes.length ? Math.max(...suffixes) + 1 : 0;
57
+ let name = suffix ? `${prefix}-${suffix}` : prefix;
58
+ while (fs.existsSync(path.join(directory, name))) name = `${prefix}-${++suffix}`;
59
+ const backup = path.join(directory, name);
60
+ if (fs.existsSync(file)) fs.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL);
61
+ else fs.writeFileSync(backup, '{}\n', { flag: 'wx', mode: 0o600 });
62
+ writeState(file, next);
63
+ for (const listener of listeners.get(keyFor(home)) ?? []) {
64
+ try { listener(changes); } catch (error) { try { warn(`配置已保存,任务状态更新失败:${error.message}`); } catch {} }
65
+ }
66
+ const backups = fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
67
+ const match = /^config\.json\.bak-(\d+)(?:-(\d+))?$/u.exec(entry.name);
68
+ return entry.isFile() && match ? [{ name: entry.name, time: Number(match[1]), suffix: Number(match[2] ?? 0) }] : [];
69
+ }).sort((left, right) => right.time - left.time || right.suffix - left.suffix);
70
+ for (const old of backups.slice(5)) {
71
+ try { fs.unlinkSync(path.join(directory, old.name)); } catch (error) { try { warn(`配置已保存,清理旧备份失败:${error.message}`); } catch {} }
72
+ }
73
+ return { ...readUiConfig(home, { warn }), backup: name };
74
+ }
package/lib/config.js CHANGED
@@ -14,7 +14,8 @@ export function readUiConfig(home = defaultHome(), options = {}) {
14
14
  const config = readConfig(home);
15
15
  return { hideButton: config.hideButton === true, showGuideOnEmpty: config.showGuideOnEmpty !== false,
16
16
  hasPersona: readPersona(config, options) !== null, personaTitle: config.personaTitle, spinSpeed: config.spinSpeed,
17
- autoMemory: { enabled: config.autoMemory.enabled, maxPerTurn: config.autoMemory.maxPerTurn },
17
+ autoMemory: { enabled: config.autoMemory.enabled, maxPerTurn: config.autoMemory.maxPerTurn, dailyCap: config.autoMemory.dailyCap },
18
+ showAutoMemoryCostHint: config.showAutoMemoryCostHint !== false,
18
19
  autoImport: { enabled: config.autoImport.enabled, sourceCount: config.autoImport.sources.length },
19
20
  lastError: readLastError(path.join(home, 'memory-engine', 'auto-log.json'), options.warn) };
20
21
  }
package/lib/index.js CHANGED
@@ -46,7 +46,7 @@ export function createTools(defineTool, store = new MemoryStore(), { map = gener
46
46
  }),
47
47
  defineTool({
48
48
  name: 'memory_recall',
49
- description: '按话题查跨会话的本地记忆,并联想相关条目。q 使用具体关键词,如「古茗」「光遇 蜡烛」。与 QQ 桥接记忆独立。',
49
+ description: '按话题查跨会话的本地记忆,并联想相关条目。q 使用具体关键词,如「手冲」「阅读 书签」。与 QQ 桥接记忆独立。',
50
50
  parameters: {
51
51
  q: { type: 'string', required: true, description: '要查的话题或关键词' },
52
52
  limit: { type: 'number', description: '默认 8,最大 20' },
package/lib/onboard.js CHANGED
@@ -1,4 +1,11 @@
1
+ // 引导文案(0.7.0):先说自动记忆,再说手动的路
2
+ // 原则:看到「三条」就知道该干什么,不需要读文档。
3
+
4
+ // 可选的手动提示词:自动记忆关闭时才需要它。
5
+ // 不要再把它说成「必须加进角色卡」——0.7.0 起自动记忆能自己抽。
1
6
  export const ONBOARD_PROMPT = [
7
+ '(自动记忆已开启时可以不用这一段)',
8
+ '',
2
9
  '聊天时遇到这些,用 memory_save 记下来:',
3
10
  ' · 对方的称呼、偏好、忌讳、习惯(kind="印象",who 填对方名字)',
4
11
  ' · 答应过的事、约定、承诺(kind="对话")',
@@ -11,8 +18,16 @@ export const ONBOARD_PROMPT = [
11
18
  ].join('\n');
12
19
 
13
20
  export const ONBOARD_STEPS = [
14
- { title: '让机器人开始记', body: '把这段提示词加进机器人的角色卡或提示词里。' },
15
- { title: '或者导入旧记忆', body: '在对话里说:「把 D:\\bot\\memories.json 导进记忆库」。支持 JSON、Markdown 和纯文本,先预览,确认后导入。' },
16
- { title: '聊几句,回来点刷新', body: '有了新记忆后,点「刷新图谱」就能看到。' },
21
+ {
22
+ title: '先打开自动记忆',
23
+ body: '图谱右上角有个「自动记忆」开关,默认是关的。点一下变成 ● 之后,每轮对话结束会自动抽 0~2 条记下来——这时候你什么都不用做。会调用模型,大约每轮几分钱,随时可以再点掉。',
24
+ },
25
+ {
26
+ title: '想把旧记录也收进来',
27
+ body: '跟 AI 说「把 D:\\bot\\memories.json 导进记忆库」,它会调 memory_import。支持 JSON、Markdown、纯文本和 DSH 会话记录;默认只预览,确认后才写入。',
28
+ },
29
+ {
30
+ title: '回来这里看',
31
+ body: '「记忆」页签能搜索、改错字、删除、导出;「图谱」页签是所有记忆画成的球体,中间那颗金色的是你的人设卡(在 config.json 里配 personaFile)。拖拽旋转,点任意光点看它连着什么。数据存在本机 ~/.dsh/memory-engine/,纯 JSON 明文,不会上传到任何地方。',
32
+ },
17
33
  ];
18
-
package/lib/routes.js CHANGED
@@ -3,6 +3,7 @@ import { computeMapData } from './map.js';
3
3
  import { renderHtml } from './render/render.mjs';
4
4
  import { ONBOARD_PROMPT, ONBOARD_STEPS } from './onboard.js';
5
5
  import { readUiConfig } from './config.js';
6
+ import { updateConfig } from './config-write.js';
6
7
  import { chars, retention, titleOf } from './decay.js';
7
8
  import { scoreMemories } from './render/score.mjs';
8
9
 
@@ -74,7 +75,7 @@ function listMemories(store, query) {
74
75
  items: items.slice(offset, offset + size).map(row => ({ ...row, preview: chars(row.text, 60) + (Array.from(row.text).length > 60 ? '…' : '') })) };
75
76
  }
76
77
 
77
- export function createMemoryHandler({ store, warn = () => {} }) {
78
+ export function createMemoryHandler({ store, warn = () => {}, beforeConfigUpdate = () => {} }) {
78
79
  return async (request, response) => {
79
80
  let methods = ['GET'];
80
81
  const reply = (status, payload, html = false) => {
@@ -98,12 +99,16 @@ export function createMemoryHandler({ store, warn = () => {} }) {
98
99
  catch { return fail(404, '找不到这个记忆接口'); }
99
100
  const item = /^\/memory-engine\/api\/item\/([^/%\\]+)$/u.exec(pathname);
100
101
  if (pathname.includes('..') || pathname.includes('\\') || (!paths.has(pathname) && !item)) return fail(404, '找不到这个记忆接口');
101
- methods = item ? ['GET', 'PUT', 'DELETE'] : pathname === `${base}/api/clear` ? ['POST'] : ['GET'];
102
+ methods = item ? ['GET', 'PUT', 'DELETE'] : pathname === `${base}/api/clear` ? ['POST'] : pathname === `${base}/api/config` ? ['GET', 'PUT'] : ['GET'];
102
103
  if (!allowedMethods.has(request.method) || !methods.includes(request.method)) return fail(405, `此接口仅支持 ${methods.join('、')}`);
103
104
  if (Number(request.headers['content-length']) > 1024 * 1024) return fail(413, '请求体不能超过 1MB');
104
105
  if (request.method === 'PUT' || request.method === 'POST') {
105
106
  if (!/^application\/json(?:\s*;|$)/i.test(request.headers['content-type'] ?? '')) return fail(400, '请求体必须使用 application/json');
106
107
  const body = await readBody(request);
108
+ if (pathname === `${base}/api/config`) {
109
+ beforeConfigUpdate(body);
110
+ return reply(200, { ok: true, data: updateConfig(path.dirname(path.dirname(store.file)), body, { warn }) });
111
+ }
107
112
  return reply(200, { ok: true, data: item ? store.updateItem(item[1], body) : store.clear(body) });
108
113
  }
109
114
  if (request.headers['transfer-encoding'] || Number(request.headers['content-length']) > 0) return fail(400, request.method === 'GET' ? '此只读接口不接受请求体' : '此接口不接受请求体');
@@ -130,7 +135,8 @@ export function createMemoryHandler({ store, warn = () => {} }) {
130
135
  } catch (error) {
131
136
  if ([400, 404, 413].includes(error.status)) return fail(error.status, error.message);
132
137
  warn(`图谱请求失败:${error.message}`);
133
- return fail(500, '读取记忆失败,请检查本地插件日志');
138
+ return fail(500, request.method === 'PUT' && (request.url ?? '').split('?')[0] === `${base}/api/config`
139
+ ? '配置保存失败,请检查数据目录权限或本地插件日志' : '读取记忆失败,请检查本地插件日志');
134
140
  }
135
141
  };
136
142
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memory-engine",
3
- "version": "0.7.0-beta.6b",
3
+ "version": "0.7.1",
4
4
  "description": "Local DSH memory engine with neuron network visualization and private file import",
5
5
  "keywords": [
6
6
  "dsh",
@@ -14,7 +14,7 @@
14
14
  "visualization"
15
15
  ],
16
16
  "license": "MIT",
17
- "author": "阿木",
17
+ "author": "dsh-memory-engine contributors",
18
18
  "type": "module",
19
19
  "main": "./lib/index.js",
20
20
  "exports": {
@@ -27,7 +27,10 @@
27
27
  },
28
28
  "files": [
29
29
  "lib/",
30
- "scripts/",
30
+ "scripts/render-map.mjs",
31
+ "scripts/sync-qq.mjs",
32
+ "scripts/build-client.mjs",
33
+ "scripts/lib/",
31
34
  "cordis.patch.yml",
32
35
  "README.md",
33
36
  "LICENSE"