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/settings.mjs CHANGED
@@ -1,327 +1,335 @@
1
- /**
2
- * 设置页的服务端半边:读写 config.json + 暴露运行状态。
3
- *
4
- * 为什么要自己写 HTTP 接口而不是用 schemastery 声明 schema:
5
- * link 安装的插件 import 不到 @deepseek-ai/*,拿不到 dsh 的 schema 包(见 ARCHITECTURE 19.5)。
6
- * 走 webServer + 自己的 React 组件反而更可控——字段说明、分组、只读状态都能自己排版。
7
- */
8
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs'
9
- import { homedir } from 'node:os'
10
- import { join } from 'node:path'
11
- import { isIP } from 'node:net'
12
-
13
- /** 可写字段白名单:只管运行参数,dir 和 _说明 不让前端改 */
14
- const WRITABLE = {
15
- apiKey: 'string',
16
- apiBase: 'string',
17
- bootMaxChars: 'number',
18
- refreshMinMs: 'number', // L3 视图(DL3 排序)刷新间隔;L1/L2 不节流
19
- autoCompact: 'boolean',
20
- compactKeepTurns: 'number',
21
- compactArchiveChars: 'number',
22
- userCaptureChars: 'number',
23
- toolCaptureChars: 'number',
24
- botCaptureChars: 'number', // 一轮 assistant 正文拼起来后截多少(L1 当最近历史用)
25
- quietMs: 'number', // 安静多久才允许整理 L1
26
- organizeEveryMs: 'number', // 整理定时器
27
- budgetTokens: 'number',
28
- flushEveryMs: 'number',
29
- sweepEveryMs: 'number',
30
- // 'siliconflow' = 嵌入/重排用那两个免费模型;'custom' = 用 models 里自己填的
31
- preset: 'string',
32
- // ★ 布局:'split' = 六文件(默认);'legacy' = 老的单文件分片(回退用)
33
- layout: 'string',
34
- // ★ 多写者标识:共用同一个数据目录时每个程序必须不同
35
- writer: 'string',
36
- }
37
-
38
- /** 数据目录:不写 config.json(它在目录里面,鸡生蛋),写固定位置的指针文件 */
39
- function pointerFile() {
40
- const home = process.env.DSH_HOME || join(homedir(), '.dsh')
41
- return join(home, 'memory-tiers.json')
42
- }
43
-
44
- /** 关思考:chat(融合判关系)默认 true,summary 默认 false */
45
- const NOTHINK_WRITABLE = { chat: 'boolean', summary: 'boolean' }
46
-
47
- /** 三层容量在 memoryCfg 里,单独处理 */
48
- const MEMORY_CFG_WRITABLE = {
49
- hotMaxChars: 'number',
50
- consolidateMaxChars: 'number',
51
- longMaxChars: 'number',
52
- dl2MaxChars: 'number',
53
- }
54
-
55
- /** 每个模型阶段可单独覆盖的连接参数;留空字符串 = 清除覆盖、回落到顶层 */
56
- const STAGES = ['embed', 'rerank', 'chat', 'summary']
57
- // source: 'session' 跟当前会话模型走;'api' 或留空则走下面的连接参数
58
- const STAGE_FIELDS = { base: 'string', apiKey: 'string', model: 'string', source: 'string' }
59
-
60
- function json(res, code, obj) {
61
- const body = JSON.stringify(obj)
62
- res.statusCode = code
63
- res.setHeader('content-type', 'application/json; charset=utf-8')
64
- res.setHeader('cache-control', 'no-store')
65
- res.end(body)
66
- }
67
-
68
- function readBody(req) {
69
- return new Promise((resolve, reject) => {
70
- let buf = ''
71
- req.on('data', (c) => {
72
- buf += c
73
- if (buf.length > 1 << 20) reject(new Error('body too large'))
74
- })
75
- req.on('end', () => resolve(buf))
76
- req.on('error', reject)
77
- })
78
- }
79
-
80
- /** 只信任本机回环;远程连接交给 dsh 的 connection 服务判定 */
81
- function isTrustedReq(ctx, req) {
82
- const addr = req.socket?.remoteAddress || ''
83
- const bare = addr.replace(/^::ffff:/, '')
84
- if (isIP(bare) && (bare === '127.0.0.1' || bare === '::1')) return true
85
- try {
86
- const conn = ctx.get?.('connection') || ctx.connection
87
- if (conn && typeof conn.isTrusted === 'function') return conn.isTrusted(req) === true
88
- } catch { /* 栅栏不可用按不信任处理 */ }
89
- return false
90
- }
91
-
92
- function coerce(kind, value) {
93
- if (kind === 'boolean') {
94
- if (typeof value === 'boolean') return value
95
- if (value === 'true') return true
96
- if (value === 'false') return false
97
- return undefined
98
- }
99
- if (kind === 'number') {
100
- const n = Number(value)
101
- return Number.isFinite(n) ? n : undefined
102
- }
103
- if (kind === 'string') return typeof value === 'string' ? value : undefined
104
- return undefined
105
- }
106
-
107
- /**
108
- * 挂上 /dsh-memory/api/*。
109
- * @returns {() => void} disposer;webServer 不可用时返回 null
110
- */
111
- export function installSettingsApi(ctx, opts) {
112
- const { dir, getCfg, log, mem, getModels, getSession } = opts
113
- const webServer = ctx.webServer
114
- if (!webServer?.register) return null
115
-
116
- const cfgPath = join(dir, 'config.json')
117
- const disposers = []
118
-
119
- const readCfgFile = () => {
120
- try {
121
- if (!existsSync(cfgPath)) return {}
122
- return JSON.parse(readFileSync(cfgPath, 'utf8'))
123
- } catch (err) {
124
- log?.warn?.('[memory-tiers] 读 config.json 失败:%s', err?.message ?? err)
125
- return {}
126
- }
127
- }
128
-
129
- function route(path, handler) {
130
- const off = webServer.register({
131
- kind: 'exact',
132
- path,
133
- handler: async (req, res) => {
134
- if (!isTrustedReq(ctx, req)) { json(res, 403, { ok: false, error: '不受信任的访问来源' }); return }
135
- try { await handler(req, res) } catch (err) {
136
- log?.warn?.('[memory-tiers] 设置接口出错:%s', err?.message ?? err)
137
- json(res, 500, { ok: false, error: String(err?.message ?? err) })
138
- }
139
- },
140
- })
141
- if (typeof off === 'function') disposers.push(off)
142
- }
143
-
144
- // ── settings:读和写必须**合成一个 handler** ──
145
- // 踩过的坑:同一路径注册两次,若宿主「首个匹配即终止」,后注册的永远收不到请求。
146
- // 实测表现是 GET 正常、POST 405(或反之),排查半天。
147
- route('/dsh-memory/api/settings', async (req, res) => {
148
- if (req.method === 'GET') {
149
- const file = readCfgFile()
150
- const live = getCfg() || {}
151
- const stats = (() => {
152
- try { return mem?.stats?.() ?? null } catch { return null }
153
- })()
154
- json(res, 200, {
155
- ok: true,
156
- config: {
157
- ...file,
158
- // dir 不在 config.json 里(它在数据目录里面,鸡生蛋),从指针文件回填给界面
159
- dir: live.dir || dir,
160
- writer: file.writer || 'default',
161
- memoryCfg: { ...(file.memoryCfg || {}) },
162
- },
163
- live: {
164
- dir,
165
- layout: live.layout || 'split',
166
- // 多写者:我自己是谁、还有谁在往这个目录写
167
- // split 布局看 L1.tmp/*.jsonl,legacy 看 store-*.json
168
- writer: live.writer || 'default',
169
- peers: (() => {
170
- try {
171
- const files = readdirSync(dir)
172
- if (files.includes('L1.tmp') || files.includes('L1.json')) {
173
- try {
174
- return readdirSync(join(dir, 'L1.tmp'))
175
- .filter((f) => f.endsWith('.jsonl'))
176
- .map((f) => f.replace(/\.jsonl$/, '').split('.')[0])
177
- .filter(Boolean)
178
- } catch { return [] }
179
- }
180
- return files
181
- .map((f) => f.match(/^store-([A-Za-z0-9_-]+)\.json$/)?.[1])
182
- .filter(Boolean)
183
- } catch { return [] }
184
- })(),
185
- pointerFile: pointerFile(),
186
- counts: stats?.count ?? null,
187
- chars: stats?.chars ?? null, // 每层字数(设置页要写清楚"存了多少字")
188
- vectors: stats?.vectors ?? null,
189
- total: stats?.total ?? null,
190
- byChannel: stats?.byChannel ?? null,
191
- temp: stats?.temp ?? null,
192
- lock: stats?.lock ?? null,
193
- last: stats?.last ?? null,
194
- semantic: !!(live.apiKey || process.env.SILICONFLOW_API_KEY ||
195
- Object.values(live.models || {}).some((m) => m?.apiKey)),
196
- autoCompact: live.autoCompact !== false,
197
- preset: live.preset || 'siliconflow',
198
- noThink: { chat: true, summary: false, ...(file.noThink || {}) },
199
- // 会话模型通道:能不能用、当前是哪个模型
200
- session: (() => {
201
- try { return getSession?.() ?? null } catch { return null }
202
- })(),
203
- // 各阶段最终解析成什么(覆盖已合并),设置页拿来显示"实际生效"
204
- models: (() => {
205
- try { return getModels?.() ?? null } catch { return null }
206
- })(),
207
- },
208
- configPath: cfgPath,
209
- })
210
- return
211
- }
212
-
213
- if (req.method !== 'POST') { json(res, 405, { ok: false, error: '只接受 GET / POST' }); return }
214
-
215
- const raw = await readBody(req)
216
- let body
217
- try { body = JSON.parse(raw || '{}') } catch { json(res, 400, { ok: false, error: 'JSON 解析失败' }); return }
218
-
219
- // 兼容 { config: {...} } 和直接 {...} 两种写法(踩过:只认一种会「保存成功但没变」)
220
- const incoming = (body && typeof body.config === 'object' && body.config) || body || {}
221
-
222
- const cur = readCfgFile()
223
- const next = { ...cur }
224
- const applied = []
225
- const ignored = []
226
-
227
- // 数据目录单独处理:写指针文件,不写 config.json
228
- if (typeof incoming.dir === 'string') {
229
- const v = incoming.dir.trim()
230
- if (v) {
231
- try {
232
- writeFileSync(pointerFile(), JSON.stringify({ dir: v }, null, 2), 'utf8')
233
- applied.push('dir(指针文件)')
234
- } catch {
235
- ignored.push('dir')
236
- }
237
- }
238
- }
239
-
240
- for (const [k, kind] of Object.entries(WRITABLE)) {
241
- if (!(k in incoming)) continue
242
- const v = coerce(kind, incoming[k])
243
- if (v === undefined) { ignored.push(k); continue }
244
- next[k] = v
245
- applied.push(k)
246
- }
247
-
248
- if (incoming.memoryCfg && typeof incoming.memoryCfg === 'object') {
249
- const mc = { ...(cur.memoryCfg || {}) }
250
- for (const [k, kind] of Object.entries(MEMORY_CFG_WRITABLE)) {
251
- if (!(k in incoming.memoryCfg)) continue
252
- const v = coerce(kind, incoming.memoryCfg[k])
253
- if (v === undefined) { ignored.push('memoryCfg.' + k); continue }
254
- mc[k] = v
255
- applied.push('memoryCfg.' + k)
256
- }
257
- next.memoryCfg = mc
258
- }
259
-
260
- // ── 每阶段模型覆盖 ──
261
- // 语义:填了就覆盖,留空(空字符串)= 删掉这条覆盖,回落到顶层的 apiKey/apiBase 和默认模型名。
262
- // 这样「全走一家」和「摘要单独挂别家」都能表达,不用区分"没填过"和"清空了"。
263
- if (incoming.models && typeof incoming.models === 'object') {
264
- const mm = { ...(cur.models || {}) }
265
- for (const st of STAGES) {
266
- const src = incoming.models[st]
267
- if (!src || typeof src !== 'object') continue
268
- const dst = { ...(mm[st] || {}) }
269
- for (const [k, kind] of Object.entries(STAGE_FIELDS)) {
270
- if (!(k in src)) continue
271
- const v = coerce(kind, src[k])
272
- if (v === undefined) { ignored.push(`models.${st}.${k}`); continue }
273
- if (v === '') delete dst[k]
274
- else dst[k] = v
275
- applied.push(`models.${st}.${k}`)
276
- }
277
- // source 只接受 session / api,别的值(含乱写)一律当"没配"
278
- if (typeof dst.source === 'string' && dst.source !== 'session' && dst.source !== 'api') {
279
- delete dst.source
280
- ignored.push(`models.${st}.source`)
281
- }
282
- if (Object.keys(dst).length) mm[st] = dst
283
- else delete mm[st]
284
- }
285
- next.models = mm
286
- }
287
-
288
- // ── 关思考 ──
289
- if (incoming.noThink && typeof incoming.noThink === 'object') {
290
- const nt = { ...(cur.noThink || {}) }
291
- for (const [k, kind] of Object.entries(NOTHINK_WRITABLE)) {
292
- if (!(k in incoming.noThink)) continue
293
- const v = coerce(kind, incoming.noThink[k])
294
- if (v === undefined) { ignored.push('noThink.' + k); continue }
295
- nt[k] = v
296
- applied.push('noThink.' + k)
297
- }
298
- next.noThink = nt
299
- }
300
-
301
- try {
302
- mkdirSync(dir, { recursive: true })
303
- writeFileSync(cfgPath, JSON.stringify(next, null, 2), 'utf8')
304
- } catch (err) {
305
- json(res, 500, { ok: false, error: '写入失败:' + String(err?.message ?? err) })
306
- return
307
- }
308
- log?.info?.('[memory-tiers] 设置已更新:%s 项,重启 dsh 生效', applied.length)
309
- json(res, 200, { ok: true, applied, ignored, needRestart: true })
310
- })
311
-
312
- // ── 最近归档/压缩审计(设置页底部展示) ──
313
- route('/dsh-memory/api/log', async (req, res) => {
314
- if (req.method !== 'GET') { json(res, 405, { ok: false }); return }
315
- let lines = []
316
- try {
317
- const p = join(dir, 'capture.log')
318
- if (existsSync(p)) {
319
- const all = readFileSync(p, 'utf8').trim().split('\n')
320
- lines = all.slice(-15)
321
- }
322
- } catch { /* 读不到就当空 */ }
323
- json(res, 200, { ok: true, lines })
324
- })
325
-
326
- return () => { for (const off of disposers) { try { off() } catch { /* 已卸 */ } } }
327
- }
1
+ /**
2
+ * 设置页的服务端半边:读写 config.json + 暴露运行状态。
3
+ *
4
+ * 为什么要自己写 HTTP 接口而不是用 schemastery 声明 schema:
5
+ * link 安装的插件 import 不到 @deepseek-ai/*,拿不到 dsh 的 schema 包(见 ARCHITECTURE 19.5)。
6
+ * 走 webServer + 自己的 React 组件反而更可控——字段说明、分组、只读状态都能自己排版。
7
+ */
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import { isIP } from 'node:net'
12
+
13
+ /** 可写字段白名单:只管运行参数,dir 和 _说明 不让前端改 */
14
+ const WRITABLE = {
15
+ apiKey: 'string',
16
+ apiBase: 'string',
17
+ bootMaxChars: 'number',
18
+ inject: 'boolean', // 注入总开关:false = 只写不读
19
+ refreshMin: 'number', // L3 视图(DL3 排序)刷新间隔(分钟);L1/L2 不节流
20
+ semanticTtlMin: 'number', // 语义检索结果的有效期(分钟)
21
+ autoCompact: 'boolean',
22
+ compactKeepTurns: 'number',
23
+ compactArchiveChars: 'number',
24
+ userCaptureChars: 'number',
25
+ toolCaptureChars: 'number',
26
+ botCaptureChars: 'number', // 一轮 assistant 正文拼起来后截多少(L1 当最近历史用)
27
+ quietMin: 'number', // 安静多少分钟才允许整理 L1
28
+ organizeEveryMin: 'number', // 整理定时器(分钟)
29
+ budgetTokens: 'number',
30
+ flushEveryMs: 'number',
31
+ sweepEveryMs: 'number',
32
+ // 'siliconflow' = 嵌入/重排用那两个免费模型;'custom' = 用 models 里自己填的
33
+ preset: 'string',
34
+ // ★ 布局:'split' = 六文件(默认);'legacy' = 老的单文件分片(回退用)
35
+ layout: 'string',
36
+ // ★ 多写者标识:共用同一个数据目录时每个程序必须不同
37
+ writer: 'string',
38
+ }
39
+
40
+ /** 数据目录:不写 config.json(它在目录里面,鸡生蛋),写固定位置的指针文件 */
41
+ function pointerFile() {
42
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
43
+ return join(home, 'memory-tiers.json')
44
+ }
45
+
46
+ /** 关思考:chat(融合判关系)默认 true,summary 默认 false */
47
+ const NOTHINK_WRITABLE = { chat: 'boolean', summary: 'boolean' }
48
+
49
+ /** 三层容量在 memoryCfg 里,单独处理 */
50
+ const MEMORY_CFG_WRITABLE = {
51
+ hotMaxChars: 'number',
52
+ consolidateMaxChars: 'number',
53
+ longMaxChars: 'number',
54
+ dl2MaxChars: 'number',
55
+ // 下面几项和 astrbot 端的配置项一一对应(hot_quota_own / l2_keep_max / summary_chunk / forget_threshold)
56
+ hotQuotaOwn: 'number',
57
+ l2KeepMax: 'number',
58
+ summaryChunk: 'number',
59
+ forgetThreshold: 'number',
60
+ }
61
+
62
+ /** 每个模型阶段可单独覆盖的连接参数;留空字符串 = 清除覆盖、回落到顶层 */
63
+ const STAGES = ['embed', 'rerank', 'chat', 'summary']
64
+ // source: 'session' 跟当前会话模型走;'api' 或留空则走下面的连接参数
65
+ const STAGE_FIELDS = { base: 'string', apiKey: 'string', model: 'string', source: 'string' }
66
+
67
+ function json(res, code, obj) {
68
+ const body = JSON.stringify(obj)
69
+ res.statusCode = code
70
+ res.setHeader('content-type', 'application/json; charset=utf-8')
71
+ res.setHeader('cache-control', 'no-store')
72
+ res.end(body)
73
+ }
74
+
75
+ function readBody(req) {
76
+ return new Promise((resolve, reject) => {
77
+ let buf = ''
78
+ req.on('data', (c) => {
79
+ buf += c
80
+ if (buf.length > 1 << 20) reject(new Error('body too large'))
81
+ })
82
+ req.on('end', () => resolve(buf))
83
+ req.on('error', reject)
84
+ })
85
+ }
86
+
87
+ /** 只信任本机回环;远程连接交给 dsh 的 connection 服务判定 */
88
+ function isTrustedReq(ctx, req) {
89
+ const addr = req.socket?.remoteAddress || ''
90
+ const bare = addr.replace(/^::ffff:/, '')
91
+ if (isIP(bare) && (bare === '127.0.0.1' || bare === '::1')) return true
92
+ try {
93
+ const conn = ctx.get?.('connection') || ctx.connection
94
+ if (conn && typeof conn.isTrusted === 'function') return conn.isTrusted(req) === true
95
+ } catch { /* 栅栏不可用按不信任处理 */ }
96
+ return false
97
+ }
98
+
99
+ function coerce(kind, value) {
100
+ if (kind === 'boolean') {
101
+ if (typeof value === 'boolean') return value
102
+ if (value === 'true') return true
103
+ if (value === 'false') return false
104
+ return undefined
105
+ }
106
+ if (kind === 'number') {
107
+ const n = Number(value)
108
+ return Number.isFinite(n) ? n : undefined
109
+ }
110
+ if (kind === 'string') return typeof value === 'string' ? value : undefined
111
+ return undefined
112
+ }
113
+
114
+ /**
115
+ * 挂上 /dsh-memory/api/*。
116
+ * @returns {() => void} disposer;webServer 不可用时返回 null
117
+ */
118
+ export function installSettingsApi(ctx, opts) {
119
+ const { dir, getCfg, log, mem, getModels, getSession } = opts
120
+ const webServer = ctx.webServer
121
+ if (!webServer?.register) return null
122
+
123
+ const cfgPath = join(dir, 'config.json')
124
+ const disposers = []
125
+
126
+ const readCfgFile = () => {
127
+ try {
128
+ if (!existsSync(cfgPath)) return {}
129
+ return JSON.parse(readFileSync(cfgPath, 'utf8'))
130
+ } catch (err) {
131
+ log?.warn?.('[memory-tiers] 读 config.json 失败:%s', err?.message ?? err)
132
+ return {}
133
+ }
134
+ }
135
+
136
+ function route(path, handler) {
137
+ const off = webServer.register({
138
+ kind: 'exact',
139
+ path,
140
+ handler: async (req, res) => {
141
+ if (!isTrustedReq(ctx, req)) { json(res, 403, { ok: false, error: '不受信任的访问来源' }); return }
142
+ try { await handler(req, res) } catch (err) {
143
+ log?.warn?.('[memory-tiers] 设置接口出错:%s', err?.message ?? err)
144
+ json(res, 500, { ok: false, error: String(err?.message ?? err) })
145
+ }
146
+ },
147
+ })
148
+ if (typeof off === 'function') disposers.push(off)
149
+ }
150
+
151
+ // ── settings:读和写必须**合成一个 handler** ──
152
+ // 踩过的坑:同一路径注册两次,若宿主「首个匹配即终止」,后注册的永远收不到请求。
153
+ // 实测表现是 GET 正常、POST 405(或反之),排查半天。
154
+ route('/dsh-memory/api/settings', async (req, res) => {
155
+ if (req.method === 'GET') {
156
+ const file = readCfgFile()
157
+ const live = getCfg() || {}
158
+ const stats = (() => {
159
+ try { return mem?.stats?.() ?? null } catch { return null }
160
+ })()
161
+ json(res, 200, {
162
+ ok: true,
163
+ config: {
164
+ ...file,
165
+ // dir 不在 config.json 里(它在数据目录里面,鸡生蛋),从指针文件回填给界面
166
+ dir: live.dir || dir,
167
+ writer: file.writer || 'default',
168
+ memoryCfg: { ...(file.memoryCfg || {}) },
169
+ },
170
+ live: {
171
+ dir,
172
+ layout: live.layout || 'split',
173
+ // 多写者:我自己是谁、还有谁在往这个目录写
174
+ // split 布局看 L1.tmp/*.jsonl,legacy 看 store-*.json
175
+ writer: live.writer || 'default',
176
+ peers: (() => {
177
+ try {
178
+ const files = readdirSync(dir)
179
+ if (files.includes('L1.tmp') || files.includes('L1.json')) {
180
+ try {
181
+ return readdirSync(join(dir, 'L1.tmp'))
182
+ .filter((f) => f.endsWith('.jsonl'))
183
+ .map((f) => f.replace(/\.jsonl$/, '').split('.')[0])
184
+ .filter(Boolean)
185
+ } catch { return [] }
186
+ }
187
+ return files
188
+ .map((f) => f.match(/^store-([A-Za-z0-9_-]+)\.json$/)?.[1])
189
+ .filter(Boolean)
190
+ } catch { return [] }
191
+ })(),
192
+ pointerFile: pointerFile(),
193
+ counts: stats?.count ?? null,
194
+ chars: stats?.chars ?? null, // 每层字数(设置页要写清楚"存了多少字")
195
+ vectors: stats?.vectors ?? null,
196
+ total: stats?.total ?? null,
197
+ byChannel: stats?.byChannel ?? null,
198
+ temp: stats?.temp ?? null,
199
+ lock: stats?.lock ?? null,
200
+ last: stats?.last ?? null,
201
+ semantic: !!(live.apiKey || process.env.SILICONFLOW_API_KEY ||
202
+ Object.values(live.models || {}).some((m) => m?.apiKey)),
203
+ inject: live.inject !== false,
204
+ autoCompact: live.autoCompact !== false,
205
+ preset: live.preset || 'siliconflow',
206
+ noThink: { chat: true, summary: false, ...(file.noThink || {}) },
207
+ // 会话模型通道:能不能用、当前是哪个模型
208
+ session: (() => {
209
+ try { return getSession?.() ?? null } catch { return null }
210
+ })(),
211
+ // 各阶段最终解析成什么(覆盖已合并),设置页拿来显示"实际生效"
212
+ models: (() => {
213
+ try { return getModels?.() ?? null } catch { return null }
214
+ })(),
215
+ },
216
+ configPath: cfgPath,
217
+ })
218
+ return
219
+ }
220
+
221
+ if (req.method !== 'POST') { json(res, 405, { ok: false, error: '只接受 GET / POST' }); return }
222
+
223
+ const raw = await readBody(req)
224
+ let body
225
+ try { body = JSON.parse(raw || '{}') } catch { json(res, 400, { ok: false, error: 'JSON 解析失败' }); return }
226
+
227
+ // 兼容 { config: {...} } 和直接 {...} 两种写法(踩过:只认一种会「保存成功但没变」)
228
+ const incoming = (body && typeof body.config === 'object' && body.config) || body || {}
229
+
230
+ const cur = readCfgFile()
231
+ const next = { ...cur }
232
+ const applied = []
233
+ const ignored = []
234
+
235
+ // 数据目录单独处理:写指针文件,不写 config.json
236
+ if (typeof incoming.dir === 'string') {
237
+ const v = incoming.dir.trim()
238
+ if (v) {
239
+ try {
240
+ writeFileSync(pointerFile(), JSON.stringify({ dir: v }, null, 2), 'utf8')
241
+ applied.push('dir(指针文件)')
242
+ } catch {
243
+ ignored.push('dir')
244
+ }
245
+ }
246
+ }
247
+
248
+ for (const [k, kind] of Object.entries(WRITABLE)) {
249
+ if (!(k in incoming)) continue
250
+ const v = coerce(kind, incoming[k])
251
+ if (v === undefined) { ignored.push(k); continue }
252
+ next[k] = v
253
+ applied.push(k)
254
+ }
255
+
256
+ if (incoming.memoryCfg && typeof incoming.memoryCfg === 'object') {
257
+ const mc = { ...(cur.memoryCfg || {}) }
258
+ for (const [k, kind] of Object.entries(MEMORY_CFG_WRITABLE)) {
259
+ if (!(k in incoming.memoryCfg)) continue
260
+ const v = coerce(kind, incoming.memoryCfg[k])
261
+ if (v === undefined) { ignored.push('memoryCfg.' + k); continue }
262
+ mc[k] = v
263
+ applied.push('memoryCfg.' + k)
264
+ }
265
+ next.memoryCfg = mc
266
+ }
267
+
268
+ // ── 每阶段模型覆盖 ──
269
+ // 语义:填了就覆盖,留空(空字符串)= 删掉这条覆盖,回落到顶层的 apiKey/apiBase 和默认模型名。
270
+ // 这样「全走一家」和「摘要单独挂别家」都能表达,不用区分"没填过"和"清空了"。
271
+ if (incoming.models && typeof incoming.models === 'object') {
272
+ const mm = { ...(cur.models || {}) }
273
+ for (const st of STAGES) {
274
+ const src = incoming.models[st]
275
+ if (!src || typeof src !== 'object') continue
276
+ const dst = { ...(mm[st] || {}) }
277
+ for (const [k, kind] of Object.entries(STAGE_FIELDS)) {
278
+ if (!(k in src)) continue
279
+ const v = coerce(kind, src[k])
280
+ if (v === undefined) { ignored.push(`models.${st}.${k}`); continue }
281
+ if (v === '') delete dst[k]
282
+ else dst[k] = v
283
+ applied.push(`models.${st}.${k}`)
284
+ }
285
+ // source 只接受 session / api,别的值(含乱写)一律当"没配"
286
+ if (typeof dst.source === 'string' && dst.source !== 'session' && dst.source !== 'api') {
287
+ delete dst.source
288
+ ignored.push(`models.${st}.source`)
289
+ }
290
+ if (Object.keys(dst).length) mm[st] = dst
291
+ else delete mm[st]
292
+ }
293
+ next.models = mm
294
+ }
295
+
296
+ // ── 关思考 ──
297
+ if (incoming.noThink && typeof incoming.noThink === 'object') {
298
+ const nt = { ...(cur.noThink || {}) }
299
+ for (const [k, kind] of Object.entries(NOTHINK_WRITABLE)) {
300
+ if (!(k in incoming.noThink)) continue
301
+ const v = coerce(kind, incoming.noThink[k])
302
+ if (v === undefined) { ignored.push('noThink.' + k); continue }
303
+ nt[k] = v
304
+ applied.push('noThink.' + k)
305
+ }
306
+ next.noThink = nt
307
+ }
308
+
309
+ try {
310
+ mkdirSync(dir, { recursive: true })
311
+ writeFileSync(cfgPath, JSON.stringify(next, null, 2), 'utf8')
312
+ } catch (err) {
313
+ json(res, 500, { ok: false, error: '写入失败:' + String(err?.message ?? err) })
314
+ return
315
+ }
316
+ log?.info?.('[memory-tiers] 设置已更新:%s 项,重启 dsh 生效', applied.length)
317
+ json(res, 200, { ok: true, applied, ignored, needRestart: true })
318
+ })
319
+
320
+ // ── 最近归档/压缩审计(设置页底部展示) ──
321
+ route('/dsh-memory/api/log', async (req, res) => {
322
+ if (req.method !== 'GET') { json(res, 405, { ok: false }); return }
323
+ let lines = []
324
+ try {
325
+ const p = join(dir, 'capture.log')
326
+ if (existsSync(p)) {
327
+ const all = readFileSync(p, 'utf8').trim().split('\n')
328
+ lines = all.slice(-15)
329
+ }
330
+ } catch { /* 读不到就当空 */ }
331
+ json(res, 200, { ok: true, lines })
332
+ })
333
+
334
+ return () => { for (const off of disposers) { try { off() } catch { /* 已卸 */ } } }
335
+ }