dsh-memory-tiers 0.8.2 → 0.8.3

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/index.mjs CHANGED
@@ -1,826 +1,939 @@
1
- /**
2
- * dsh 插件适配层 —— 接 memory5(六层记忆)。只干"接线",不含记忆逻辑。
3
- *
4
- * v1(memory.mjs)→ v5(memory5.mjs)的四个接入口:
5
- * ① 写:订阅 session/event,turn 结束时把这一轮合成记忆(自动,不靠模型自觉)
6
- * ② 读:systemPrompt.section 注入 buildBlock(L3 视图 + L2 + L1)
7
- * ③ 查:memory_search / memory_attend 工具(搜到不涨热度,确认才涨)
8
- * ④ 维护:定时 flushSummaries(deferred 摘要)+ 周期 sweep
9
- *
10
- * 关于自动写入:dsh 的 session 是事件溯源的,每次 append 都会 post-commit 广播
11
- * `session/event`。一轮对话里会有 user/message、tool/call、tool/result、turn/end。
12
- * 我们**按 turn 聚合**——中途写会得到「问了还没答」的半截对话,而且写入次数翻倍。
13
- *
14
- * 没有 embedding key 时语义层整体关闭(v0.5.2 起不再用本地哈希向量充数):
15
- * 检索只走关键词 + 热度,向量文件不读也不写,接上模型后原样可用。
16
- * 融合 / 摘要不受影响——它们默认走 dsh 当前会话模型(v0.6.0),不需要自己的 key。
17
- */
18
-
19
- import { homedir } from 'node:os'
20
- import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'
21
- import { join } from 'node:path'
22
- import { createMemory5, LAYER, DEFAULTS as MEM_DEFAULTS } from './memory5.mjs'
23
- import { createMemory6, DEFAULTS6 as MEM6_DEFAULTS } from './memory6.mjs'
24
- import { createSilicon, keyFromAstrbot } from './silicon.mjs'
25
- import { compactSurface } from './compaction.mjs'
26
- import { installSettingsApi } from './settings.mjs'
27
- import { createSessionCaller } from './dshllm.mjs'
28
-
29
- export const name = 'memory-tiers'
30
- export const inject = ['tools']
31
-
32
- const PLUGIN_DEFAULTS = {
33
- dir: '',
34
- // ★ 布局:'split' = 六文件(memory6,默认);'legacy' = 老的单文件分片(memory5,回退用)
35
- layout: 'split',
36
- // ★ 多写者标识:几个程序共用同一个数据目录时,每个必须不一样。
37
- // split 布局下它决定 L1.tmp/<writer>.jsonl 和 signals/<writer>.jsonl 用哪个名字。
38
- // 同名 = 互相往同一份 temp 里写(不会丢,但分不清是谁)。
39
- writer: 'default',
40
- // 'siliconflow' = 嵌入/重排用硅基流动那两个免费的(bge-m3 / bge-reranker-v2-m3)
41
- // 'custom' = 用 models 里自己填的来源
42
- preset: 'siliconflow',
43
- apiKey: '',
44
- apiBase: 'https://api.siliconflow.cn/v1',
45
- // 每个阶段:{ source: 'session' } 跟当前会话模型,或 { base, apiKey, model } 走外部
46
- models: { chat: { source: 'session' }, summary: { source: 'session' } },
47
- noThink: { chat: true, summary: true },
48
- bootMaxChars: 0, // 0 = 不限制,注入量由 L1/L2/L3 各自容量决定
49
- budgetTokens: 0,
50
- autoCapture: true, // 自动订阅 session/event 写入 L1
51
- userCaptureChars: 2000, // 单条用户消息最多记多少字
52
- toolCaptureChars: 500, // 工具输出截断(bash 一次能吐几万字,L1 才 3000 字)
53
- // 一轮会话里 assistant 的正文:拼起来再截断(不是每条消息截)。L1 要靠它当"最近历史"。
54
- botCaptureChars: 400,
55
- // split 布局:一段会话结束后,隔这么久没新消息才允许整理(避免聊到一半被摘要腰斩)
56
- quietMs: 10 * 60 * 1000,
57
- organizeEveryMs: 5 * 60 * 1000, // 整理触发:端结束 + 这个定时器补漏
58
- // L1/L2/L3 恒定注入,没有开关——三层就是上下文的全部「记忆」,可调的是各自容量。
59
- refreshMinMs: 30 * 60 * 1000, // L3 视图(DL3 按热度×档位排序)的重算间隔。
60
- // L1/L2 不节流,每轮现算并追加在末尾;只有 L3 排在注入块最前,它一动后面缓存全断。
61
- flushEveryMs: 5 * 60 * 1000,
62
- sweepEveryMs: 6 * 60 * 60 * 1000,
63
- autoCompact: true, // 每轮结束把 dsh 自带历史压成一条占位(零 LLM)
64
- compactKeepTurns: 0, // 0 = 完全取替 dsh 会话历史(L1 里已经有历史了)
65
- compactArchiveChars: 1200, // 每轮压缩归档进记忆的字数上限
66
- }
67
-
68
- /** 数据放哪:优先 DSH_HOME,否则 ~/.dsh。v5 用独立目录,别和 v1 的 store 混。 */
69
- /**
70
- * 记忆数据放哪。优先级(高 → 低):
71
- * 1. 插件配置里的 dir(dsh 侧 cordis 配置,跨 agent 移植时最可靠)
72
- * 2. 环境变量 MEMORY_TIERS_DIR
73
- * 3. <DSH_HOME 或 ~/.dsh>/storages/memory-v5
74
- *
75
- * ★ 为什么不能从 config.json 读 dir:config.json 就在 dir 里面,
76
- * 要先知道 dir 才能读它,鸡生蛋。想换目录只有上面两种方式,
77
- * 换完把旧目录整个拷过去即可(store.json / vectors-part-*.json / archive/ 都在里面,自包含)。
78
- */
79
- function expandHome(p) {
80
- const s = String(p || '').trim()
81
- if (!s) return ''
82
- if (s === '~') return homedir()
83
- if (s.startsWith('~/') || s.startsWith('~\\')) return join(homedir(), s.slice(2))
84
- return s
85
- }
86
-
87
- /** 指针文件:{ "dir": "D:/shared/memory" }
88
- *
89
- * 为什么需要它:config.json 在**数据目录里面**,要先知道目录才能读它——
90
- * 所以"数据目录在哪"这件事本身不能写在 config.json 里(鸡生蛋)。
91
- * 放一个固定位置的指针文件,设置页就能改目录了。 */
92
- function pointerFile() {
93
- const home = process.env.DSH_HOME || join(homedir(), '.dsh')
94
- return join(home, 'memory-tiers.json')
95
- }
96
-
97
- function readPointerDir() {
98
- try {
99
- const raw = JSON.parse(readFileSync(pointerFile(), 'utf8'))
100
- return expandHome(raw && raw.dir)
101
- } catch { return '' }
102
- }
103
-
104
- function defaultDir() {
105
- const fromPointer = readPointerDir()
106
- if (fromPointer) return fromPointer
107
- const fromEnv = expandHome(process.env.MEMORY_TIERS_DIR)
108
- if (fromEnv) return fromEnv
109
- const home = process.env.DSH_HOME || join(homedir(), '.dsh')
110
- return join(home, 'storages', 'memory-v5')
111
- }
112
-
113
- /**
114
- * 从数据目录读 config.json。
115
- *
116
- * 为什么不用 schemastery 声明配置 schema(那样设置页会有表单):
117
- * 插件是 link 安装的,**import 不到 dsh 自己的包**(schemastery 也一样),
118
- * 和当年 defineTool 那个坑同源。所以配置走 JSON 文件,零依赖。
119
- *
120
- * 文件不存在就返回空对象;写坏了也不抛错,只当没配。
121
- */
122
- function loadFileConfig(dir) {
123
- const p = join(dir, 'config.json')
124
- if (!existsSync(p)) return {}
125
- try {
126
- const raw = JSON.parse(readFileSync(p, 'utf8').replace(/^\uFEFF/, ''))
127
- return raw && typeof raw === 'object' ? raw : {}
128
- } catch { return {} }
129
- }
130
-
131
- /** 每个工具的返回值都是纯文本,渲染函数共用一个 */
132
- const textRender = (_args, value) => [{ type: 'text', text: String(value ?? '') }]
133
-
134
- /**
135
- * 找 embedding key:插件配置 > 环境变量 > AstrBot 配置
136
- * 找不到就返回 null(降级本地向量),不抛错——插件加载不该被 key 卡死。
137
- */
138
- function resolveAuth(cfg) {
139
- if (cfg.apiKey) return { apiKey: cfg.apiKey, base: cfg.apiBase }
140
- const env = process.env.SILICONFLOW_API_KEY || process.env.SILICON_API_KEY
141
- if (env) return { apiKey: env, base: cfg.apiBase }
142
- const cands = [
143
- join(process.env.DSH_HOME || join(homedir(), '.dsh'), '..', '.astrbot', 'data', 'cmd_config.json'),
144
- join(homedir(), '.astrbot', 'data', 'cmd_config.json'),
145
- ]
146
- for (const p of cands) {
147
- const hit = keyFromAstrbot(p)
148
- if (hit) return { apiKey: hit.apiKey, base: hit.base || cfg.apiBase }
149
- }
150
- return null
151
- }
152
-
153
- /** 本地哈希向量已移除(v0.5.2):它给的相似度和真实语义无关,
154
- * 在 1024 维下和真模型无法区分,只会制造"看起来在检索"的假象。
155
- * 没有模型时宁可让语义检索完全关闭,只走关键词。 */
156
-
157
- /**
158
- * 把记忆块注入上下文。
159
- *
160
- * ★ 优先用 systemPrompt.context() 而不是 section(),原因是缓存:
161
- * - section() 拼进系统提示词。系统提示词整块是「前缀」,记忆一变,
162
- * 它后面那段稳定内容(工具说明等)也跟着重新计费。实测记忆块排在 2600
163
- * (正好撞上 TOOL_WORKFLOW),把它后面的 3100 字也拖下水了,每轮多花约 4000 token。
164
- * - context() 是「durable user-role snapshot」:作为一条 user 消息追加进历史,
165
- * 而且框架会比对文本,内容没变就不追加。系统提示词保持逐字节不变,
166
- * 缓存全命中;只有记忆真的变了才多一条消息。
167
- * section() 只作为老版本兜底,order 放到 10200 之后(persona suffix 之后)。
168
- */
169
- function injectPrompt(ctx, getText) {
170
- const sp = ctx.systemPrompt
171
- if (!sp) return false
172
- const tryOnce = (fn) => { try { fn(); return true } catch { return false } }
173
-
174
- // 优先:追加式快照,不破坏系统提示词缓存
175
- if (typeof sp.context === 'function') {
176
- const ok = tryOnce(() => sp.context({
177
- name: 'memory-tiers',
178
- order: 250, // 排在 working directory(100)/sandbox(110) 之后
179
- text: getText,
180
- interpolate: false, // 记忆里可能出现 {{ }},别当变量替换
181
- }))
182
- if (ok) return true
183
- }
184
- // 兜底:老版本没有 context(),塞系统提示词末尾(10200 之后已是最末)
185
- if (typeof sp.section === 'function') {
186
- return tryOnce(() => sp.section({
187
- name: 'memory-tiers',
188
- order: 10300,
189
- text: getText,
190
- interpolate: false,
191
- }))
192
- }
193
- return false
194
- }
195
-
196
- /**
197
- * 归档审计日志:每轮结束记一行,落到 <dir>/capture.log。
198
- *
199
- * 为什么要有:K1 那个 bug(tool/result 的 callId 读错字段)让所有 action 静默消失,
200
- * 内存里、日志里都看不出来,只有去数 store.json 才发现 action 是 0 条。
201
- * 有了这个文件,「本轮抓到几条 / 写了几条 / L1 里有几条」一目了然。
202
- */
203
- function appendCaptureLog(dir, line) {
204
- try {
205
- mkdirSync(dir, { recursive: true })
206
- appendFileSync(join(dir, 'capture.log'), `${new Date().toISOString()} ${line}\n`, 'utf8')
207
- } catch { /* 日志写不了不影响主流程 */ }
208
- }
209
-
210
- /** 从 ContentBlock[] 里只取可见文本(reasoning 是草稿、image 是附件,都不进记忆) */
211
- function takeText(blocks) {
212
- if (!Array.isArray(blocks)) return ''
213
- return blocks
214
- .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
215
- .map((b) => b.text)
216
- .join('\n')
217
- .trim()
218
- }
219
-
220
- export function apply(ctx, config = {}) {
221
- const baseDir = config.dir || defaultDir()
222
- // 优先级:默认值 < config.json < 调用方传入 config
223
- const cfg = { ...PLUGIN_DEFAULTS, ...loadFileConfig(baseDir), ...config }
224
- const log = ctx.logger ?? console
225
- const dir = baseDir
226
-
227
- // 第一次启动时落一份示例配置,用户知道该去哪改(JSON 不能写注释,用字段名兜住)
228
- try {
229
- mkdirSync(dir, { recursive: true })
230
- const p = join(dir, 'config.json')
231
- if (!existsSync(p)) {
232
- writeFileSync(p, JSON.stringify({
233
- // preset: 'siliconflow' = 嵌入/重排用硅基流动那两个免费模型;'custom' = 自己填下面的 models
234
- preset: 'siliconflow',
235
- // ★ 布局:split = 六文件(L1/L2/L3/DL1/DL2/DL3);legacy = 老的单文件分片(回退用)
236
- layout: 'split',
237
- // ★ 写者标识:多个程序共用这个目录时,每个必须改成不一样的
238
- // (dsh / astrbot / ...)。split 布局下它决定写 L1.tmp/<writer>.jsonl 里的哪一份。
239
- // 填成一样的两个程序会往同一个 temp 里写(不丢,但分不清是谁)。
240
- writer: 'default',
241
- apiKey: '',
242
- apiBase: 'https://api.siliconflow.cn/v1',
243
- bootMaxChars: 0, // 0 = 不限制,注入量由 L1/L2/L3 各自容量决定
244
- autoCapture: true,
245
- userCaptureChars: 2000,
246
- toolCaptureChars: 500,
247
- botCaptureChars: 400, // 一轮回复拼起来后截断(L1 要当最近历史用)
248
- quietMs: 600000, // 安静 10 分钟才允许整理 L1(别腰斩正在聊的对话)
249
- organizeEveryMs: 300000, // 整理定时器:5 分钟补一轮
250
- budgetTokens: 0,
251
- refreshMinMs: 1800000, // L3 快照刷新间隔,见 _说明
252
- autoCompact: true,
253
- compactKeepTurns: 0, // 0 = dsh 历史一条不留,全交给记忆(L1 就是历史)
254
- compactArchiveChars: 1200,
255
- memoryCfg: { longMaxChars: 4000, hotMaxChars: 3000 },
256
- // 每个模型阶段可单独覆盖。两种来源:
257
- // source: 'session' —— 走 dsh 当前会话模型(对话用 flash 就 flash,不用额外 key)
258
- // { base, apiKey, model } —— 走外部 provider
259
- // 默认:嵌入/重排走硅基流动免费模型,融合/摘要跟当前会话模型。
260
- models: {
261
- chat: { source: 'session' },
262
- summary: { source: 'session' },
263
- },
264
- // 关思考:融合只要一个词,关掉更快更省;摘要要它想清楚,默认不关
265
- noThink: { chat: true, summary: true },
266
- _说明: [
267
- 'apiKey 填硅基流动的 key(也可以设环境变量 SILICONFLOW_API_KEY);',
268
- '留空则语义检索关闭:检索只走关键词,向量文件不读也不写(不造假向量)。',
269
- ' 融合 / 摘要不受影响,它们默认跟当前会话模型走。改完重启 dsh 生效。',
270
- 'bootMaxChars = 总闸门,0 = 不限制(推荐)。注入多少由 memoryCfg 的三层容量自己决定。',
271
- 'memoryCfg.longMaxChars = L3 视图容量(长期记忆能进上下文的字数)。',
272
- '三层(L1/L2/L3)恒定注入,没有开关:想多要或少要就改 memoryCfg 里各自的容量。',
273
- 'refreshMinMs = L3 视图的刷新间隔(毫秒),默认 1800000(30 分钟)。',
274
- ' 这是全局唯一需要节流的地方:L3 排在注入块最前,它一变,后面 L2/L1 的缓存全断。',
275
- ' L1/L2 不节流——写了就带上下一轮,追加在末尾,不影响前面的缓存。',
276
- 'autoCompact = 每轮结束时把 dsh 自带历史压成一条占位(零 LLM),被压掉的内容先归档进记忆。',
277
- 'compactKeepTurns = 0 表示完全取替 dsh 会话历史(L1 里已经有历史了)。',
278
- ' 想多留一点临时内容,把 L1 容量(memoryCfg.hotMaxChars)调大即可。',
279
- 'preset = siliconflow 时,嵌入用 BAAI/bge-m3、重排用 BAAI/bge-reranker-v2-m3(都免费);custom 则用 models 里填的。',
280
- 'models[stage].source = session 表示这个阶段跟着当前对话用的模型走(不用额外 key);api 则走下面填的地址/key。',
281
- 'noThink = 是否关思考。默认都关(实测开思考摘要慢 3 倍);想要更细的摘要可以给 summary 打开。',
282
- 'writer = 写者标识。多个程序共用本目录时每个必须不同:split 布局下各写 L1.tmp/<writer>.jsonl,',
283
- ' 读的时候合起来。同名会分不清是谁。改完重启。',
284
- 'layout = split(六文件,默认)/ legacy(老的单文件分片,回退用)。',
285
- 'botCaptureChars = 一轮回复拼起来后截多少字;L1 容量(memoryCfg.hotMaxChars,默认 3000)达到就整理。',
286
- 'dir = 数据目录本身。它**不在这个文件里**(本文件就在数据目录里面,鸡生蛋),',
287
- ' 要改就改 ~/.dsh/memory-tiers.json 里的 {\"dir\":\"...\"},或设环境变量 MEMORY_TIERS_DIR。',
288
- ],
289
- }, null, 2), 'utf8')
290
- }
291
- } catch { /* 写不了不影响主流程 */ }
292
-
293
- // ── 会话模型通道:融合 / 摘要可以直接跟着当前对话用的模型走 ──
294
- // 拿不到 llm 服务也不影响,那些阶段会回落到自己的 HTTP 配置
295
- const session = createSessionCaller(() => ctx, { log })
296
- const sessionInfo = session.info()
297
- if (sessionInfo.available) {
298
- log.info?.('[memory-tiers] 会话模型可用:%s / %s(融合、摘要默认跟着它走)',
299
- sessionInfo.provider, sessionInfo.model)
300
- } else {
301
- log.warn?.('[memory-tiers] 会话模型不可用(%s):融合 / 摘要按 models 里的外部配置走',
302
- sessionInfo.reason)
303
- }
304
-
305
- // 哪些阶段走会话模型:默认 chat(融合判关系)+ summary;用户显式改 'api' 才走 HTTP
306
- const sessionStages = ['chat', 'summary']
307
- .filter((s) => {
308
- const m = cfg.models?.[s]
309
- // 没配就默认跟会话模型;配了 source:'api' 或有外部 base/apiKey 就走 HTTP
310
- if (!m) return true
311
- if (m.source === 'api') return false
312
- if (m.source === 'session') return true
313
- return !(m.base || m.apiKey || m.model)
314
- })
315
-
316
- // ── 模型层:有 key 接 silicon,没 key 降级本地向量 ──
317
- const auth = resolveAuth(cfg)
318
- let silicon = null
319
- if (auth || (sessionInfo.available && sessionStages.length)) {
320
- try {
321
- silicon = createSilicon({
322
- apiKey: auth?.apiKey || '',
323
- base: auth?.base || cfg.apiBase,
324
- budgetTokens: cfg.budgetTokens,
325
- // 每个阶段可单独指定 { base, apiKey, model };留空继承上面的 apiKey/base
326
- models: cfg.models || {},
327
- noThink: cfg.noThink || {},
328
- // 没有 key 时,embed/rerank 照样起不来,但 chat/summary 还能走会话模型
329
- session: sessionInfo.available && sessionStages.length
330
- ? { call: session.call, stages: sessionStages, model: sessionInfo.model }
331
- : null,
332
- onFallback: (fmt, ...args) => log.warn?.('[memory-tiers] ' + fmt, ...args),
333
- })
334
- } catch (err) {
335
- log.warn?.('[memory-tiers] silicon 初始化失败:%s', err?.message ?? err)
336
- }
337
- } else {
338
- log.warn?.('[memory-tiers] 未连接模型(配置 apiKey / 环境变量 SILICONFLOW_API_KEY):'
339
- + '语义检索关闭,只用关键词命中、不做摘要。'
340
- + '去「设置 → 记忆 → 模型连接」填 API Key 后重启即可')
341
- }
342
-
343
- const models = silicon
344
- ? {
345
- embed: (t) => silicon.embed(t),
346
- classify: (t) => silicon.classify(t),
347
- classifyBatch: (t) => silicon.classifyBatch(t),
348
- judgeRelation: (a, b) => silicon.relation(a, b),
349
- // ★ 参数顺序要包装:memory5 调 (lines, channel, folks),silicon 是 (texts, hint, channel, people)
350
- summarize: (lines, channel, folks) => silicon.summarize(lines, '', channel, folks),
351
- summarizeWithSources: (lines, ch, folks, hint) => silicon.summarizeWithSources(lines, ch, folks, hint),
352
- rerank: (q, docs) => silicon.rerank(q, docs),
353
- }
354
- // 没有模型就什么都不给:不装假向量。memory5 会自动退到关键词打分
355
- : {}
356
-
357
- // 向量指纹:写进 vectors.meta.json,换 embed 模型时能被识别出来而不是静默变乱。
358
- // 维度相同的模型(Qwen3-Embedding-0.6B / bge-m3 都是 1024 维)靠维度检查发现不了。
359
- // 'none' = 没有模型,此时不加载也不改写向量文件(保留给用户之后接上模型用)。
360
- const embedSignature = silicon
361
- ? `api:${silicon.resolveStage('embed').model}`
362
- : 'none'
363
-
364
- // writer 从进程侧注入(环境变量优先),不再只信共享 config.json——
365
- // 两个程序读同一份 config 会拿到同一个 writer,等于往同一个 temp 里写。
366
- const writer = String(process.env.MEMORY_WRITER || cfg.writer || 'default')
367
- const layout = cfg.layout === 'legacy' ? 'legacy' : 'split'
368
- log.info?.('[memory-tiers] 布局 %s · writer=%s · 嵌入 %s', layout, writer, embedSignature)
369
-
370
- const mem = layout === 'legacy'
371
- ? createMemory5({
372
- dir,
373
- writer,
374
- ...models,
375
- embedSignature,
376
- // memoryCfg 允许 config.json 覆盖 v5 的底层参数(如 longMaxChars = L3 视图容量)
377
- cfg: {
378
- ...MEM_DEFAULTS,
379
- ...(cfg.memoryCfg || {}),
380
- summarizeMode: 'deferred',
381
- viewRefreshMs: Number(cfg.refreshMinMs) || MEM_DEFAULTS.viewRefreshMs,
382
- },
383
- })
384
- : createMemory6({
385
- dir,
386
- writer,
387
- ...models,
388
- embedSignature,
389
- cfg: {
390
- ...MEM6_DEFAULTS,
391
- ...(cfg.memoryCfg || {}),
392
- viewRefreshMs: Number(cfg.refreshMinMs) || MEM6_DEFAULTS.viewRefreshMs,
393
- quietMs: Number.isFinite(Number(cfg.quietMs)) ? Number(cfg.quietMs) : MEM6_DEFAULTS.quietMs,
394
- organizeEveryMs: Number(cfg.organizeEveryMs) || MEM6_DEFAULTS.organizeEveryMs,
395
- botCaptureChars: Number(cfg.botCaptureChars) || MEM6_DEFAULTS.botCaptureChars,
396
- toolCaptureChars: Number(cfg.toolCaptureChars) || MEM6_DEFAULTS.toolCaptureChars,
397
- },
398
- })
399
-
400
- // split:内部自带"端结束 + 5 分钟定时器";legacy:保持原来的启动结算。
401
- const splitLayout = typeof mem.writeTurn === 'function'
402
- if (splitLayout) {
403
- mem.start()
404
- } else {
405
- Promise.resolve()
406
- .then(() => mem.sweep())
407
- .catch((err) => log.warn?.('[memory-tiers] 启动结算失败:%s', err?.message ?? err))
408
- }
409
-
410
- /** 统一注册工具,单个失败不影响其他 */
411
- let toolCount = 0
412
- const register = (def) => {
413
- try {
414
- ctx.tools.register(def)
415
- toolCount += 1
416
- } catch (err) {
417
- log.warn?.('[memory-tiers] 工具 %s 注册失败:%s', def.name, err?.message ?? err)
418
- }
419
- }
420
-
421
- register({
422
- name: 'memory_write',
423
- description:
424
- '把值得长期记住的信息写入记忆。适用于:用户的偏好/习惯、已确定的决定、' +
425
- '项目背景、踩过的坑与结论。不要用来记临时性的中间过程。',
426
- parameters: {
427
- type: 'object',
428
- properties: {
429
- text: { type: 'string', description: '一条独立可读的结论,别用代词' },
430
- importance: { type: 'number', description: '0~1,默认由本地规则判断。用户明确强调的给 0.8 以上' },
431
- channel: { type: 'string', description: 'dialog / action / event,默认 dialog' },
432
- },
433
- required: ['text'],
434
- },
435
- output: { schema: {}, render: textRender },
436
- async execute(args = {}) {
437
- const text = String(args.text || '').trim()
438
- if (!text) return '写入失败:text 不能为空'
439
- const r = await mem.write({
440
- text,
441
- speaker: 'user',
442
- channel: args.channel || 'dialog',
443
- importance: Number.isFinite(Number(args.importance)) ? Number(args.importance) : null,
444
- })
445
- return `已记住 [${r.id}](${r.layer})`
446
- },
447
- })
448
-
449
- register({
450
- name: 'memory_search',
451
- description:
452
- '检索长期记忆。当用户提到过去聊过的事、旧约定、项目历史时调用。' +
453
- 'depth:summary(只搜摘要,默认)/ detail(摘要+原文)/ full(再加流水账)。',
454
- parameters: {
455
- type: 'object',
456
- properties: {
457
- query: { type: 'string', description: '关键词或短句,别写整段问题' },
458
- depth: { type: 'string', description: 'summary / detail / full,默认 auto' },
459
- limit: { type: 'number', description: '返回条数,默认 6' },
460
- },
461
- required: ['query'],
462
- },
463
- output: { schema: {}, render: textRender },
464
- async execute(args = {}) {
465
- const q = String(args.query || '').trim()
466
- if (!q) return '检索失败:query 不能为空'
467
- const r = await mem.search(q, {
468
- depth: args.depth || 'auto',
469
- limit: Math.min(20, Number(args.limit) || 6),
470
- })
471
- if (!r.hits.length) return '没有找到相关记忆。'
472
- const lines = r.hits.map((h) => `- [${h.id}] (${h.layer} ${h.when}) ${h.text}`)
473
- lines.push('', '(确认这些确实相关后,请调用 memory_attend 传 id 列表;只搜到不算数)')
474
- return lines.join('\n')
475
- },
476
- })
477
-
478
- register({
479
- name: 'memory_attend',
480
- description:
481
- '确认哪些检索结果真的相关。被确认的记忆热度 +1,冷层条目会回到观察层。' +
482
- '搜到但不调用它,热度不会涨——这是防止回音室的设计。',
483
- parameters: {
484
- type: 'object',
485
- properties: {
486
- ids: { type: 'array', items: { type: 'string' }, description: '相关的记忆 id 列表' },
487
- },
488
- required: ['ids'],
489
- },
490
- output: { schema: {}, render: textRender },
491
- async execute(args = {}) {
492
- const ids = Array.isArray(args.ids) ? args.ids : (args.ids ? [args.ids] : [])
493
- if (!ids.length) return '没有提供 id'
494
- const n = mem.attend(ids)
495
- return splitLayout ? `已提交 ${n} 条关注(下次整理生效)` : `已确认 ${n} 条相关(热度 +1)`
496
- },
497
- })
498
-
499
- register({
500
- name: 'memory_forget',
501
- description: '删除一条记错的记忆。发现记忆与用户实际说法冲突时,先 forget 再 write,不要硬留。',
502
- parameters: {
503
- type: 'object',
504
- properties: { id: { type: 'string', description: '要删除的记忆 id' } },
505
- required: ['id'],
506
- },
507
- output: { schema: {}, render: textRender },
508
- async execute(args = {}) {
509
- if (!args.id) return '删除失败:id 不能为空'
510
- const r = mem.forget(String(args.id))
511
- if (r.queued) return `已提交删除 ${args.id}(下次整理生效)`
512
- return r.ok ? `已删除 ${args.id}` : `未找到 ${args.id}`
513
- },
514
- })
515
-
516
- register({
517
- name: 'memory_stats',
518
- description: '查看六层记忆分布、调用统计和待摘要队列。排查"为什么它想不起来"时先调这个。',
519
- parameters: { type: 'object', properties: {} },
520
- output: { schema: {}, render: textRender },
521
- async execute() {
522
- const s = mem.stats()
523
- const layers = Object.entries(s.count).map(([k, v]) => `${k}=${v}`).join(' ')
524
- const pending = splitLayout ? (s.temp ?? 0) : mem.pendingSummaries()
525
- const calls = Object.entries(s.calls || {})
526
- .filter(([, v]) => v && v.rounds)
527
- .map(([k, v]) => `${k}:${v.rounds}`)
528
- .join(' ')
529
- return [
530
- `总条数 ${s.total} · 向量 ${s.vectors}`,
531
- `分层 ${layers}`,
532
- `${splitLayout ? '待整理' : '待摘要'} ${pending} 条 · 调用 ${calls || '(无)'}`,
533
- `布局 ${layout} · 整理 ${s.lock ? '进行中/被占用' : '空闲'}`,
534
- `数据目录 ${dir}`,
535
- ].join('\n')
536
- },
537
- })
538
-
539
- // ── 自动捕获:session/event → 按 turn 聚合写入 L1 ──
540
- if (cfg.autoCapture !== false && typeof ctx.on === 'function') {
541
- ctx.effect(() => {
542
- const bufs = new Map() // sessionId -> { user: [], bot: [], tools: Map }
543
-
544
- const bufOf = (sid) => {
545
- if (!bufs.has(sid)) bufs.set(sid, { user: [], bot: [], tools: new Map() })
546
- return bufs.get(sid)
547
- }
548
-
549
- const off = ctx.on('session/event', (session, event) => {
550
- const d = event?.data || {}
551
- const sid = session?.id || 'default'
552
- const buf = bufOf(sid)
553
-
554
- if (event.type === 'user/message') {
555
- // ★ 只记人类输入。agent.inject / skill 内容 / cron 通知也走这个事件,
556
- // 它们的 source.kind 不是 'user'。全记会让 L1 被系统注入污染,
557
- // 而且每轮重复注入会让「重复度=重要性」信号失真。
558
- if (d.source?.kind === 'user') {
559
- const t = takeText(d.content)
560
- if (t) buf.user.push(t.slice(0, cfg.userCaptureChars))
561
- }
562
- return
563
- }
564
-
565
- if (event.type === 'assistant/message') {
566
- // L1 要记完整的一轮(问 + 答)。这里只收正文,reasoning/工具块不收。
567
- // 一轮里可能有几条(工具循环),先全收,turn/end 时拼起来再截断。
568
- const t = takeText(d.content ?? d.message?.content)
569
- if (t) buf.bot.push(t)
570
- return
571
- }
572
-
573
- if (event.type === 'tool/call') {
574
- buf.tools.set(d.callId, { name: String(d.name || 'tool'), args: String(d.arguments || '') })
575
- return
576
- }
577
-
578
- if (event.type === 'tool/result') {
579
- // ★ 坑:callId / isError 不在 event.data 顶层,而在 message 里。
580
- // data = { turn, step, message: { role:'tool', toolCallId, isError, content } }
581
- // 早期版本写成 d.callId,永远 undefined → 匹配不上 → 所有 action 被静默丢掉(K1)。
582
- const cid = d.callId ?? d.message?.toolCallId ?? d.message?.source?.callId
583
- const c = cid ? buf.tools.get(cid) : undefined
584
- if (c) {
585
- c.out = takeText(d.message?.content).slice(0, cfg.toolCaptureChars)
586
- c.err = !!(d.isError ?? d.message?.isError)
587
- c.done = true
588
- }
589
- return
590
- }
591
-
592
- if (event.type === 'turn/end') {
593
- const rows = []
594
- // 同一个 turnId 标这一轮的所有行:溢出时按整轮取,问题和回答不会被拆开。
595
- const turnId = `${writer}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
596
- for (const t of buf.user) rows.push({ text: t, speaker: 'user', channel: 'dialog', turnId })
597
- if (buf.bot.length) {
598
- const botText = buf.bot.join('\n').trim()
599
- // ★ 截的是"整轮"的回复,不是每条消息——工具循环里可能有好几条 assistant 正文。
600
- if (botText) rows.push({ text: botText.slice(0, cfg.botCaptureChars), speaker: 'bot', channel: 'dialog', turnId })
601
- }
602
- for (const c of buf.tools.values()) {
603
- // ★ 没配到 result 的也要记:被中断/超时的调用同样是「做过的事」,
604
- // 漏掉它们会让"记录确确实实进了 L1"这个保证出现缺口。
605
- const out = c.done ? (c.out || '(无输出)') : '(被中断,无结果)'
606
- const head = c.err ? '失败' : (c.done ? '完成' : '未完成')
607
- rows.push({
608
- text: `运行 ${c.name}「${c.args.slice(0, 120)}」→ ${head}:${out}`,
609
- speaker: 'bot',
610
- channel: 'action',
611
- turnId,
612
- })
613
- }
614
- const stat = { user: buf.user.length, bot: buf.bot.length, tool: buf.tools.size, rows: rows.length }
615
- bufs.delete(sid)
616
-
617
- // ★ 顺序很重要:先归档进记忆,再压缩历史。
618
- // 压缩函数内部也会自己归档一次(它要保证「写成功才丢」),
619
- // 两者不冲突:这里记的是本轮原始消息,那里记的是被压掉的整段对话。
620
- const doCompact = async ({ archive = true } = {}) => {
621
- if (cfg.autoCompact === false || !session) return
622
- try {
623
- const cr = await compactSurface(session, mem, {
624
- keepTurns: cfg.compactKeepTurns,
625
- archiveChars: cfg.compactArchiveChars,
626
- // rows 已经把这轮写进 L1 了,这里就别再归档一遍(会存两份)
627
- archive,
628
- log,
629
- })
630
- if (cr.compacted) {
631
- log.info?.('[memory-tiers] 压缩历史 removed=%d archived=%d', cr.removed, cr.archived)
632
- appendCaptureLog(dir, `压缩 removed=${cr.removed} archived=${cr.archived}`)
633
- }
634
- } catch (err) {
635
- log.warn?.('[memory-tiers] 压缩失败:%s', err?.message ?? err)
636
- appendCaptureLog(dir, `压缩失败 err=${err?.message ?? err}`)
637
- }
638
- }
639
-
640
- if (rows.length) {
641
- Promise.resolve()
642
- .then(() => (typeof mem.writeTurn === 'function' ? mem.writeTurn(rows) : mem.writeMany(rows)))
643
- .then((res) => {
644
- // ★ 审计:这是 K1 那类 bug 唯一的照妖镜。
645
- // 写入条数和实际进 L1 的条数都要记,光看 rows.length 看不出来。
646
- const n = mem.stats?.().count?.L1 ?? '?'
647
- const written = Array.isArray(res) ? res.length : (res?.ids?.length ?? '?')
648
- log.info?.('[memory-tiers] turn 归档 user=%d bot=%d tool=%d 写入=%d L1=%s',
649
- stat.user, stat.bot, stat.tool, rows.length, n)
650
- appendCaptureLog(dir, `turn 归档 user=${stat.user} bot=${stat.bot} tool=${stat.tool} ` +
651
- `rows=${rows.length} written=${written} L1=${n}`)
652
- return doCompact({ archive: false })
653
- })
654
- // ★ 端结束就试一次整理(文档一直写的是"端结束 + 定时器",原来只有定时器)。
655
- // 拿不到钥匙(另一端正在整理)会自己跳过;不 await,不阻塞下一轮输入。
656
- // 没有变化时 organize 直接返回,不会重写文件(见 memory6 的 changed 判断)。
657
- .then(() => {
658
- if (typeof mem.organize === 'function') {
659
- Promise.resolve().then(() => mem.organize()).catch(() => {})
660
- }
661
- })
662
- .catch((err) => {
663
- log.warn?.('[memory-tiers] 自动写入失败:%s', err?.message ?? err)
664
- appendCaptureLog(dir, `turn 归档失败 user=${stat.user} bot=${stat.bot} tool=${stat.tool} rows=${rows.length} err=${err?.message ?? err}`)
665
- })
666
- } else {
667
- appendCaptureLog(dir, `turn 归档空 user=${stat.user} bot=${stat.bot} tool=${stat.tool}`)
668
- Promise.resolve().then(doCompact).catch(() => {})
669
- }
670
- }
671
- })
672
-
673
- return () => { try { off?.() } catch { /* 已卸 */ } }
674
- })
675
- }
676
-
677
- // ── 注入上下文:每轮现算,随写随带 ──
678
- //
679
- // ★ 2026-11-01 改:以前这里对整个记忆块做时间节流(L3+L2+L1 一起压 30 分钟),
680
- // 那是错的——
681
- // ① L1 每轮都有新数据写入,压着不刷新,模型就看不到你刚说的话;
682
- // ② 快照是**追加**在末尾的,前面历史的缓存不受影响,没什么可省的。
683
- //
684
- // 真正要节流的是 L3 的排序(决定哪些 DL3 条目进 L3),它在注入块最前面,
685
- // 一动后面全断。那个节流在 memory6 的 L3 快照里做,间隔 = refreshMinMs。
686
- //
687
- // 这里只保留一条:内容没变就不重复追加,免得每轮多一条一模一样的 user 消息。
688
- let lastBlock = ''
689
- const getBlock = () => {
690
- const fresh = mem.buildBlock(cfg.bootMaxChars)
691
- if (fresh !== lastBlock) lastBlock = fresh
692
- return lastBlock
693
- }
694
-
695
- ctx.inject(['systemPrompt'], (c) => {
696
- const ok = injectPrompt(c, getBlock)
697
- // 写一份状态文件,方便不用翻日志就知道插件有没有接上
698
- try {
699
- writeFileSync(
700
- join(dir, 'status.json'),
701
- JSON.stringify({
702
- loadedAt: new Date().toISOString(),
703
- tools: toolCount,
704
- promptInjected: ok,
705
- semantic: !!silicon,
706
- autoCapture: cfg.autoCapture !== false,
707
- layout,
708
- writer,
709
- dir,
710
- }, null, 2),
711
- 'utf8',
712
- )
713
- } catch { /* 状态文件写不了不影响主流程 */ }
714
- if (!ok) {
715
- throw new Error('[memory-tiers] systemPrompt 注入失败:当前版本的 section() 签名与插件不匹配')
716
- }
717
- log.info?.('[memory-tiers] 已加载,注册 %d 个工具(语义=%s,自动捕获=%s)',
718
- toolCount, !!silicon, cfg.autoCapture !== false)
719
- })
720
-
721
- // ── 设置页的服务端接口:读写 config.json(前端见 client.mjs) ──
722
- // webServer 可能比插件晚就绪,用 inject 等它,而不是直接读 ctx.webServer。
723
- ctx.inject(['webServer'], (c) => {
724
- const off = installSettingsApi(c, {
725
- dir,
726
- getCfg: () => cfg,
727
- log,
728
- mem,
729
- getModels: () => {
730
- if (!silicon?.resolveStage) return null
731
- const out = {}
732
- for (const st of silicon.stages()) out[st] = silicon.resolveStage(st)
733
- return out
734
- },
735
- getSession: () => session.info(),
736
- })
737
- if (typeof off === 'function') {
738
- try { c.effect(() => off, 'memory-tiers: settings api') } catch { /* 老版本没有 effect 也不影响接口可用 */ }
739
- } else {
740
- log.warn?.('[memory-tiers] webServer 不可用,设置页接口没挂上')
741
- }
742
- log.info?.('[memory-tiers] 设置页接口已挂:/dsh-memory/api/settings')
743
- })
744
-
745
- // ── 斜杠命令:手动查看/维护入口 ──
746
- ctx.inject(['commands'], (c) => {
747
- c.commands.register({
748
- name: 'memory',
749
- description: '记忆:/memory 概览 · /memory list · /memory search 关键词 · /memory flush · /memory sweep',
750
- input: { hint: '[list | search 关键词 | flush | sweep]' },
751
- async handler(invocation) {
752
- const raw = String(invocation.rawInput || '').trim()
753
-
754
- if (raw === 'list') {
755
- const all = mem.list()
756
- if (!all.length) return { kind: 'success', text: '还没有任何记忆。' }
757
- const byLayer = {}
758
- for (const it of all) {
759
- byLayer[it.layer] = byLayer[it.layer] || []
760
- byLayer[it.layer].push(it)
761
- }
762
- const parts = Object.entries(byLayer).map(([l, rows]) => [
763
- `### ${l}(${rows.length})`,
764
- ...rows.slice(0, 8).map((i) => `- [${i.id}] ${String(i.text).slice(0, 40)}`),
765
- ].join('\n'))
766
- return { kind: 'success', text: parts.join('\n\n') }
767
- }
768
-
769
- if (raw.startsWith('search ')) {
770
- const r = await mem.search(raw.slice(7).trim(), { depth: 'full', limit: 8 })
771
- return {
772
- kind: 'success',
773
- text: r.hits.length
774
- ? r.hits.map((h) => `[${h.layer}] ${h.text}`).join('\n')
775
- : '没有命中。',
776
- }
777
- }
778
-
779
- if (raw === 'flush') {
780
- const n = await mem.flushSummaries()
781
- return { kind: 'success', text: `处理了 ${n} 条待摘要` }
782
- }
783
-
784
- if (raw === 'sweep') {
785
- await mem.sweep()
786
- const s = mem.stats()
787
- return { kind: 'success', text: `已收敛:总 ${s.total} 条,L3 视图 ${s.count[LAYER.LONG]} 条` }
788
- }
789
-
790
- const s = mem.stats()
791
- return {
792
- kind: 'success',
793
- text: [
794
- `六层:${Object.entries(s.count).map(([k, v]) => `${k}=${v}`).join(' · ')}`,
795
- `总条数 ${s.total} · 向量 ${s.vectors} · 待整理 ${s.temp ?? mem.pendingSummaries()}`,
796
- `数据目录 ${dir}`,
797
- ].join('\n'),
798
- }
799
- },
800
- })
801
- })
802
-
803
- // ── 定时维护:deferred 摘要 + 周期收敛 ──
804
- // ctx.effect 的返回值是清理函数,插件卸载时 dsh 会调用;不写会泄漏定时器。
805
- ctx.effect?.(() => {
806
- // split 布局:定时器由 memory6 自己管(5 分钟整理 + L3 刷新),这里只负责卸载时停掉。
807
- if (typeof mem.stop === 'function') {
808
- return () => { try { mem.stop() } catch { /* 已停 */ } }
809
- }
810
-
811
- const flushTimer = setInterval(() => {
812
- mem.flushSummaries().catch((err) => log.warn?.('[memory-tiers] flush 失败:%s', err?.message ?? err))
813
- }, cfg.flushEveryMs)
814
-
815
- const sweepTimer = setInterval(() => {
816
- mem.sweep().catch((err) => log.warn?.('[memory-tiers] sweep 失败:%s', err?.message ?? err))
817
- }, cfg.sweepEveryMs)
818
-
819
- if (flushTimer.unref) flushTimer.unref()
820
- if (sweepTimer.unref) sweepTimer.unref()
821
- return () => {
822
- clearInterval(flushTimer)
823
- clearInterval(sweepTimer)
824
- }
825
- })
826
- }
1
+ /**
2
+ * dsh 插件适配层 —— 接 memory5(六层记忆)。只干"接线",不含记忆逻辑。
3
+ *
4
+ * 单文件合并(memory.mjs)之后,四个接入口没变:
5
+ * ① 写:订阅 session/event,turn 结束时把这一轮合成记忆(自动,不靠模型自觉)
6
+ * ② 读:systemPrompt.section 注入 buildBlock(L3 视图 + L2 + L1)
7
+ * ③ 查:memory_search / memory_attend 工具(搜到不涨热度,确认才涨)
8
+ * ④ 维护:定时 flushSummaries(deferred 摘要)+ 周期 sweep
9
+ *
10
+ * 关于自动写入:dsh 的 session 是事件溯源的,每次 append 都会 post-commit 广播
11
+ * `session/event`。一轮对话里会有 user/message、tool/call、tool/result、turn/end。
12
+ * 我们**按 turn 聚合**——中途写会得到「问了还没答」的半截对话,而且写入次数翻倍。
13
+ *
14
+ * 没有 embedding key 时语义层整体关闭(v0.5.2 起不再用本地哈希向量充数):
15
+ * 检索只走关键词 + 热度,向量文件不读也不写,接上模型后原样可用。
16
+ * 融合 / 摘要不受影响——它们默认走 dsh 当前会话模型(v0.6.0),不需要自己的 key。
17
+ */
18
+
19
+ import { homedir } from 'node:os'
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+ import {
23
+ createMemory, createLegacyMemory, LAYER,
24
+ DEFAULTS_LEGACY as MEM_LEGACY_DEFAULTS,
25
+ DEFAULTS_SPLIT as MEM_SPLIT_DEFAULTS,
26
+ } from './memory.mjs'
27
+ import { createSilicon, keyFromAstrbot } from './silicon.mjs'
28
+ import { compactSurface } from './compaction.mjs'
29
+ import { installSettingsApi } from './settings.mjs'
30
+ import { createSessionCaller } from './dshllm.mjs'
31
+
32
+ export const name = 'memory-tiers'
33
+ export const inject = ['tools']
34
+
35
+ const PLUGIN_DEFAULTS = {
36
+ dir: '',
37
+ // ★ 布局:'split' = 六文件(memory6,默认);'legacy' = 老的单文件分片(memory5,回退用)
38
+ layout: 'split',
39
+ // ★ 多写者标识:几个程序共用同一个数据目录时,每个必须不一样。
40
+ // split 布局下它决定 L1.tmp/<writer>.jsonl 和 signals/<writer>.jsonl 用哪个名字。
41
+ // 同名 = 互相往同一份 temp 里写(不会丢,但分不清是谁)。
42
+ writer: 'default',
43
+ // 'siliconflow' = 嵌入/重排用硅基流动那两个免费的(bge-m3 / bge-reranker-v2-m3)
44
+ // 'custom' = 用 models 里自己填的来源
45
+ preset: 'siliconflow',
46
+ apiKey: '',
47
+ apiBase: 'https://api.siliconflow.cn/v1',
48
+ // 每个阶段:{ source: 'session' } 跟当前会话模型,或 { base, apiKey, model } 走外部
49
+ models: { chat: { source: 'session' }, summary: { source: 'session' } },
50
+ noThink: { chat: true, summary: true },
51
+ bootMaxChars: 0, // 0 = 不限制,注入量由 L1/L2/L3 各自容量决定
52
+ budgetTokens: 0,
53
+ autoCapture: true, // 自动订阅 session/event 写入 L1
54
+ userCaptureChars: 2000, // 单条用户消息最多记多少字
55
+ toolCaptureChars: 500, // 工具输出截断(bash 一次能吐几万字,L1 才 3000 字)
56
+ // 一轮会话里 assistant 的正文:拼起来再截断(不是每条消息截)。L1 要靠它当"最近历史"。
57
+ botCaptureChars: 400,
58
+ // ── 相关旧事(与 astrbot 的 semantic_inject 对齐)──
59
+ // 开启后每轮用你的话做一次语义检索,命中的旧记忆拼成「## 相关的旧事」附在注入块末尾。
60
+ // 代价是每轮多一次 embedding 调用。默认关。
61
+ semanticInject: false,
62
+ semanticTopK: 5, // 最多拼几条
63
+ semanticMinSim: 0.35, // 相似度下限,低于这个不拼(避免字面擦边)
64
+ semanticTtlMin: 3, // 检索结果的有效期(分钟,超时不用,避免注入隔夜的话题)
65
+ // split 布局:一段会话结束后,隔这么久没新消息才允许整理(避免聊到一半被摘要腰斩)
66
+ quietMin: 10,
67
+ organizeEveryMin: 5, // 整理触发:端结束 + 这个定时器补漏
68
+ // 注入总开关:关掉只写不读(先验证写入是否正常时用)
69
+ inject: true,
70
+ // L1/L2/L3 恒定注入,没有开关——三层就是上下文的全部「记忆」,可调的是各自容量。
71
+ refreshMin: 30, // L3 视图(DL3 按热度×档位排序)的重算间隔(分钟)
72
+ // L1/L2 不节流,每轮现算并追加在末尾;只有 L3 排在注入块最前,它一动后面缓存全断。
73
+ // 下面两个是内部维护定时器,不进设置页,仍用毫秒。
74
+ flushEveryMs: 5 * 60 * 1000,
75
+ sweepEveryMs: 6 * 60 * 60 * 1000,
76
+ autoCompact: true, // 每轮结束把 dsh 自带历史压成一条占位(零 LLM)
77
+ compactKeepTurns: 0, // 0 = 完全取替 dsh 会话历史(L1 里已经有历史了)
78
+ compactArchiveChars: 1200, // 每轮压缩归档进记忆的字数上限
79
+ }
80
+
81
+ /**
82
+ * 老的毫秒键 → 新的分钟键。
83
+ *
84
+ * v0.8 之前时间类配置叫 quietMs / organizeEveryMs / refreshMinMs / semanticTtlMs,
85
+ * 单位毫秒,值大(600000)且容易少填或多填一个 0。现在统一用分钟(quietMin = 10),
86
+ * 和 astrbot 那边的 quiet_min / organize_every_min 对得上,抄配置不用换算。
87
+ * 只在读的时候换算一次:> 1000 当毫秒处理(没人会把 10 分钟填成 10000 分钟)。
88
+ */
89
+ const MS_TO_MIN = {
90
+ quietMs: 'quietMin',
91
+ organizeEveryMs: 'organizeEveryMin',
92
+ refreshMinMs: 'refreshMin',
93
+ semanticTtlMs: 'semanticTtlMin',
94
+ }
95
+
96
+ export function migrateTimeUnits(cfg) {
97
+ const out = { ...cfg }
98
+ for (const [oldKey, newKey] of Object.entries(MS_TO_MIN)) {
99
+ if (!(oldKey in out)) continue
100
+ const n = Number(out[oldKey])
101
+ if (out[newKey] === undefined && Number.isFinite(n) && n >= 0) {
102
+ out[newKey] = n >= 1000 ? Math.round(n / 60000) : n
103
+ }
104
+ delete out[oldKey]
105
+ }
106
+ return out
107
+ }
108
+
109
+ /** 分钟 → 毫秒;0 / 负数 / 非法值回落到给定的毫秒默认 */
110
+ function minutesToMs(v, dfltMs) {
111
+ const n = Number(v)
112
+ if (!Number.isFinite(n) || n <= 0) return dfltMs
113
+ return n * 60000
114
+ }
115
+
116
+ /** 数据放哪:优先 DSH_HOME,否则 ~/.dsh。v5 用独立目录,别和 v1 的 store 混。 */
117
+ /**
118
+ * 记忆数据放哪。优先级(高 → 低):
119
+ * 1. 插件配置里的 dir(dsh 侧 cordis 配置,跨 agent 移植时最可靠)
120
+ * 2. 环境变量 MEMORY_TIERS_DIR
121
+ * 3. <DSH_HOME 或 ~/.dsh>/storages/memory-v5
122
+ *
123
+ * ★ 为什么不能从 config.json 读 dir:config.json 就在 dir 里面,
124
+ * 要先知道 dir 才能读它,鸡生蛋。想换目录只有上面两种方式,
125
+ * 换完把旧目录整个拷过去即可(store.json / vectors-part-*.json / archive/ 都在里面,自包含)。
126
+ */
127
+ function expandHome(p) {
128
+ const s = String(p || '').trim()
129
+ if (!s) return ''
130
+ if (s === '~') return homedir()
131
+ if (s.startsWith('~/') || s.startsWith('~\\')) return join(homedir(), s.slice(2))
132
+ return s
133
+ }
134
+
135
+ /** 指针文件:{ "dir": "D:/shared/memory" }
136
+ *
137
+ * 为什么需要它:config.json 在**数据目录里面**,要先知道目录才能读它——
138
+ * 所以"数据目录在哪"这件事本身不能写在 config.json 里(鸡生蛋)。
139
+ * 放一个固定位置的指针文件,设置页就能改目录了。 */
140
+ function pointerFile() {
141
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
142
+ return join(home, 'memory-tiers.json')
143
+ }
144
+
145
+ function readPointerDir() {
146
+ try {
147
+ const raw = JSON.parse(readFileSync(pointerFile(), 'utf8'))
148
+ return expandHome(raw && raw.dir)
149
+ } catch { return '' }
150
+ }
151
+
152
+ function defaultDir() {
153
+ const fromPointer = readPointerDir()
154
+ if (fromPointer) return fromPointer
155
+ const fromEnv = expandHome(process.env.MEMORY_TIERS_DIR)
156
+ if (fromEnv) return fromEnv
157
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
158
+ return join(home, 'storages', 'memory-v5')
159
+ }
160
+
161
+ /**
162
+ * 从数据目录读 config.json。
163
+ *
164
+ * 为什么不用 schemastery 声明配置 schema(那样设置页会有表单):
165
+ * 插件是 link 安装的,**import 不到 dsh 自己的包**(schemastery 也一样),
166
+ * 和当年 defineTool 那个坑同源。所以配置走 JSON 文件,零依赖。
167
+ *
168
+ * 文件不存在就返回空对象;写坏了也不抛错,只当没配。
169
+ */
170
+ function loadFileConfig(dir) {
171
+ const p = join(dir, 'config.json')
172
+ if (!existsSync(p)) return {}
173
+ try {
174
+ const raw = JSON.parse(readFileSync(p, 'utf8').replace(/^\uFEFF/, ''))
175
+ return raw && typeof raw === 'object' ? raw : {}
176
+ } catch { return {} }
177
+ }
178
+
179
+ /** 每个工具的返回值都是纯文本,渲染函数共用一个 */
180
+ const textRender = (_args, value) => [{ type: 'text', text: String(value ?? '') }]
181
+
182
+ /**
183
+ * 找 embedding key:插件配置 > 环境变量 > AstrBot 配置
184
+ * 找不到就返回 null(降级本地向量),不抛错——插件加载不该被 key 卡死。
185
+ */
186
+ function resolveAuth(cfg) {
187
+ if (cfg.apiKey) return { apiKey: cfg.apiKey, base: cfg.apiBase }
188
+ const env = process.env.SILICONFLOW_API_KEY || process.env.SILICON_API_KEY
189
+ if (env) return { apiKey: env, base: cfg.apiBase }
190
+ const cands = [
191
+ join(process.env.DSH_HOME || join(homedir(), '.dsh'), '..', '.astrbot', 'data', 'cmd_config.json'),
192
+ join(homedir(), '.astrbot', 'data', 'cmd_config.json'),
193
+ ]
194
+ for (const p of cands) {
195
+ const hit = keyFromAstrbot(p)
196
+ if (hit) return { apiKey: hit.apiKey, base: hit.base || cfg.apiBase }
197
+ }
198
+ return null
199
+ }
200
+
201
+ /** 本地哈希向量已移除(v0.5.2):它给的相似度和真实语义无关,
202
+ * 在 1024 维下和真模型无法区分,只会制造"看起来在检索"的假象。
203
+ * 没有模型时宁可让语义检索完全关闭,只走关键词。 */
204
+
205
+ /**
206
+ * 把记忆块注入上下文。
207
+ *
208
+ * ★ 优先用 systemPrompt.context() 而不是 section(),原因是缓存:
209
+ * - section() 拼进系统提示词。系统提示词整块是「前缀」,记忆一变,
210
+ * 它后面那段稳定内容(工具说明等)也跟着重新计费。实测记忆块排在 2600
211
+ * (正好撞上 TOOL_WORKFLOW),把它后面的 3100 字也拖下水了,每轮多花约 4000 token。
212
+ * - context() 是「durable user-role snapshot」:作为一条 user 消息追加进历史,
213
+ * 而且框架会比对文本,内容没变就不追加。系统提示词保持逐字节不变,
214
+ * 缓存全命中;只有记忆真的变了才多一条消息。
215
+ * section() 只作为老版本兜底,order 放到 10200 之后(persona suffix 之后)。
216
+ */
217
+ function injectPrompt(ctx, getText) {
218
+ const sp = ctx.systemPrompt
219
+ if (!sp) return false
220
+ const tryOnce = (fn) => { try { fn(); return true } catch { return false } }
221
+
222
+ // 优先:追加式快照,不破坏系统提示词缓存
223
+ if (typeof sp.context === 'function') {
224
+ const ok = tryOnce(() => sp.context({
225
+ name: 'memory-tiers',
226
+ order: 250, // 排在 working directory(100)/sandbox(110) 之后
227
+ text: getText,
228
+ interpolate: false, // 记忆里可能出现 {{ }},别当变量替换
229
+ }))
230
+ if (ok) return true
231
+ }
232
+ // 兜底:老版本没有 context(),塞系统提示词末尾(10200 之后已是最末)
233
+ if (typeof sp.section === 'function') {
234
+ return tryOnce(() => sp.section({
235
+ name: 'memory-tiers',
236
+ order: 10300,
237
+ text: getText,
238
+ interpolate: false,
239
+ }))
240
+ }
241
+ return false
242
+ }
243
+
244
+ /**
245
+ * 归档审计日志:每轮结束记一行,落到 <dir>/capture.log。
246
+ *
247
+ * 为什么要有:K1 那个 bug(tool/result 的 callId 读错字段)让所有 action 静默消失,
248
+ * 内存里、日志里都看不出来,只有去数 store.json 才发现 action 是 0 条。
249
+ * 有了这个文件,「本轮抓到几条 / 写了几条 / L1 里有几条」一目了然。
250
+ */
251
+ function appendCaptureLog(dir, line) {
252
+ try {
253
+ mkdirSync(dir, { recursive: true })
254
+ appendFileSync(join(dir, 'capture.log'), `${new Date().toISOString()} ${line}\n`, 'utf8')
255
+ } catch { /* 日志写不了不影响主流程 */ }
256
+ }
257
+
258
+ /** 从 ContentBlock[] 里只取可见文本(reasoning 是草稿、image 是附件,都不进记忆) */
259
+ function takeText(blocks) {
260
+ if (!Array.isArray(blocks)) return ''
261
+ return blocks
262
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
263
+ .map((b) => b.text)
264
+ .join('\n')
265
+ .trim()
266
+ }
267
+
268
+ export function apply(ctx, config = {}) {
269
+ const baseDir = config.dir || defaultDir()
270
+ // 优先级:默认值 < config.json < 调用方传入 config
271
+ const cfg = migrateTimeUnits({ ...PLUGIN_DEFAULTS, ...loadFileConfig(baseDir), ...config })
272
+ const log = ctx.logger ?? console
273
+ const dir = baseDir
274
+
275
+ // 第一次启动时落一份示例配置,用户知道该去哪改(JSON 不能写注释,用字段名兜住)
276
+ try {
277
+ mkdirSync(dir, { recursive: true })
278
+ const p = join(dir, 'config.json')
279
+ if (!existsSync(p)) {
280
+ writeFileSync(p, JSON.stringify({
281
+ // preset: 'siliconflow' = 嵌入/重排用硅基流动那两个免费模型;'custom' = 自己填下面的 models
282
+ preset: 'siliconflow',
283
+ // ★ 布局:split = 六文件(L1/L2/L3/DL1/DL2/DL3);legacy = 老的单文件分片(回退用)
284
+ layout: 'split',
285
+ // ★ 写者标识:多个程序共用这个目录时,每个必须改成不一样的
286
+ // (dsh / astrbot / ...)。split 布局下它决定写 L1.tmp/<writer>.jsonl 里的哪一份。
287
+ // 填成一样的两个程序会往同一个 temp 里写(不丢,但分不清是谁)。
288
+ writer: 'default',
289
+ apiKey: '',
290
+ apiBase: 'https://api.siliconflow.cn/v1',
291
+ bootMaxChars: 0, // 0 = 不限制,注入量由 L1/L2/L3 各自容量决定
292
+ autoCapture: true,
293
+ userCaptureChars: 2000,
294
+ toolCaptureChars: 500,
295
+ botCaptureChars: 400, // 一轮回复拼起来后截断(L1 要当最近历史用)
296
+ inject: true, // 注入总开关:false = 只写不读
297
+ quietMin: 10, // 安静 10 分钟才允许整理 L1(别腰斩正在聊的对话)
298
+ organizeEveryMin: 5, // 整理定时器:5 分钟补一轮
299
+ budgetTokens: 0,
300
+ refreshMin: 30, // L3 快照刷新间隔(分钟),见 _说明
301
+ autoCompact: true,
302
+ compactKeepTurns: 0, // 0 = dsh 历史一条不留,全交给记忆(L1 就是历史)
303
+ compactArchiveChars: 1200,
304
+ semanticTtlMin: 3, // 语义检索结果的有效期(分钟),超时重算
305
+ memoryCfg: { longMaxChars: 3000, hotMaxChars: 3000, consolidateMaxChars: 2500 },
306
+ // 每个模型阶段可单独覆盖。两种来源:
307
+ // source: 'session' —— 走 dsh 当前会话模型(对话用 flash 就 flash,不用额外 key)
308
+ // { base, apiKey, model } —— 走外部 provider
309
+ // 默认:嵌入/重排走硅基流动免费模型,融合/摘要跟当前会话模型。
310
+ models: {
311
+ chat: { source: 'session' },
312
+ summary: { source: 'session' },
313
+ },
314
+ // 关思考:融合只要一个词,关掉更快更省;摘要要它想清楚,默认不关
315
+ noThink: { chat: true, summary: true },
316
+ _说明: [
317
+ 'apiKey 填硅基流动的 key(也可以设环境变量 SILICONFLOW_API_KEY);',
318
+ '留空则语义检索关闭:检索只走关键词,向量文件不读也不写(不造假向量)。',
319
+ ' 融合 / 摘要不受影响,它们默认跟当前会话模型走。改完重启 dsh 生效。',
320
+ 'bootMaxChars = 总闸门,0 = 不限制(推荐)。注入多少由 memoryCfg 的三层容量自己决定。',
321
+ 'memoryCfg.longMaxChars = L3 视图容量(长期记忆能进上下文的字数),默认 3000。',
322
+ 'inject = 注入总开关。false 时只写不读,适合先验证写入是否正常。',
323
+ '三层(L1/L2/L3)的容量分别由 memoryCfg.hotMaxChars / consolidateMaxChars / longMaxChars 决定。',
324
+ '时间类配置一律用分钟:quietMin / organizeEveryMin / refreshMin / semanticTtlMin。',
325
+ ' (旧版叫 quietMs / organizeEveryMs / refreshMinMs / semanticTtlMs,单位毫秒,读的时候自动换算。)',
326
+ 'refreshMin = L3 视图的刷新间隔(分钟),默认 30。',
327
+ ' 这是全局唯一需要节流的地方:L3 排在注入块最前,它一变,后面 L2/L1 的缓存全断。',
328
+ ' L1/L2 不节流——写了就带上下一轮,追加在末尾,不影响前面的缓存。',
329
+ 'autoCompact = 每轮结束时把 dsh 自带历史压成一条占位(零 LLM),被压掉的内容先归档进记忆。',
330
+ 'compactKeepTurns = 0 表示完全取替 dsh 会话历史(L1 里已经有历史了)。',
331
+ ' 想多留一点临时内容,把 L1 容量(memoryCfg.hotMaxChars)调大即可。',
332
+ 'preset = siliconflow 时,嵌入用 BAAI/bge-m3、重排用 BAAI/bge-reranker-v2-m3(都免费);custom 则用 models 里填的。',
333
+ 'models[stage].source = session 表示这个阶段跟着当前对话用的模型走(不用额外 key);api 则走下面填的地址/key。',
334
+ 'noThink = 是否关思考。默认都关(实测开思考摘要慢 3 倍);想要更细的摘要可以给 summary 打开。',
335
+ 'writer = 写者标识。多个程序共用本目录时每个必须不同:split 布局下各写 L1.tmp/<writer>.jsonl,',
336
+ ' 读的时候合起来。同名会分不清是谁。改完重启。',
337
+ 'layout = split(六文件,默认)/ legacy(老的单文件分片,回退用)。',
338
+ 'botCaptureChars = 一轮回复拼起来后截多少字;L1 容量(memoryCfg.hotMaxChars,默认 3000)达到就整理。',
339
+ 'dir = 数据目录本身。它**不在这个文件里**(本文件就在数据目录里面,鸡生蛋),',
340
+ ' 要改就改 ~/.dsh/memory-tiers.json 里的 {\"dir\":\"...\"},或设环境变量 MEMORY_TIERS_DIR。',
341
+ ],
342
+ }, null, 2), 'utf8')
343
+ }
344
+ } catch { /* 写不了不影响主流程 */ }
345
+
346
+ // ── 会话模型通道:融合 / 摘要可以直接跟着当前对话用的模型走 ──
347
+ // 拿不到 llm 服务也不影响,那些阶段会回落到自己的 HTTP 配置
348
+ const session = createSessionCaller(() => ctx, { log })
349
+ const sessionInfo = session.info()
350
+ if (sessionInfo.available) {
351
+ log.info?.('[memory-tiers] 会话模型可用:%s / %s(融合、摘要默认跟着它走)',
352
+ sessionInfo.provider, sessionInfo.model)
353
+ } else {
354
+ log.warn?.('[memory-tiers] 会话模型不可用(%s):融合 / 摘要按 models 里的外部配置走',
355
+ sessionInfo.reason)
356
+ }
357
+
358
+ // 哪些阶段走会话模型:默认 chat(融合判关系)+ summary;用户显式改 'api' 才走 HTTP
359
+ const sessionStages = ['chat', 'summary']
360
+ .filter((s) => {
361
+ const m = cfg.models?.[s]
362
+ // 没配就默认跟会话模型;配了 source:'api' 或有外部 base/apiKey 就走 HTTP
363
+ if (!m) return true
364
+ if (m.source === 'api') return false
365
+ if (m.source === 'session') return true
366
+ return !(m.base || m.apiKey || m.model)
367
+ })
368
+
369
+ // ── 模型层:有 key 接 silicon,没 key 降级本地向量 ──
370
+ const auth = resolveAuth(cfg)
371
+ let silicon = null
372
+ if (auth || (sessionInfo.available && sessionStages.length)) {
373
+ try {
374
+ silicon = createSilicon({
375
+ apiKey: auth?.apiKey || '',
376
+ base: auth?.base || cfg.apiBase,
377
+ budgetTokens: cfg.budgetTokens,
378
+ // 每个阶段可单独指定 { base, apiKey, model };留空继承上面的 apiKey/base
379
+ models: cfg.models || {},
380
+ noThink: cfg.noThink || {},
381
+ // 没有 key 时,embed/rerank 照样起不来,但 chat/summary 还能走会话模型
382
+ session: sessionInfo.available && sessionStages.length
383
+ ? { call: session.call, stages: sessionStages, model: sessionInfo.model }
384
+ : null,
385
+ onFallback: (fmt, ...args) => log.warn?.('[memory-tiers] ' + fmt, ...args),
386
+ })
387
+ } catch (err) {
388
+ log.warn?.('[memory-tiers] silicon 初始化失败:%s', err?.message ?? err)
389
+ }
390
+ } else {
391
+ log.warn?.('[memory-tiers] 未连接模型(配置 apiKey / 环境变量 SILICONFLOW_API_KEY):'
392
+ + '语义检索关闭,只用关键词命中、不做摘要。'
393
+ + '去「设置 → 记忆 → 模型连接」填 API Key 后重启即可')
394
+ }
395
+
396
+ const models = silicon
397
+ ? {
398
+ embed: (t) => silicon.embed(t),
399
+ classify: (t) => silicon.classify(t),
400
+ classifyBatch: (t) => silicon.classifyBatch(t),
401
+ judgeRelation: (a, b) => silicon.relation(a, b),
402
+ // ★ 参数顺序要包装:memory5 调 (lines, channel, folks),silicon 是 (texts, hint, channel, people)
403
+ summarize: (lines, channel, folks) => silicon.summarize(lines, '', channel, folks),
404
+ summarizeWithSources: (lines, ch, folks, hint) => silicon.summarizeWithSources(lines, ch, folks, hint),
405
+ rerank: (q, docs) => silicon.rerank(q, docs),
406
+ }
407
+ // 没有模型就什么都不给:不装假向量。memory5 会自动退到关键词打分
408
+ : {}
409
+
410
+ // 向量指纹:写进 vectors.meta.json,换 embed 模型时能被识别出来而不是静默变乱。
411
+ // 维度相同的模型(Qwen3-Embedding-0.6B / bge-m3 都是 1024 维)靠维度检查发现不了。
412
+ // 'none' = 没有模型,此时不加载也不改写向量文件(保留给用户之后接上模型用)。
413
+ const embedSignature = silicon
414
+ ? `api:${silicon.resolveStage('embed').model}`
415
+ : 'none'
416
+
417
+ // writer 从进程侧注入(环境变量优先),不再只信共享 config.json——
418
+ // 两个程序读同一份 config 会拿到同一个 writer,等于往同一个 temp 里写。
419
+ const writer = String(process.env.MEMORY_WRITER || cfg.writer || 'default')
420
+ const layout = cfg.layout === 'legacy' ? 'legacy' : 'split'
421
+ log.info?.('[memory-tiers] 布局 %s · writer=%s · 嵌入 %s', layout, writer, embedSignature)
422
+
423
+ const mem = layout === 'legacy'
424
+ ? createLegacyMemory({
425
+ dir,
426
+ writer,
427
+ ...models,
428
+ embedSignature,
429
+ // memoryCfg 允许 config.json 覆盖 v5 的底层参数(如 longMaxChars = L3 视图容量)
430
+ cfg: {
431
+ ...MEM_LEGACY_DEFAULTS,
432
+ ...(cfg.memoryCfg || {}),
433
+ summarizeMode: 'deferred',
434
+ viewRefreshMs: minutesToMs(cfg.refreshMin, MEM_LEGACY_DEFAULTS.viewRefreshMs),
435
+ },
436
+ })
437
+ : createMemory({
438
+ dir,
439
+ writer,
440
+ ...models,
441
+ embedSignature,
442
+ cfg: {
443
+ ...MEM_SPLIT_DEFAULTS,
444
+ ...(cfg.memoryCfg || {}),
445
+ viewRefreshMs: minutesToMs(cfg.refreshMin, MEM_SPLIT_DEFAULTS.viewRefreshMs),
446
+ quietMs: minutesToMs(cfg.quietMin, MEM_SPLIT_DEFAULTS.quietMs),
447
+ organizeEveryMs: minutesToMs(cfg.organizeEveryMin, MEM_SPLIT_DEFAULTS.organizeEveryMs),
448
+ botCaptureChars: Number(cfg.botCaptureChars) || MEM_SPLIT_DEFAULTS.botCaptureChars,
449
+ toolCaptureChars: Number(cfg.toolCaptureChars) || MEM_SPLIT_DEFAULTS.toolCaptureChars,
450
+ },
451
+ })
452
+
453
+ // split:内部自带"端结束 + 5 分钟定时器";legacy:保持原来的启动结算。
454
+ const splitLayout = typeof mem.writeTurn === 'function'
455
+ if (splitLayout) {
456
+ mem.start()
457
+ } else {
458
+ Promise.resolve()
459
+ .then(() => mem.sweep())
460
+ .catch((err) => log.warn?.('[memory-tiers] 启动结算失败:%s', err?.message ?? err))
461
+ }
462
+
463
+ /** 统一注册工具,单个失败不影响其他 */
464
+ let toolCount = 0
465
+ const register = (def) => {
466
+ try {
467
+ ctx.tools.register(def)
468
+ toolCount += 1
469
+ } catch (err) {
470
+ log.warn?.('[memory-tiers] 工具 %s 注册失败:%s', def.name, err?.message ?? err)
471
+ }
472
+ }
473
+
474
+ register({
475
+ name: 'memory_write',
476
+ description:
477
+ '把值得长期记住的信息写入记忆。适用于:用户的偏好/习惯、已确定的决定、' +
478
+ '项目背景、踩过的坑与结论。不要用来记临时性的中间过程。',
479
+ parameters: {
480
+ type: 'object',
481
+ properties: {
482
+ text: { type: 'string', description: '一条独立可读的结论,别用代词' },
483
+ importance: { type: 'number', description: '0~1,默认由本地规则判断。用户明确强调的给 0.8 以上' },
484
+ channel: { type: 'string', description: 'dialog / action / event,默认 dialog' },
485
+ },
486
+ required: ['text'],
487
+ },
488
+ output: { schema: {}, render: textRender },
489
+ async execute(args = {}) {
490
+ const text = String(args.text || '').trim()
491
+ if (!text) return '写入失败:text 不能为空'
492
+ const r = await mem.write({
493
+ text,
494
+ speaker: 'user',
495
+ channel: args.channel || 'dialog',
496
+ importance: Number.isFinite(Number(args.importance)) ? Number(args.importance) : null,
497
+ })
498
+ return `已记住 [${r.id}](${r.layer})`
499
+ },
500
+ })
501
+
502
+ register({
503
+ name: 'memory_search',
504
+ description:
505
+ '检索长期记忆。当用户提到过去聊过的事、旧约定、项目历史时调用。' +
506
+ 'depth:summary(只搜摘要,默认)/ detail(摘要+原文)/ full(再加流水账)。',
507
+ parameters: {
508
+ type: 'object',
509
+ properties: {
510
+ query: { type: 'string', description: '关键词或短句,别写整段问题' },
511
+ depth: { type: 'string', description: 'summary / detail / full,默认 auto' },
512
+ limit: { type: 'number', description: '返回条数,默认 6' },
513
+ },
514
+ required: ['query'],
515
+ },
516
+ output: { schema: {}, render: textRender },
517
+ async execute(args = {}) {
518
+ const q = String(args.query || '').trim()
519
+ if (!q) return '检索失败:query 不能为空'
520
+ const r = await mem.search(q, {
521
+ depth: args.depth || 'auto',
522
+ limit: Math.min(20, Number(args.limit) || 6),
523
+ })
524
+ if (!r.hits.length) return '没有找到相关记忆。'
525
+ const lines = r.hits.map((h) => `- [${h.id}] (${h.layer} ${h.when}) ${h.text}`)
526
+ lines.push('', '(确认这些确实相关后,请调用 memory_attend 传 id 列表;只搜到不算数)')
527
+ return lines.join('\n')
528
+ },
529
+ })
530
+
531
+ register({
532
+ name: 'memory_attend',
533
+ description:
534
+ '确认哪些检索结果真的相关。被确认的记忆热度 +1,冷层条目会回到观察层。' +
535
+ '搜到但不调用它,热度不会涨——这是防止回音室的设计。',
536
+ parameters: {
537
+ type: 'object',
538
+ properties: {
539
+ ids: { type: 'array', items: { type: 'string' }, description: '相关的记忆 id 列表' },
540
+ },
541
+ required: ['ids'],
542
+ },
543
+ output: { schema: {}, render: textRender },
544
+ async execute(args = {}) {
545
+ const ids = Array.isArray(args.ids) ? args.ids : (args.ids ? [args.ids] : [])
546
+ if (!ids.length) return '没有提供 id'
547
+ const n = mem.attend(ids)
548
+ return splitLayout ? `已提交 ${n} 条关注(下次整理生效)` : `已确认 ${n} 条相关(热度 +1)`
549
+ },
550
+ })
551
+
552
+ register({
553
+ name: 'memory_forget',
554
+ description: '删除一条记错的记忆。发现记忆与用户实际说法冲突时,先 forget 再 write,不要硬留。',
555
+ parameters: {
556
+ type: 'object',
557
+ properties: { id: { type: 'string', description: '要删除的记忆 id' } },
558
+ required: ['id'],
559
+ },
560
+ output: { schema: {}, render: textRender },
561
+ async execute(args = {}) {
562
+ if (!args.id) return '删除失败:id 不能为空'
563
+ const r = mem.forget(String(args.id))
564
+ if (r.queued) return `已提交删除 ${args.id}(下次整理生效)`
565
+ return r.ok ? `已删除 ${args.id}` : `未找到 ${args.id}`
566
+ },
567
+ })
568
+
569
+ register({
570
+ name: 'memory_stats',
571
+ description: '查看六层记忆分布、调用统计和待摘要队列。排查"为什么它想不起来"时先调这个。',
572
+ parameters: { type: 'object', properties: {} },
573
+ output: { schema: {}, render: textRender },
574
+ async execute() {
575
+ const s = mem.stats()
576
+ const layers = Object.entries(s.count).map(([k, v]) => `${k}=${v}`).join(' ')
577
+ const pending = splitLayout ? (s.temp ?? 0) : mem.pendingSummaries()
578
+ const calls = Object.entries(s.calls || {})
579
+ .filter(([, v]) => v && v.rounds)
580
+ .map(([k, v]) => `${k}:${v.rounds}`)
581
+ .join(' ')
582
+ return [
583
+ `总条数 ${s.total} · 向量 ${s.vectors}`,
584
+ `分层 ${layers}`,
585
+ `${splitLayout ? '待整理' : '待摘要'} ${pending} 条 · 调用 ${calls || '(无)'}`,
586
+ `布局 ${layout} · 整理 ${s.lock ? '进行中/被占用' : '空闲'}`,
587
+ `数据目录 ${dir}`,
588
+ ].join('\n')
589
+ },
590
+ })
591
+
592
+ // ── 自动捕获:session/event → 按 turn 聚合写入 L1 ──
593
+ if (cfg.autoCapture !== false && typeof ctx.on === 'function') {
594
+ ctx.effect(() => {
595
+ const bufs = new Map() // sessionId -> { user: [], bot: [], tools: Map }
596
+
597
+ const bufOf = (sid) => {
598
+ if (!bufs.has(sid)) bufs.set(sid, { user: [], bot: [], tools: new Map() })
599
+ return bufs.get(sid)
600
+ }
601
+
602
+ const off = ctx.on('session/event', (session, event) => {
603
+ const d = event?.data || {}
604
+ const sid = session?.id || 'default'
605
+ const buf = bufOf(sid)
606
+
607
+ if (event.type === 'user/message') {
608
+ // ★ 只记人类输入。agent.inject / skill 内容 / cron 通知也走这个事件,
609
+ // 它们的 source.kind 不是 'user'。全记会让 L1 被系统注入污染,
610
+ // 而且每轮重复注入会让「重复度=重要性」信号失真。
611
+ if (d.source?.kind === 'user') {
612
+ const t = takeText(d.content)
613
+ if (t) {
614
+ buf.user.push(t.slice(0, cfg.userCaptureChars))
615
+ // 用这句话预取「相关旧事」(异步,不阻塞本轮)
616
+ prefetchSemantic(t.slice(0, 200))
617
+ }
618
+ }
619
+ return
620
+ }
621
+
622
+ if (event.type === 'assistant/message') {
623
+ // L1 要记完整的一轮(问 + 答)。这里只收正文,reasoning/工具块不收。
624
+ // 一轮里可能有几条(工具循环),先全收,turn/end 时拼起来再截断。
625
+ const t = takeText(d.content ?? d.message?.content)
626
+ if (t) buf.bot.push(t)
627
+ return
628
+ }
629
+
630
+ if (event.type === 'tool/call') {
631
+ buf.tools.set(d.callId, { name: String(d.name || 'tool'), args: String(d.arguments || '') })
632
+ return
633
+ }
634
+
635
+ if (event.type === 'tool/result') {
636
+ // ★ 坑:callId / isError 不在 event.data 顶层,而在 message 里。
637
+ // data = { turn, step, message: { role:'tool', toolCallId, isError, content } }
638
+ // 早期版本写成 d.callId,永远 undefined → 匹配不上 → 所有 action 被静默丢掉(K1)。
639
+ const cid = d.callId ?? d.message?.toolCallId ?? d.message?.source?.callId
640
+ const c = cid ? buf.tools.get(cid) : undefined
641
+ if (c) {
642
+ c.out = takeText(d.message?.content).slice(0, cfg.toolCaptureChars)
643
+ c.err = !!(d.isError ?? d.message?.isError)
644
+ c.done = true
645
+ }
646
+ return
647
+ }
648
+
649
+ if (event.type === 'turn/end') {
650
+ const rows = []
651
+ // 同一个 turnId 标这一轮的所有行:溢出时按整轮取,问题和回答不会被拆开。
652
+ const turnId = `${writer}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
653
+ // scene 占位:桌面端没有「群」的概念,用固定值保持字段形状与 astrbot 一致
654
+ // (那边是 `平台·群名` / `平台·私聊`)。等有了多会话区分再填真实值。
655
+ for (const t of buf.user) rows.push({ text: t, speaker: 'user', channel: 'dialog', scene: 'dsh', turnId })
656
+ if (buf.bot.length) {
657
+ const botText = buf.bot.join('\n').trim()
658
+ // ★ 截的是"整轮"的回复,不是每条消息——工具循环里可能有好几条 assistant 正文。
659
+ if (botText) rows.push({ text: botText.slice(0, cfg.botCaptureChars), speaker: 'bot', channel: 'dialog', scene: 'dsh', turnId })
660
+ }
661
+ for (const c of buf.tools.values()) {
662
+ // ★ 没配到 result 的也要记:被中断/超时的调用同样是「做过的事」,
663
+ // 漏掉它们会让"记录确确实实进了 L1"这个保证出现缺口。
664
+ const out = c.done ? (c.out || '(无输出)') : '(被中断,无结果)'
665
+ const head = c.err ? '失败' : (c.done ? '完成' : '未完成')
666
+ rows.push({
667
+ text: `运行 ${c.name}「${c.args.slice(0, 120)}」→ ${head}:${out}`,
668
+ speaker: 'bot',
669
+ channel: 'action',
670
+ scene: 'dsh',
671
+ turnId,
672
+ })
673
+ }
674
+ const stat = { user: buf.user.length, bot: buf.bot.length, tool: buf.tools.size, rows: rows.length }
675
+ bufs.delete(sid)
676
+
677
+ // ★ 顺序很重要:先归档进记忆,再压缩历史。
678
+ // 压缩函数内部也会自己归档一次(它要保证「写成功才丢」),
679
+ // 两者不冲突:这里记的是本轮原始消息,那里记的是被压掉的整段对话。
680
+ const doCompact = async ({ archive = true } = {}) => {
681
+ if (cfg.autoCompact === false || !session) return
682
+ try {
683
+ const cr = await compactSurface(session, mem, {
684
+ keepTurns: cfg.compactKeepTurns,
685
+ archiveChars: cfg.compactArchiveChars,
686
+ // rows 已经把这轮写进 L1 了,这里就别再归档一遍(会存两份)
687
+ archive,
688
+ log,
689
+ })
690
+ if (cr.compacted) {
691
+ log.info?.('[memory-tiers] 压缩历史 removed=%d archived=%d', cr.removed, cr.archived)
692
+ appendCaptureLog(dir, `压缩 removed=${cr.removed} archived=${cr.archived}`)
693
+ }
694
+ } catch (err) {
695
+ log.warn?.('[memory-tiers] 压缩失败:%s', err?.message ?? err)
696
+ appendCaptureLog(dir, `压缩失败 err=${err?.message ?? err}`)
697
+ }
698
+ }
699
+
700
+ if (rows.length) {
701
+ Promise.resolve()
702
+ .then(() => (typeof mem.writeTurn === 'function' ? mem.writeTurn(rows) : mem.writeMany(rows)))
703
+ .then((res) => {
704
+ // ★ 审计:这是 K1 那类 bug 唯一的照妖镜。
705
+ // 写入条数和实际进 L1 的条数都要记,光看 rows.length 看不出来。
706
+ const n = mem.stats?.().count?.L1 ?? '?'
707
+ const written = Array.isArray(res) ? res.length : (res?.ids?.length ?? '?')
708
+ log.info?.('[memory-tiers] turn 归档 user=%d bot=%d tool=%d 写入=%d L1=%s',
709
+ stat.user, stat.bot, stat.tool, rows.length, n)
710
+ appendCaptureLog(dir, `turn 归档 user=${stat.user} bot=${stat.bot} tool=${stat.tool} ` +
711
+ `rows=${rows.length} written=${written} L1=${n}`)
712
+ return doCompact({ archive: false })
713
+ })
714
+ // ★ 端结束就试一次整理(文档一直写的是"端结束 + 定时器",原来只有定时器)。
715
+ // 拿不到钥匙(另一端正在整理)会自己跳过;不 await,不阻塞下一轮输入。
716
+ // 没有变化时 organize 直接返回,不会重写文件(见 memory6 的 changed 判断)。
717
+ .then(() => {
718
+ if (typeof mem.organize === 'function') {
719
+ Promise.resolve().then(() => mem.organize()).catch(() => {})
720
+ }
721
+ })
722
+ .catch((err) => {
723
+ log.warn?.('[memory-tiers] 自动写入失败:%s', err?.message ?? err)
724
+ appendCaptureLog(dir, `turn 归档失败 user=${stat.user} bot=${stat.bot} tool=${stat.tool} rows=${rows.length} err=${err?.message ?? err}`)
725
+ })
726
+ } else {
727
+ appendCaptureLog(dir, `turn 归档空 user=${stat.user} bot=${stat.bot} tool=${stat.tool}`)
728
+ Promise.resolve().then(doCompact).catch(() => {})
729
+ }
730
+ }
731
+ })
732
+
733
+ return () => { try { off?.() } catch { /* 已卸 */ } }
734
+ })
735
+ }
736
+
737
+ // ── 注入上下文:每轮现算,随写随带 ──
738
+ //
739
+ // ★ 2026-11-01 改:以前这里对整个记忆块做时间节流(L3+L2+L1 一起压 30 分钟),
740
+ // 那是错的——
741
+ // ① L1 每轮都有新数据写入,压着不刷新,模型就看不到你刚说的话;
742
+ // ② 快照是**追加**在末尾的,前面历史的缓存不受影响,没什么可省的。
743
+ //
744
+ // 真正要节流的是 L3 的排序(决定哪些 DL3 条目进 L3),它在注入块最前面,
745
+ // 一动后面全断。那个节流在 memory 的 L3 快照里做,间隔 = refreshMin(分钟)。
746
+ //
747
+ // 这里只保留一条:内容没变就不重复追加,免得每轮多一条一模一样的 user 消息。
748
+ // ── 相关旧事(与 astrbot 的 semantic_inject 对齐)──
749
+ //
750
+ // 为什么是「异步预取 + 同步读」:注入走 systemPrompt.context 的同步取值,
751
+ // 事件回调 `ctx.on('session/event')` 也是同步的,都不能 await。
752
+ // 所以 user/message 时启动一次检索,getBlock 时读已完成的结果。
753
+ //
754
+ // 取舍:检索可能在本次请求组装之后才完成,于是这轮用不上、下轮才用得上。
755
+ // 所以带 TTL(默认 3 分钟)——超时的结果宁可不用,也不要把隔夜的话题注进去。
756
+ let semCache = { text: '', at: 0 }
757
+ let semBusy = false
758
+
759
+ function hitLine(h) {
760
+ const mmdd = String(h.when || '').match(/\d{1,2}\/\d{1,2}/)
761
+ const tag = mmdd ? `${mmdd[0]} ` : ''
762
+ const who = `[${h.layer || '?'}${h.speaker ? '·' + h.speaker : ''}] `
763
+ const txt = String(h.text || '').replace(/\s+/g, ' ').trim()
764
+ const sim = Number(h.rel ?? h.sem ?? h.score ?? 0)
765
+ return `- ${tag}${who}${txt}(${sim.toFixed(2)})`
766
+ }
767
+
768
+ function prefetchSemantic(q) {
769
+ if (!cfg.semanticInject || !silicon || semBusy) return
770
+ const query = String(q || '').trim()
771
+ if (!query) return
772
+ semBusy = true
773
+ Promise.resolve()
774
+ .then(() => mem.search(query, { limit: cfg.semanticTopK }))
775
+ .then((res) => {
776
+ const hits = (res?.hits || []).slice(0, cfg.semanticTopK)
777
+ // 只留够相关的,避免字面擦边也塞进来
778
+ const keep = hits.filter((h) => Number(h.rel ?? h.sem ?? 0) >= cfg.semanticMinSim)
779
+ if (!keep.length) { semCache = { text: '', at: 0 }; return }
780
+ semCache = { text: ['## 相关的旧事', ...keep.map(hitLine)].join('\n'), at: Date.now() }
781
+ })
782
+ .catch((e) => {
783
+ // 检索失败不影响注入,只留痕
784
+ appendCaptureLog(dir, `语义检索失败 ${e?.message ?? e}`)
785
+ semCache = { text: '', at: 0 }
786
+ })
787
+ .finally(() => { semBusy = false })
788
+ }
789
+
790
+ const semanticExtra = () => {
791
+ if (!cfg.semanticInject || !semCache.text) return ''
792
+ if (Date.now() - semCache.at > minutesToMs(cfg.semanticTtlMin, 3 * 60000)) return ''
793
+ return semCache.text
794
+ }
795
+
796
+ let lastBlock = ''
797
+ const getBlock = () => {
798
+ const fresh = mem.buildBlock(cfg.bootMaxChars, { extra: semanticExtra() })
799
+ if (fresh !== lastBlock) lastBlock = fresh
800
+ return lastBlock
801
+ }
802
+
803
+ ctx.inject(['systemPrompt'], (c) => {
804
+ // inject=false:只写不读。不是"注入空串",而是根本不挂这个 section,
805
+ // 免得系统提示词里多一条空块把前缀缓存顶掉。
806
+ const injectOn = cfg.inject !== false
807
+ const ok = injectOn ? injectPrompt(c, getBlock) : true
808
+ // 写一份状态文件,方便不用翻日志就知道插件有没有接上
809
+ try {
810
+ writeFileSync(
811
+ join(dir, 'status.json'),
812
+ JSON.stringify({
813
+ loadedAt: new Date().toISOString(),
814
+ tools: toolCount,
815
+ promptInjected: ok && injectOn,
816
+ inject: injectOn,
817
+ semantic: !!silicon,
818
+ autoCapture: cfg.autoCapture !== false,
819
+ layout,
820
+ writer,
821
+ dir,
822
+ }, null, 2),
823
+ 'utf8',
824
+ )
825
+ } catch { /* 状态文件写不了不影响主流程 */ }
826
+ if (!ok) {
827
+ throw new Error('[memory-tiers] systemPrompt 注入失败:当前版本的 section() 签名与插件不匹配')
828
+ }
829
+ if (!injectOn) log.info?.('[memory-tiers] 注入已关闭(inject=false):只写不读')
830
+ log.info?.('[memory-tiers] 已加载,注册 %d 个工具(语义=%s,自动捕获=%s)',
831
+ toolCount, !!silicon, cfg.autoCapture !== false)
832
+ })
833
+
834
+ // ── 设置页的服务端接口:读写 config.json(前端见 client.mjs) ──
835
+ // webServer 可能比插件晚就绪,用 inject 等它,而不是直接读 ctx.webServer。
836
+ ctx.inject(['webServer'], (c) => {
837
+ const off = installSettingsApi(c, {
838
+ dir,
839
+ getCfg: () => cfg,
840
+ log,
841
+ mem,
842
+ getModels: () => {
843
+ if (!silicon?.resolveStage) return null
844
+ const out = {}
845
+ for (const st of silicon.stages()) out[st] = silicon.resolveStage(st)
846
+ return out
847
+ },
848
+ getSession: () => session.info(),
849
+ })
850
+ if (typeof off === 'function') {
851
+ try { c.effect(() => off, 'memory-tiers: settings api') } catch { /* 老版本没有 effect 也不影响接口可用 */ }
852
+ } else {
853
+ log.warn?.('[memory-tiers] webServer 不可用,设置页接口没挂上')
854
+ }
855
+ log.info?.('[memory-tiers] 设置页接口已挂:/dsh-memory/api/settings')
856
+ })
857
+
858
+ // ── 斜杠命令:手动查看/维护入口 ──
859
+ ctx.inject(['commands'], (c) => {
860
+ c.commands.register({
861
+ name: 'memory',
862
+ description: '记忆:/memory 概览 · /memory list · /memory search 关键词 · /memory flush · /memory sweep',
863
+ input: { hint: '[list | search 关键词 | flush | sweep]' },
864
+ async handler(invocation) {
865
+ const raw = String(invocation.rawInput || '').trim()
866
+
867
+ if (raw === 'list') {
868
+ const all = mem.list()
869
+ if (!all.length) return { kind: 'success', text: '还没有任何记忆。' }
870
+ const byLayer = {}
871
+ for (const it of all) {
872
+ byLayer[it.layer] = byLayer[it.layer] || []
873
+ byLayer[it.layer].push(it)
874
+ }
875
+ const parts = Object.entries(byLayer).map(([l, rows]) => [
876
+ `### ${l}(${rows.length})`,
877
+ ...rows.slice(0, 8).map((i) => `- [${i.id}] ${String(i.text).slice(0, 40)}`),
878
+ ].join('\n'))
879
+ return { kind: 'success', text: parts.join('\n\n') }
880
+ }
881
+
882
+ if (raw.startsWith('search ')) {
883
+ const r = await mem.search(raw.slice(7).trim(), { depth: 'full', limit: 8 })
884
+ return {
885
+ kind: 'success',
886
+ text: r.hits.length
887
+ ? r.hits.map((h) => `[${h.layer}] ${h.text}`).join('\n')
888
+ : '没有命中。',
889
+ }
890
+ }
891
+
892
+ if (raw === 'flush') {
893
+ const n = await mem.flushSummaries()
894
+ return { kind: 'success', text: `处理了 ${n} 条待摘要` }
895
+ }
896
+
897
+ if (raw === 'sweep') {
898
+ await mem.sweep()
899
+ const s = mem.stats()
900
+ return { kind: 'success', text: `已收敛:总 ${s.total} 条,L3 视图 ${s.count[LAYER.LONG]} 条` }
901
+ }
902
+
903
+ const s = mem.stats()
904
+ return {
905
+ kind: 'success',
906
+ text: [
907
+ `六层:${Object.entries(s.count).map(([k, v]) => `${k}=${v}`).join(' · ')}`,
908
+ `总条数 ${s.total} · 向量 ${s.vectors} · 待整理 ${s.temp ?? mem.pendingSummaries()}`,
909
+ `数据目录 ${dir}`,
910
+ ].join('\n'),
911
+ }
912
+ },
913
+ })
914
+ })
915
+
916
+ // ── 定时维护:deferred 摘要 + 周期收敛 ──
917
+ // ctx.effect 的返回值是清理函数,插件卸载时 dsh 会调用;不写会泄漏定时器。
918
+ ctx.effect?.(() => {
919
+ // split 布局:定时器由 memory6 自己管(5 分钟整理 + L3 刷新),这里只负责卸载时停掉。
920
+ if (typeof mem.stop === 'function') {
921
+ return () => { try { mem.stop() } catch { /* 已停 */ } }
922
+ }
923
+
924
+ const flushTimer = setInterval(() => {
925
+ mem.flushSummaries().catch((err) => log.warn?.('[memory-tiers] flush 失败:%s', err?.message ?? err))
926
+ }, cfg.flushEveryMs)
927
+
928
+ const sweepTimer = setInterval(() => {
929
+ mem.sweep().catch((err) => log.warn?.('[memory-tiers] sweep 失败:%s', err?.message ?? err))
930
+ }, cfg.sweepEveryMs)
931
+
932
+ if (flushTimer.unref) flushTimer.unref()
933
+ if (sweepTimer.unref) sweepTimer.unref()
934
+ return () => {
935
+ clearInterval(flushTimer)
936
+ clearInterval(sweepTimer)
937
+ }
938
+ })
939
+ }