memory-eternal 0.9.17 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api.js CHANGED
@@ -1,483 +1,558 @@
1
- // 记忆核心 · 知识库 JSON API(与宿主无关的纯 HTTP 层)。
2
- //
3
- // 从 index.js 抽出的 handleApi/json:逻辑原封不动,仅把对 DSH 闭包
4
- // (vaultDir/vaultRoots/settings)的依赖改为通过 createApi(deps) 注入,
5
- // 使 DSH 插件(经 webServer 前缀路由)与独立 Web server(lib/web.js)
6
- // 共用同一份 API 实现。
7
- //
8
- // deps:
9
- // - vaultDir() → 当前激活 vault 根目录(string)
10
- // - vaultRoots() → [{ name, root }] 全部 profile 根(跨库聚合用)
11
- // - getSettings() → 插件设置对象(/budget /compress 读取;可选,缺省 {})
12
- // - getCaptureLog() → 自动沉淀运行轨迹(DSH host 注入;独立 web server 无此数据)
13
-
14
- import { gzipSync, brotliCompressSync, constants as zlibConstants } from 'node:zlib'
15
- import os from 'node:os'
16
- import { readFileSync } from 'node:fs'
17
- import { listCards, countCards, readCard, search, searchAll, graph, graphAll, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, restoreCard } from './vault.js'
18
- import { compressExcerpt } from './capture.js'
19
- import { buildDiagnostics } from './feedback.js'
20
- import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
21
- import { backupDb } from './db.js'
22
-
23
- export const API_PREFIX = '/memory-eternal/api'
24
-
25
- // npm 最新版本缓存(避免每次点「检查更新」都打 registry)
26
- const versionCache = { at: 0, latest: '' }
27
-
28
- export function createApi(deps = {}) {
29
- const vaultDir = deps.vaultDir ?? (() => '')
30
- const vaultRoots = deps.vaultRoots ?? (() => [{ name: '', root: vaultDir() }])
31
- const getSettings = deps.getSettings ?? (() => ({}))
32
- // DSH 宿主信息(独立 web server 无 DSH 环境时为 null)。head DSH 状态行用。
33
- const getDshInfo = deps.getDshInfo ?? (() => null)
34
- // 自动沉淀运行轨迹(最新在前)+ 健康状态(异常时 UI 顶部亮红)。
35
- const getCaptureLog = deps.getCaptureLog ?? (() => null)
36
- const getCaptureHealth = deps.getCaptureHealth ?? (() => null)
37
-
38
- return async function handleApi(req, res) {
39
- const vaultRoot = vaultDir()
40
- const url = new URL(req.url, 'http://localhost')
41
- const route = url.pathname.slice(API_PREFIX.length).replace(/\/+$/, '') || '/overview'
42
- const query = url.searchParams
43
-
44
- switch (route) {
45
- case '/overview': {
46
- await ensureVault(vaultRoot)
47
- json(res, 200, { ok: true, vaultDir: vaultRoot, ...(await overview(vaultRoot)) })
48
- return
49
- }
50
- case '/cards': {
51
- const kind = query.get('kind') || ''
52
- const q = query.get('q') || ''
53
- const status = (query.get('status') || 'approved').toLowerCase()
54
- // 真分页:offset/limit + total(客户端据此判断还有没有下一页)
55
- const limit = Math.min(Math.max(Number(query.get('limit')) || 200, 1), 500)
56
- const offset = Math.max(Number(query.get('offset')) || 0, 0)
57
- const sort = query.get('sort') === 'title' ? 'title' : 'recent'
58
- const agent = query.get('agent') || '' // 服务端署名过滤(如 agent=deepseek-harness)
59
- // all = 全部(含 pending/rejected,排除回收站);默认/approved = 已审核。
60
- const want = status === 'all' ? ['approved', 'pending', 'rejected'] : status === 'pending' || status === 'rejected' ? [status] : ['approved']
61
- if (q.trim()) {
62
- // 检索是相关性排序:先把命中集合取齐,再在内存里分页(保证 total 与顺序一致)
63
- const hits = await search(vaultRoot, q, { limit: 500 })
64
- const hitPaths = new Set(hits.map((h) => h.path))
65
- let cards = (await listCards(vaultRoot, { status: want, kind, agent })).filter((c) => hitPaths.has(c.path))
66
- const total = cards.length
67
- json(res, 200, { ok: true, cards: cards.slice(offset, offset + limit), total, offset, limit })
68
- return
69
- }
70
- const total = await countCards(vaultRoot, { status: want, kind, agent })
71
- const cards = await listCards(vaultRoot, { status: want, kind, agent, limit, offset, sort })
72
- json(res, 200, { ok: true, cards, total, offset, limit })
73
- return
74
- }
75
- case '/card': {
76
- const rel = query.get('path') || ''
77
- if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
78
- const text = await readCard(vaultRoot, rel)
79
- json(res, 200, { ok: true, path: rel, text })
80
- return
81
- }
82
- case '/search': {
83
- const q = query.get('q') || ''
84
- if (!q.trim()) return json(res, 200, { ok: true, hits: [] })
85
- const all = query.get('all') === '1'
86
- const semantic = query.get('semantic') === '1'
87
- let hits
88
- try { hits = all ? await searchAll(vaultRoots(), q, { limit: 30, semantic }) : await search(vaultRoot, q, { limit: 30, semantic }) }
89
- catch (e) { hits = await search(vaultRoot, q, { limit: 30, semantic }) }
90
- json(res, 200, { ok: true, hits })
91
- return
92
- }
93
- case '/version-check': {
94
- // 版本跟踪:运行中(宿主启动时加载) vs 磁盘安装 vs npm 最新。
95
- // 典型场景:磁盘已更新,但 DSH 进程仍加载旧版 → UI 显示旧版本号、新接口 404。
96
- const loaded = (getDshInfo() || {}).version || ''
97
- let onDisk = ''
98
- try {
99
- const pj = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
100
- onDisk = String(pj.version || '')
101
- } catch { onDisk = '' }
102
- let latest = ''
103
- let checkError = ''
104
- const force = query.get('force') === '1'
105
- try {
106
- if (force || !versionCache.at || Date.now() - versionCache.at > 10 * 60 * 1000) {
107
- const ctl = new AbortController()
108
- const timer = setTimeout(() => ctl.abort(), 4000)
109
- const resp = await fetch('https://registry.npmjs.org/memory-eternal', { signal: ctl.signal })
110
- clearTimeout(timer)
111
- const data = await resp.json()
112
- latest = String((data && data['dist-tags'] && data['dist-tags'].latest) || '')
113
- versionCache.at = Date.now()
114
- versionCache.latest = latest
115
- } else {
116
- latest = versionCache.latest
117
- }
118
- } catch (e) { checkError = String((e && e.message) || e).slice(0, 120) }
119
- json(res, 200, {
120
- ok: true,
121
- loaded,
122
- onDisk,
123
- latest,
124
- checkError,
125
- stale: !!(loaded && onDisk && loaded !== onDisk),
126
- updateAvailable: !!(latest && onDisk && latest !== onDisk),
127
- updateCommand: 'dsh plugin --profile <你的 profile> add memory-eternal@latest',
128
- })
129
- return
130
- }
131
- case '/diagnostics': {
132
- // 「反馈异常」用:返回一段可直接贴进 issue 的**已脱敏**诊断文本。
133
- // 宿主与独立 web 共用这个实现;home 目录、各类 key/token 全都会被替换掉。
134
- const cfgD = getSettings() ?? {}
135
- const dshD = getDshInfo() ?? null
136
- const envD = typeof process !== 'undefined' ? process.env : {}
137
- let countsD = null
138
- try { countsD = await overview(vaultRoot) } catch { countsD = null }
139
- let healthD = null
140
- try { healthD = getCaptureHealth() } catch { healthD = null }
141
- let logD = []
142
- try {
143
- const raw = getCaptureLog()
144
- logD = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.entries) ? raw.entries : [])
145
- } catch { logD = [] }
146
- const homeD = (() => { try { return os.homedir() } catch { return '' } })()
147
- const text = buildDiagnostics({
148
- version: (dshD && dshD.version) || envD.MEMORY_ETERNAL_VERSION || '',
149
- dshVersion: (dshD && dshD.version) || '',
150
- host: dshD ? 'deepseek-harness' : 'standalone web',
151
- os: process.platform + ' ' + process.arch + ' / node ' + process.version,
152
- vaultDir: vaultRoot,
153
- vaultSource: dshD && dshD.vaultSource,
154
- workspace: dshD && dshD.workspace,
155
- cards: countsD ? countsD.total : undefined,
156
- health: healthD,
157
- captureLog: logD,
158
- maxTokens: undefined,
159
- }, homeD)
160
- json(res, 200, { ok: true, diagnostics: text, repo: 'EternalNight996/memory-eternal', version: (dshD && dshD.version) || envD.MEMORY_ETERNAL_VERSION || '' })
161
- return
162
- }
163
- case '/graph': {
164
- await ensureVault(vaultRoot)
165
- const all = query.get('all') === '1'
166
- let g
167
- try { g = all ? await graphAll(vaultRoots()) : await graph(vaultRoot) }
168
- catch (e) { g = await graph(vaultRoot) }
169
- json(res, 200, { ok: true, ...g })
170
- return
171
- }
172
- case '/capture-log': {
173
- // 最近 100 条自动沉淀运行轨迹(监听 → 判定 → 写入/跳过原因)+ 健康状态。
174
- // 宿主侧是内存缓冲(同步),独立 Web 侧是从 JSONL 读文件(异步)→ 统一 await。
175
- const log = await getCaptureLog()
176
- if (!log) return json(res, 200, { ok: true, available: false, entries: [], health: null })
177
- json(res, 200, { ok: true, available: true, health: await getCaptureHealth(), entries: log.slice(0, 100) })
178
- return
179
- }
180
- case '/export': {
181
- await ensureVault(vaultRoot)
182
- json(res, 200, { ok: true, cards: await exportCards(vaultRoot) })
183
- return
184
- }
185
- case '/delete': {
186
- const rel = query.get('path') || ''
187
- if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
188
- // 软删(进回收站);?permanent=1 直接永久删除
189
- const permanent = query.get('permanent') === '1'
190
- await deleteCard(vaultRoot, rel, { permanent })
191
- json(res, 200, { ok: true, soft: !permanent })
192
- return
193
- }
194
- case '/audit/list': {
195
- const { pending, rejected } = await auditQueue(vaultRoot)
196
- json(res, 200, { ok: true, pending, rejected })
197
- return
198
- }
199
- case '/audit/approve': {
200
- // POST {path} 或 GET ?path= → status=approved
201
- const rel = query.get('path') || (await readBody(req))?.path || ''
202
- if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
203
- await setCardStatus(vaultRoot, rel, 'approved')
204
- json(res, 200, { ok: true, path: rel, status: 'approved' })
205
- return
206
- }
207
- case '/audit/reject': {
208
- // POST {path} 或 GET ?path= → status=rejected(标记驳回,不删除)
209
- const rel = query.get('path') || (await readBody(req))?.path || ''
210
- if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
211
- await setCardStatus(vaultRoot, rel, 'rejected')
212
- json(res, 200, { ok: true, path: rel, status: 'rejected' })
213
- return
214
- }
215
- case '/recycle/list': {
216
- const items = await recycleList(vaultRoot)
217
- json(res, 200, { ok: true, items })
218
- return
219
- }
220
- case '/recycle/restore': {
221
- const rel = query.get('path') || ''
222
- if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
223
- await restoreCard(vaultRoot, rel)
224
- json(res, 200, { ok: true, path: rel })
225
- return
226
- }
227
- case '/recycle/purge': {
228
- const rel = query.get('path') || ''
229
- if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
230
- await deleteCard(vaultRoot, rel, { permanent: true })
231
- json(res, 200, { ok: true, path: rel, purged: true })
232
- return
233
- }
234
- case '/recycle/purge-expired': {
235
- const days = Number(query.get('days')) || 30
236
- const r = await purgeExpired(vaultRoot, days)
237
- json(res, 200, r)
238
- return
239
- }
240
- case '/write': {
241
- const chunks = []
242
- for await (const chunk of req) chunks.push(chunk)
243
- const raw = Buffer.concat(chunks).toString('utf8')
244
- let body
245
- try { body = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
246
- if (!body.body) return json(res, 400, { ok: false, error: '缺少正文' })
247
- await ensureVault(vaultRoot)
248
- const r = await writeCard(vaultRoot, { kind: body.kind || 'knowledge', title: body.title || '无标题', tags: body.tags || [], body: body.body, source: body.source || 'manual', status: 'pending', submittedBy: body.source || 'manual', severity: body.severity || 'info', reason: body.reason || '手动创建' }, { dedup: false })
249
- json(res, 200, r)
250
- return
251
- }
252
- case '/import': {
253
- const importChunks = []
254
- for await (const chunk of req) importChunks.push(chunk)
255
- const raw = Buffer.concat(importChunks).toString('utf8')
256
- if (raw.length > 20 * 1024 * 1024) return json(res, 413, { ok: false, error: '文件过大' })
257
- let payload
258
- try { payload = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
259
- const list = payload.cards || []
260
- if (!Array.isArray(list)) return json(res, 400, { ok: false, error: '缺少 cards 数组' })
261
- let imported = 0, skipped = 0
262
- for (const c of list) {
263
- const text = c.text || ''
264
- let kind = c.kind || 'knowledge', title = c.title || '导入记忆', tags = [], body = text, source = ''
265
- try { const p = parseCard(text); kind = p.meta.kind || kind; title = p.meta.title || title; tags = p.meta.tags || tags; body = p.body; source = p.meta.source } catch {}
266
- const r = await writeCard(vaultRoot, { kind, title, tags, body, source, status: 'pending' })
267
- if (r.ok) imported++; else skipped++
268
- }
269
- json(res, 200, { ok: true, imported, skipped })
270
- return
271
- }
272
- case '/merge': {
273
- const paths = (query.get('paths') || '').split(',').map((p) => p.trim()).filter(Boolean)
274
- const r = await mergeCards(vaultRoot, paths)
275
- if (!r.ok) return json(res, 400, r)
276
- json(res, 200, r)
277
- return
278
- }
279
- case '/stats': {
280
- await ensureVault(vaultRoot)
281
- const days = Math.min(Number(query.get('days')) || 30, 90)
282
- json(res, 200, { ok: true, ...(await stats(vaultRoot)), trend: await dailyCounts(vaultRoot, days) })
283
- return
284
- }
285
- case '/optimize': {
286
- // 非破坏性:只返回「整理建议」(相似卡对 + 陈旧卡),不自动删改。
287
- json(res, 200, { ok: true, ...(await optimizeCandidates(vaultRoot)) })
288
- return
289
- }
290
- case '/optimize-execute': {
291
- // 一键优化执行:合并相似卡 + 可选清理陈旧卡。POST body: {cleanupStale?, simThreshold?, staleDays?, dryRun?}
292
- if (req.method !== 'POST') { json(res, 405, { ok: false, error: '需 POST' }); return }
293
- let raw = ''
294
- for await (const chunk of req) raw += chunk
295
- let opts = {}
296
- try { opts = JSON.parse(raw || '{}') } catch { opts = {} }
297
- const result = await optimizeApply(vaultRoot, opts)
298
- json(res, 200, result)
299
- return
300
- }
301
- case '/setup-run': {
302
- // 「补全 MCP」:真正执行 runSetup(写外部 agent 配置)。共享层(独立 web 也可用)
303
- if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
304
- try {
305
- const out = await runSetup({ log: () => {}, enabled: true })
306
- json(res, 200, { ok: true, results: out.results })
307
- } catch (e) { json(res, 500, { ok: false, error: String(e?.message || e) }) }
308
- return
309
- }
310
- case '/mcp/action': {
311
- // 单智能体安装/卸载 MCP:POST {agent, action}。共享层(独立 web 可用)
312
- if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
313
- try {
314
- let raw = ''
315
- for await (const chunk of req) raw += chunk
316
- const body = JSON.parse(raw || '{}')
317
- const out = await mcpAgentAction(String(body.agent || ''), String(body.action || ''), { log: () => {} })
318
- json(res, 200, out)
319
- } catch (e) { json(res, 500, { ok: false, error: String(e?.message || e) }) }
320
- return
321
- }
322
- case '/setup-status': {
323
- // 只读查询各 agent MCP 配置状态,不写任何文件
324
- const status = await getSetupStatus()
325
- // prepend DSH 宿主行(独立 web server 无 DSH 环境时只有外部 agent)
326
- const dsh = getDshInfo ? getDshInfo() : null
327
- if (dsh) {
328
- status.agents = [{ name: 'dsh', label: dsh.label || 'DSH (当前宿主)', installed: true, mcpConfigured: true, isDsh: true, recallTool: dsh.memoryRecallTool, autoCapture: dsh.autoCapture, autoRecall: dsh.autoRecall, vaultDir: dsh.vaultDir }, ...(status.agents || [])]
329
- }
330
- json(res, 200, status)
331
- return
332
- }
333
- case '/feedback': {
334
- const fbChunks = []
335
- for await (const chunk of req) fbChunks.push(chunk)
336
- const raw = Buffer.concat(fbChunks).toString('utf8')
337
- let rec
338
- try { rec = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
339
- if (!rec || !rec.path) return json(res, 400, { ok: false, error: '缺少 path' })
340
- await addFeedback(vaultRoot, { query: String(rec.query || ''), path: String(rec.path || ''), useful: rec.useful === true })
341
- json(res, 200, { ok: true })
342
- return
343
- }
344
- case '/budget': {
345
- try {
346
- const cfg = getSettings() ?? {}
347
- const env = typeof process !== 'undefined' ? process.env : {}
348
- json(res, 200, {
349
- ok: true,
350
- budgetChars: cfg.sessionBudgetChars ?? 80000,
351
- recallLimit: cfg.recallLimit ?? 5,
352
- embedding: cfg.recallEmbedding || '',
353
- // 服务自管理配置(DSH host 从 settings 读;独立 web server 从 env 兜底)
354
- autoWeb: cfg.autoWeb ?? env.MEMORY_AUTO_WEB !== '0',
355
- autoWebMode: cfg.autoWebMode || 'init',
356
- webPort: Number(cfg.webPort) || Number(env.MEMORY_WEB_PORT) || 7999,
357
- webCheckIntervalMs: Number(cfg.webCheckIntervalMs) || 5000,
358
- webMaxRestart: Number(cfg.webMaxRestart) || 10,
359
- watchdogAutoSpawn: cfg.watchdogAutoSpawn ?? env.MEMORY_WATCHDOG === '1',
360
- autoMcpSetup: cfg.autoMcpSetup ?? false,
361
- })
362
- } catch (e) {
363
- // 配置读取异常时降级返回默认值,避免面板整块红屏
364
- json(res, 200, { ok: true, budgetChars: 80000, recallLimit: 5, embedding: '', autoWeb: true, autoWebMode: 'init', webPort: 7999, webCheckIntervalMs: 5000, webMaxRestart: 10, watchdogAutoSpawn: true, autoMcpSetup: false })
365
- }
366
- return
367
- }
368
- case '/config': {
369
- // 独立 web server(无 DSH settings)的配置读取兜底:env + 默认值。DSH host 的 /config 在其 webServer handler 拦截实现(可写),此处只读。
370
- const cfg = getSettings() ?? {}
371
- const env = typeof process !== 'undefined' ? process.env : {}
372
- const safe = {
373
- autoCapture: cfg.autoCapture ?? true, autoRecall: cfg.autoRecall ?? true, recallLimit: cfg.recallLimit ?? 5, recallSummaryLen: cfg.recallSummaryLen ?? 130, recallIncludeBody: cfg.recallIncludeBody ?? false,
374
- captureMinChars: cfg.captureMinChars ?? 200, captureCooldownMs: cfg.captureCooldownMs ?? 300000, dedupThreshold: cfg.dedupThreshold ?? 0.62, maxCardsPerDay: cfg.maxCardsPerDay ?? 60,
375
- distillEnabled: cfg.distillEnabled ?? true, dedupByLLM: cfg.dedupByLLM ?? true, captureMaxTokens: cfg.captureMaxTokens ?? 900, recallMinScore: cfg.recallMinScore ?? 2,
376
- autoWeb: cfg.autoWeb ?? env.MEMORY_AUTO_WEB !== '0', autoWebMode: cfg.autoWebMode || 'init', webPort: Number(cfg.webPort) || Number(env.MEMORY_WEB_PORT) || 7999, webCheckIntervalMs: Number(cfg.webCheckIntervalMs) || 5000, webMaxRestart: Number(cfg.webMaxRestart) || 10, watchdogAutoSpawn: cfg.watchdogAutoSpawn ?? env.MEMORY_WATCHDOG === '1', autoMcpSetup: cfg.autoMcpSetup ?? false,
377
- auditMode: cfg.auditMode ?? 'all', auditExemptAgents: cfg.auditExemptAgents ?? [], auditExemptKinds: cfg.auditExemptKinds ?? [], recycleRetentionDays: cfg.recycleRetentionDays ?? 30,
378
- vaultProfiles: Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : [], activeVault: cfg.activeVault || '',
379
- enabled: cfg.enabled !== false, vaultDir: cfg.vaultDir || '', captureProvider: cfg.captureProvider || '',
380
- captureModel: cfg.captureModel || '', recallEmbedding: cfg.recallEmbedding || '', sessionBudgetChars: cfg.sessionBudgetChars ?? 80000,
381
- webPort: cfg.webPort ?? 7999, webCheckIntervalMs: cfg.webCheckIntervalMs ?? 5000, webMaxRestart: cfg.webMaxRestart ?? 10,
382
- }
383
- json(res, 200, { ok: true, config: safe, revision: 0, writable: false, readonly: true, version: env.MEMORY_ETERNAL_VERSION || '' })
384
- return
385
- }
386
- case '/compress': {
387
- // 记忆侧「压缩产物」接口:供 harness 在会话内压缩旧轮次时调用,返回一段可注入的摘要。
388
- const cfg = getSettings() ?? {}
389
- const body = new URLSearchParams(query)
390
- const text = body.get('text') || ''
391
- const maxChars = Math.min(Number(body.get('max')) || 2400, 6000)
392
- if (!text.trim()) return json(res, 400, { ok: false, error: '缺少 text' })
393
- const compressed = await compressExcerpt(text, maxChars)
394
- json(res, 200, { ok: true, compressed, budgetChars: cfg.sessionBudgetChars ?? 80000 })
395
- return
396
- }
397
- case '/backup': {
398
- const r = await backupDb(vaultRoot, { maxKeep: 7 })
399
- json(res, r.ok ? 200 : 500, r)
400
- return
401
- }
402
- default:
403
- json(res, 404, { ok: false, error: '未知接口' })
404
- }
405
- }
406
- }
407
-
408
- export function json(res, status, body) {
409
- const payload = JSON.stringify(body)
410
- const headers = {
411
- 'Content-Type': 'application/json; charset=utf-8',
412
- 'Cache-Control': 'no-store',
413
- 'Vary': 'Accept-Encoding',
414
- }
415
- const { body: out, encoding } = encodeBody(res, Buffer.from(payload, 'utf8'))
416
- if (encoding) headers['Content-Encoding'] = encoding
417
- headers['Content-Length'] = out.length
418
- res.writeHead(status, headers)
419
- res.end(out)
420
- }
421
-
422
- /** 请求方的 Accept-Encoding(`res.req` 由 Node 的 http server 挂上;取不到当不支持)。 */
423
- export function acceptEncoding(res) {
424
- try {
425
- const req = res && (res.req || res.__meReq)
426
- return String((req && req.headers && req.headers['accept-encoding']) || '').toLowerCase()
427
- } catch { return '' }
428
- }
429
-
430
- /** 请求方是否接受 gzip。 */
431
- export function acceptsGzip(res) {
432
- return acceptEncoding(res).includes('gzip')
433
- }
434
-
435
- /**
436
- * 协商压缩大响应体:
437
- * - 支持 brotli 就优先 br(质量 4 = 在线压缩的速度/体积平衡点,比 gzip 再小约 30%)
438
- * - 否则退 gzip
439
- * - 都不支持、体积太小、或压完反而更大 → 明文
440
- */
441
- export function encodeBody(res, buf) {
442
- if (buf.length <= 2048) return { body: buf, encoding: '' }
443
- const ae = acceptEncoding(res)
444
- const wantBr = /(^|[,\s])br($|[,\s;])/.test(ae)
445
- const wantGzip = ae.includes('gzip')
446
- // 大响应体同时压 br + gzip 取更小的那个:br q5 在图谱这种高重复 JSON 上比 gzip 小 35%,
447
- // 但在高熵的 JS/小 JSON 上偶尔略大 —— 取小者保证永不劣化(成本仅几毫秒)。
448
- if (buf.length >= 16 * 1024) {
449
- let br = null
450
- if (wantBr) {
451
- try { br = brotliCompressSync(buf, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5, [zlibConstants.BROTLI_PARAM_SIZE_HINT]: buf.length } }) } catch { br = null }
452
- }
453
- let gz = null
454
- if (wantGzip) { try { gz = gzipSync(buf) } catch { gz = null } }
455
- const best = [br && { body: br, encoding: 'br' }, gz && { body: gz, encoding: 'gzip' }]
456
- .filter(Boolean)
457
- .filter((x) => x.body.length < buf.length)
458
- .sort((a, b) => a.body.length - b.body.length)[0]
459
- if (best) return best
460
- return { body: buf, encoding: '' }
461
- }
462
- if (wantBr) {
463
- try {
464
- const out = brotliCompressSync(buf, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5, [zlibConstants.BROTLI_PARAM_SIZE_HINT]: buf.length } })
465
- if (out.length < buf.length) return { body: out, encoding: 'br' }
466
- } catch { /* 落到 gzip */ }
467
- }
468
- if (wantGzip) {
469
- const out = gzipSync(buf)
470
- if (out.length < buf.length) return { body: out, encoding: 'gzip' }
471
- }
472
- return { body: buf, encoding: '' }
473
- }
474
-
475
- /** 读请求体(POST body),返回解析后的 JSON 对象或空。 */
476
- export async function readBody(req) {
477
- try {
478
- const chunks = []
479
- for await (const chunk of req) chunks.push(chunk)
480
- const raw = Buffer.concat(chunks).toString('utf8')
481
- return raw ? JSON.parse(raw) : {}
482
- } catch { return {} }
483
- }
1
+ // 记忆核心 · 知识库 JSON API(与宿主无关的纯 HTTP 层)。
2
+ //
3
+ // 从 index.js 抽出的 handleApi/json:逻辑原封不动,仅把对 DSH 闭包
4
+ // (vaultDir/vaultRoots/settings)的依赖改为通过 createApi(deps) 注入,
5
+ // 使 DSH 插件(经 webServer 前缀路由)与独立 Web server(lib/web.js)
6
+ // 共用同一份 API 实现。
7
+ //
8
+ // deps:
9
+ // - vaultDir() → 当前激活 vault 根目录(string)
10
+ // - vaultRoots() → [{ name, root }] 全部 profile 根(跨库聚合用)
11
+ // - getSettings() → 插件设置对象(/budget /compress 读取;可选,缺省 {})
12
+ // - getCaptureLog() → 自动沉淀运行轨迹(DSH host 注入;独立 web server 无此数据)
13
+
14
+ import { gzipSync, brotliCompressSync, constants as zlibConstants } from 'node:zlib'
15
+ import os from 'node:os'
16
+ import { readFileSync } from 'node:fs'
17
+ import { listCards, countCards, readCard, search, searchAll, graph, graphAll, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, restoreCard } from './vault.js'
18
+ import { compressExcerpt } from './capture.js'
19
+ import { buildDiagnostics } from './feedback.js'
20
+ import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
21
+ import { backupDb } from './db.js'
22
+ import { createHub } from './sse.js'
23
+
24
+ // 独立 web 的 SSE 中心:配置/数据变更即时推给已打开的页面(无需手动刷新)
25
+ const hub = createHub()
26
+ /** 供 lib/web.js 在检测到共享配置变化时广播。 */
27
+ export function broadcast(event, data) { return hub.broadcast(event, data) }
28
+
29
+ export const API_PREFIX = '/memory-eternal/api'
30
+
31
+ // npm 最新版本缓存(避免每次点「检查更新」都打 registry)
32
+ const versionCache = { at: 0, latest: '' }
33
+
34
+ export function createApi(deps = {}) {
35
+ const vaultDir = deps.vaultDir ?? (() => '')
36
+ const vaultRoots = deps.vaultRoots ?? (() => [{ name: '', root: vaultDir() }])
37
+ const getSettings = deps.getSettings ?? (() => ({}))
38
+ // DSH 宿主信息(独立 web server 无 DSH 环境时为 null)。head DSH 状态行用。
39
+ const getDshInfo = deps.getDshInfo ?? (() => null)
40
+ // 自动沉淀运行轨迹(最新在前)+ 健康状态(异常时 UI 顶部亮红)。
41
+ const getCaptureLog = deps.getCaptureLog ?? (() => null)
42
+ const getCaptureHealth = deps.getCaptureHealth ?? (() => null)
43
+
44
+ return async function handleApi(req, res) {
45
+ const vaultRoot = vaultDir()
46
+ const url = new URL(req.url, 'http://localhost')
47
+ const route = url.pathname.slice(API_PREFIX.length).replace(/\/+$/, '') || '/overview'
48
+ const query = url.searchParams
49
+
50
+ switch (route) {
51
+ case '/overview': {
52
+ await ensureVault(vaultRoot)
53
+ json(res, 200, { ok: true, vaultDir: vaultRoot, ...(await overview(vaultRoot)) })
54
+ return
55
+ }
56
+ case '/cards': {
57
+ const kind = query.get('kind') || ''
58
+ const q = query.get('q') || ''
59
+ const status = (query.get('status') || 'approved').toLowerCase()
60
+ // 真分页:offset/limit + total(客户端据此判断还有没有下一页)
61
+ const limit = Math.min(Math.max(Number(query.get('limit')) || 200, 1), 500)
62
+ const offset = Math.max(Number(query.get('offset')) || 0, 0)
63
+ const sort = query.get('sort') === 'title' ? 'title' : 'recent'
64
+ const agent = query.get('agent') || '' // 服务端署名过滤(如 agent=deepseek-harness)
65
+ // all = 全部(含 pending/rejected,排除回收站);默认/approved = 已审核。
66
+ // 白名单(P0 修复):除下面三种取值外一律按 approved 处理,未知 status 不再可能越权列未审核卡。
67
+ const want = status === 'all' ? ['approved', 'pending', 'rejected']
68
+ : status === 'pending' ? ['pending']
69
+ : status === 'rejected' ? ['rejected']
70
+ : ['approved']
71
+ if (q.trim()) {
72
+ // 检索是相关性排序:先把命中集合取齐,再在内存里分页(保证 total 与顺序一致)
73
+ const hits = await search(vaultRoot, q, { limit: 500 })
74
+ const hitPaths = new Set(hits.map((h) => h.path))
75
+ let cards = (await listCards(vaultRoot, { status: want, kind, agent })).filter((c) => hitPaths.has(c.path))
76
+ const total = cards.length
77
+ json(res, 200, { ok: true, cards: cards.slice(offset, offset + limit), total, offset, limit })
78
+ return
79
+ }
80
+ const total = await countCards(vaultRoot, { status: want, kind, agent })
81
+ const cards = await listCards(vaultRoot, { status: want, kind, agent, limit, offset, sort })
82
+ json(res, 200, { ok: true, cards, total, offset, limit })
83
+ return
84
+ }
85
+ case '/card': {
86
+ const rel = query.get('path') || ''
87
+ if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
88
+ // 默认只能读已审核卡(P0 修复)。审核中心 / 回收站要预览未审核卡时带 ?status=pending|rejected|deleted,
89
+ // 但只放行**确实在审核队列或回收站里**的 path —— 未知 path 不能靠参数把未审核正文读出来。
90
+ const statusParam = (query.get('status') || '').toLowerCase()
91
+ const wantsUnapproved = statusParam === 'pending' || statusParam === 'rejected' || statusParam === 'deleted'
92
+ if (wantsUnapproved) {
93
+ let inQueue = false
94
+ try {
95
+ if (statusParam === 'deleted') {
96
+ inQueue = (await recycleList(vaultRoot)).some((c) => c.path === rel)
97
+ } else {
98
+ const q = await auditQueue(vaultRoot)
99
+ inQueue = (statusParam === 'pending' ? q.pending : q.rejected).some((c) => c.path === rel)
100
+ }
101
+ } catch { inQueue = false }
102
+ if (!inQueue) return json(res, 403, { ok: false, error: '该卡片不在审核队列 / 回收站中,不能以未审核身份读取', code: 'CARD_NOT_APPROVED' })
103
+ }
104
+ const card = await readCard(vaultRoot, rel, { allowUnapproved: wantsUnapproved })
105
+ json(res, 200, { ok: true, path: card.path, status: card.status, text: card.text })
106
+ return
107
+ }
108
+ case '/search': {
109
+ const q = query.get('q') || ''
110
+ if (!q.trim()) return json(res, 200, { ok: true, hits: [] })
111
+ const all = query.get('all') === '1'
112
+ const semantic = query.get('semantic') === '1'
113
+ let hits
114
+ try { hits = all ? await searchAll(vaultRoots(), q, { limit: 30, semantic }) : await search(vaultRoot, q, { limit: 30, semantic }) }
115
+ catch (e) { hits = await search(vaultRoot, q, { limit: 30, semantic }) }
116
+ json(res, 200, { ok: true, hits })
117
+ return
118
+ }
119
+ case '/version-check': {
120
+ // 版本跟踪:运行中(宿主启动时加载) vs 磁盘安装 vs npm 最新。
121
+ // 典型场景:磁盘已更新,但 DSH 进程仍加载旧版 → UI 显示旧版本号、新接口 404。
122
+ const loaded = (getDshInfo() || {}).version || ''
123
+ let onDisk = ''
124
+ try {
125
+ const pj = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
126
+ onDisk = String(pj.version || '')
127
+ } catch { onDisk = '' }
128
+ let latest = ''
129
+ let checkError = ''
130
+ const force = query.get('force') === '1'
131
+ try {
132
+ if (force || !versionCache.at || Date.now() - versionCache.at > 10 * 60 * 1000) {
133
+ const ctl = new AbortController()
134
+ const timer = setTimeout(() => ctl.abort(), 4000)
135
+ const resp = await fetch('https://registry.npmjs.org/memory-eternal', { signal: ctl.signal })
136
+ clearTimeout(timer)
137
+ const data = await resp.json()
138
+ latest = String((data && data['dist-tags'] && data['dist-tags'].latest) || '')
139
+ versionCache.at = Date.now()
140
+ versionCache.latest = latest
141
+ } else {
142
+ latest = versionCache.latest
143
+ }
144
+ } catch (e) { checkError = String((e && e.message) || e).slice(0, 120) }
145
+ json(res, 200, {
146
+ ok: true,
147
+ loaded,
148
+ onDisk,
149
+ latest,
150
+ checkError,
151
+ stale: !!(loaded && onDisk && loaded !== onDisk),
152
+ updateAvailable: !!(latest && onDisk && latest !== onDisk),
153
+ updateCommand: 'dsh plugin --profile <你的 profile> add memory-eternal@latest',
154
+ })
155
+ return
156
+ }
157
+ case '/diagnostics': {
158
+ // 「反馈异常」用:返回一段可直接贴进 issue 的**已脱敏**诊断文本。
159
+ // 宿主与独立 web 共用这个实现;home 目录、各类 key/token 全都会被替换掉。
160
+ const cfgD = getSettings() ?? {}
161
+ const dshD = getDshInfo() ?? null
162
+ const envD = typeof process !== 'undefined' ? process.env : {}
163
+ let countsD = null
164
+ try { countsD = await overview(vaultRoot) } catch { countsD = null }
165
+ let healthD = null
166
+ try { healthD = getCaptureHealth() } catch { healthD = null }
167
+ let logD = []
168
+ try {
169
+ const raw = getCaptureLog()
170
+ logD = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.entries) ? raw.entries : [])
171
+ } catch { logD = [] }
172
+ const homeD = (() => { try { return os.homedir() } catch { return '' } })()
173
+ const text = buildDiagnostics({
174
+ version: (dshD && dshD.version) || envD.MEMORY_ETERNAL_VERSION || '',
175
+ dshVersion: (dshD && dshD.version) || '',
176
+ host: dshD ? 'deepseek-harness' : 'standalone web',
177
+ os: process.platform + ' ' + process.arch + ' / node ' + process.version,
178
+ vaultDir: vaultRoot,
179
+ vaultSource: dshD && dshD.vaultSource,
180
+ workspace: dshD && dshD.workspace,
181
+ cards: countsD ? countsD.total : undefined,
182
+ health: healthD,
183
+ captureLog: logD,
184
+ maxTokens: undefined,
185
+ }, homeD)
186
+ json(res, 200, { ok: true, diagnostics: text, repo: 'EternalNight996/memory-eternal', version: (dshD && dshD.version) || envD.MEMORY_ETERNAL_VERSION || '' })
187
+ return
188
+ }
189
+ case '/graph': {
190
+ await ensureVault(vaultRoot)
191
+ const all = query.get('all') === '1'
192
+ let g
193
+ try { g = all ? await graphAll(vaultRoots()) : await graph(vaultRoot) }
194
+ catch (e) { g = await graph(vaultRoot) }
195
+ json(res, 200, { ok: true, ...g })
196
+ return
197
+ }
198
+ case '/capture-log': {
199
+ // 最近 100 条自动沉淀运行轨迹(监听 → 判定 → 写入/跳过原因)+ 健康状态。
200
+ // 宿主侧是内存缓冲(同步),独立 Web 侧是从 JSONL 读文件(异步)→ 统一 await。
201
+ const log = await getCaptureLog()
202
+ if (!log) return json(res, 200, { ok: true, available: false, entries: [], health: null })
203
+ json(res, 200, { ok: true, available: true, health: await getCaptureHealth(), entries: log.slice(0, 100) })
204
+ return
205
+ }
206
+ case '/export': {
207
+ await ensureVault(vaultRoot)
208
+ json(res, 200, { ok: true, cards: await exportCards(vaultRoot) })
209
+ return
210
+ }
211
+ case '/delete': {
212
+ const rel = query.get('path') || ''
213
+ if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
214
+ // 软删(进回收站);?permanent=1 直接永久删除
215
+ const permanent = query.get('permanent') === '1'
216
+ await deleteCard(vaultRoot, rel, { permanent })
217
+ json(res, 200, { ok: true, soft: !permanent })
218
+ return
219
+ }
220
+ case '/audit/list': {
221
+ const { pending, rejected } = await auditQueue(vaultRoot)
222
+ json(res, 200, { ok: true, pending, rejected })
223
+ return
224
+ }
225
+ case '/audit/approve': {
226
+ // POST {path} 或 GET ?path= → status=approved
227
+ const rel = query.get('path') || (await readBody(req))?.path || ''
228
+ if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
229
+ await setCardStatus(vaultRoot, rel, 'approved')
230
+ json(res, 200, { ok: true, path: rel, status: 'approved' })
231
+ return
232
+ }
233
+ case '/audit/reject': {
234
+ // POST {path} 或 GET ?path= → status=rejected(标记驳回,不删除)
235
+ const rel = query.get('path') || (await readBody(req))?.path || ''
236
+ if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
237
+ await setCardStatus(vaultRoot, rel, 'rejected')
238
+ json(res, 200, { ok: true, path: rel, status: 'rejected' })
239
+ return
240
+ }
241
+ case '/recycle/list': {
242
+ const items = await recycleList(vaultRoot)
243
+ json(res, 200, { ok: true, items })
244
+ return
245
+ }
246
+ case '/recycle/restore': {
247
+ const rel = query.get('path') || ''
248
+ if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
249
+ await restoreCard(vaultRoot, rel)
250
+ json(res, 200, { ok: true, path: rel })
251
+ return
252
+ }
253
+ case '/recycle/purge': {
254
+ const rel = query.get('path') || ''
255
+ if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
256
+ await deleteCard(vaultRoot, rel, { permanent: true })
257
+ json(res, 200, { ok: true, path: rel, purged: true })
258
+ return
259
+ }
260
+ case '/recycle/purge-expired': {
261
+ const days = Number(query.get('days')) || 30
262
+ const r = await purgeExpired(vaultRoot, days)
263
+ json(res, 200, r)
264
+ return
265
+ }
266
+ case '/write': {
267
+ const chunks = []
268
+ for await (const chunk of req) chunks.push(chunk)
269
+ const raw = Buffer.concat(chunks).toString('utf8')
270
+ let body
271
+ try { body = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
272
+ if (!body.body) return json(res, 400, { ok: false, error: '缺少正文' })
273
+ await ensureVault(vaultRoot)
274
+ const r = await writeCard(vaultRoot, { kind: body.kind || 'knowledge', title: body.title || '无标题', tags: body.tags || [], body: body.body, source: body.source || 'manual', status: 'pending', submittedBy: body.source || 'manual', severity: body.severity || 'info', reason: body.reason || '手动创建' }, { dedup: false })
275
+ json(res, 200, r)
276
+ return
277
+ }
278
+ case '/import': {
279
+ const importChunks = []
280
+ for await (const chunk of req) importChunks.push(chunk)
281
+ const raw = Buffer.concat(importChunks).toString('utf8')
282
+ if (raw.length > 20 * 1024 * 1024) return json(res, 413, { ok: false, error: '文件过大' })
283
+ let payload
284
+ try { payload = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
285
+ const list = payload.cards || []
286
+ if (!Array.isArray(list)) return json(res, 400, { ok: false, error: '缺少 cards 数组' })
287
+ let imported = 0, skipped = 0
288
+ for (const c of list) {
289
+ const text = c.text || ''
290
+ let kind = c.kind || 'knowledge', title = c.title || '导入记忆', tags = [], body = text, source = '', status = c.status || 'pending'
291
+ try { const p = parseCard(text); kind = p.meta.kind || kind; title = p.meta.title || title; tags = p.meta.tags || tags; body = p.body; source = p.meta.source || source; status = p.meta.status || status } catch {}
292
+ // 两套存储:导入按导出时的状态回填(备份/迁移才完整),但仍过 enforceAudit 这道闸门 ——
293
+ // 校验不过的会被写成 pending 进隔离区,主库永远不会被导入数据绕过。
294
+ const r = await writeCard(vaultRoot, { kind, title, tags, body, source, status })
295
+ if (r.ok) imported++; else skipped++
296
+ }
297
+ json(res, 200, { ok: true, imported, skipped })
298
+ return
299
+ }
300
+ case '/merge': {
301
+ const paths = (query.get('paths') || '').split(',').map((p) => p.trim()).filter(Boolean)
302
+ const r = await mergeCards(vaultRoot, paths)
303
+ if (!r.ok) return json(res, 400, r)
304
+ json(res, 200, r)
305
+ return
306
+ }
307
+ case '/stats': {
308
+ await ensureVault(vaultRoot)
309
+ const days = Math.min(Number(query.get('days')) || 30, 90)
310
+ json(res, 200, { ok: true, ...(await stats(vaultRoot)), trend: await dailyCounts(vaultRoot, days) })
311
+ return
312
+ }
313
+ case '/optimize': {
314
+ // 非破坏性:只返回「整理建议」(相似卡对 + 陈旧卡),不自动删改。
315
+ json(res, 200, { ok: true, ...(await optimizeCandidates(vaultRoot)) })
316
+ return
317
+ }
318
+ case '/optimize-execute': {
319
+ // 一键优化执行:合并相似卡 + 可选清理陈旧卡。POST body: {cleanupStale?, simThreshold?, staleDays?, dryRun?}
320
+ if (req.method !== 'POST') { json(res, 405, { ok: false, error: '需 POST' }); return }
321
+ let raw = ''
322
+ for await (const chunk of req) raw += chunk
323
+ let opts = {}
324
+ try { opts = JSON.parse(raw || '{}') } catch { opts = {} }
325
+ const result = await optimizeApply(vaultRoot, opts)
326
+ json(res, 200, result)
327
+ return
328
+ }
329
+ case '/setup-run': {
330
+ // 「补全 MCP」:真正执行 runSetup(写外部 agent 配置)。共享层(独立 web 也可用)
331
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
332
+ try {
333
+ const out = await runSetup({ log: () => {}, enabled: true })
334
+ json(res, 200, { ok: true, results: out.results })
335
+ } catch (e) { json(res, 500, { ok: false, error: String(e?.message || e) }) }
336
+ return
337
+ }
338
+ case '/mcp/action': {
339
+ // 单智能体安装/卸载 MCP:POST {agent, action}。共享层(独立 web 可用)
340
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
341
+ try {
342
+ let raw = ''
343
+ for await (const chunk of req) raw += chunk
344
+ const body = JSON.parse(raw || '{}')
345
+ const out = await mcpAgentAction(String(body.agent || ''), String(body.action || ''), { log: () => {} })
346
+ json(res, 200, out)
347
+ } catch (e) { json(res, 500, { ok: false, error: String(e?.message || e) }) }
348
+ return
349
+ }
350
+ case '/setup-status': {
351
+ // 只读查询各 agent MCP 配置状态,不写任何文件
352
+ const status = await getSetupStatus()
353
+ // prepend DSH 宿主行(独立 web server 无 DSH 环境时只有外部 agent)
354
+ const dsh = getDshInfo ? getDshInfo() : null
355
+ if (dsh) {
356
+ status.agents = [{ name: 'dsh', label: dsh.label || 'DSH (当前宿主)', installed: true, mcpConfigured: true, isDsh: true, recallTool: dsh.memoryRecallTool, autoCapture: dsh.autoCapture, autoRecall: dsh.autoRecall, vaultDir: dsh.vaultDir }, ...(status.agents || [])]
357
+ }
358
+ json(res, 200, status)
359
+ return
360
+ }
361
+ case '/feedback': {
362
+ const fbChunks = []
363
+ for await (const chunk of req) fbChunks.push(chunk)
364
+ const raw = Buffer.concat(fbChunks).toString('utf8')
365
+ let rec
366
+ try { rec = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
367
+ if (!rec || !rec.path) return json(res, 400, { ok: false, error: '缺少 path' })
368
+ await addFeedback(vaultRoot, { query: String(rec.query || ''), path: String(rec.path || ''), useful: rec.useful === true })
369
+ json(res, 200, { ok: true })
370
+ return
371
+ }
372
+ case '/budget': {
373
+ try {
374
+ const cfg = getSettings() ?? {}
375
+ const env = typeof process !== 'undefined' ? process.env : {}
376
+ json(res, 200, {
377
+ ok: true,
378
+ budgetChars: cfg.sessionBudgetChars ?? 80000,
379
+ recallLimit: cfg.recallLimit ?? 5,
380
+ embedding: cfg.recallEmbedding || '',
381
+ // 服务自管理配置(DSH host 从 settings 读;独立 web server 从 env 兜底)
382
+ autoWeb: cfg.autoWeb ?? env.MEMORY_AUTO_WEB !== '0',
383
+ autoWebMode: cfg.autoWebMode || 'init',
384
+ webPort: Number(cfg.webPort) || Number(env.MEMORY_WEB_PORT) || 7999,
385
+ webCheckIntervalMs: Number(cfg.webCheckIntervalMs) || 5000,
386
+ webMaxRestart: Number(cfg.webMaxRestart) || 10,
387
+ watchdogAutoSpawn: cfg.watchdogAutoSpawn ?? env.MEMORY_WATCHDOG === '1',
388
+ autoMcpSetup: cfg.autoMcpSetup ?? false,
389
+ })
390
+ } catch (e) {
391
+ // 配置读取异常时降级返回默认值,避免面板整块红屏
392
+ json(res, 200, { ok: true, budgetChars: 80000, recallLimit: 5, embedding: '', autoWeb: true, autoWebMode: 'init', webPort: 7999, webCheckIntervalMs: 5000, webMaxRestart: 10, watchdogAutoSpawn: true, autoMcpSetup: false })
393
+ }
394
+ return
395
+ }
396
+ case '/events': {
397
+ // SSE 长连接(不 end),EventSource 会自动重连
398
+ hub.add(res)
399
+ return
400
+ }
401
+ case '/config': {
402
+ // 独立 web server(无 DSH settings)的配置读取兜底:env + 默认值。DSH host 的 /config 在其 webServer handler 拦截实现(可写),此处只读。
403
+ const cfg = getSettings() ?? {}
404
+ const env = typeof process !== 'undefined' ? process.env : {}
405
+ const safe = {
406
+ autoCapture: cfg.autoCapture ?? true, autoRecall: cfg.autoRecall ?? true, recallLimit: cfg.recallLimit ?? 5, recallSummaryLen: cfg.recallSummaryLen ?? 130, recallIncludeBody: cfg.recallIncludeBody ?? false,
407
+ captureMinChars: cfg.captureMinChars ?? 200, captureCooldownMs: cfg.captureCooldownMs ?? 300000, dedupThreshold: cfg.dedupThreshold ?? 0.62, maxCardsPerDay: cfg.maxCardsPerDay ?? 60,
408
+ distillEnabled: cfg.distillEnabled ?? true, dedupByLLM: cfg.dedupByLLM ?? true, captureMaxTokens: cfg.captureMaxTokens ?? 900, recallMinScore: cfg.recallMinScore ?? 2,
409
+ autoWeb: cfg.autoWeb ?? env.MEMORY_AUTO_WEB !== '0', autoWebMode: cfg.autoWebMode || 'init', webPort: Number(cfg.webPort) || Number(env.MEMORY_WEB_PORT) || 7999, webCheckIntervalMs: Number(cfg.webCheckIntervalMs) || 5000, webMaxRestart: Number(cfg.webMaxRestart) || 10, watchdogAutoSpawn: cfg.watchdogAutoSpawn ?? env.MEMORY_WATCHDOG === '1', autoMcpSetup: cfg.autoMcpSetup ?? false,
410
+ auditMode: cfg.auditMode ?? 'all', auditExemptAgents: cfg.auditExemptAgents ?? [], auditExemptKinds: cfg.auditExemptKinds ?? [], recycleRetentionDays: cfg.recycleRetentionDays ?? 30,
411
+ vaultProfiles: Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : [], activeVault: cfg.activeVault || '',
412
+ enabled: cfg.enabled !== false, vaultDir: cfg.vaultDir || '', captureProvider: cfg.captureProvider || '',
413
+ captureModel: cfg.captureModel || '', recallEmbedding: cfg.recallEmbedding || '', sessionBudgetChars: cfg.sessionBudgetChars ?? 80000,
414
+ webPort: cfg.webPort ?? 7999, webCheckIntervalMs: cfg.webCheckIntervalMs ?? 5000, webMaxRestart: cfg.webMaxRestart ?? 10,
415
+ }
416
+ const NUM_RANGE = {
417
+ dedupThreshold: [0, 1], captureMinChars: [0, 1000000], captureCooldownMs: [0, 1000000000],
418
+ maxCardsPerDay: [0, 1000000], captureMaxTokens: [100, 4000], recallMinScore: [0, 50],
419
+ recallLimit: [1, 20], recallSummaryLen: [40, 400], recycleRetentionDays: [1, 3650],
420
+ webPort: [1, 65535], webCheckIntervalMs: [1000, 600000], webMaxRestart: [1, 1000],
421
+ sessionBudgetChars: [0, 1000000000],
422
+ }
423
+ // POST:独立 web 没有 DSH settings 服务,改成写「待应用」文件,由 DSH 端自动应用
424
+ // (否则这里的保存按钮永远是死的 —— issue #12 的一半就栽在这)
425
+ if (req.method === 'POST') {
426
+ const body = await readBody(req)
427
+ const patch = body && typeof body.patch === 'object' && body.patch ? body.patch : null
428
+ if (!patch) return json(res, 400, { ok: false, error: '缺少 patch' })
429
+ const allowed = new Set(Object.keys(safe))
430
+ const clean = {}
431
+ const bad = []
432
+ for (const [k, v] of Object.entries(patch)) {
433
+ if (!allowed.has(k)) continue
434
+ const base = safe[k]
435
+ if (Array.isArray(base)) { if (!Array.isArray(v)) bad.push(k); else clean[k] = v; continue }
436
+ if (typeof base === 'number') {
437
+ const n = typeof v === 'number' ? v : Number(v)
438
+ const rng = NUM_RANGE[k] || [-Infinity, Infinity]
439
+ const lo = rng[0], hi = rng[1]
440
+ if (v === '' || v === null || v === undefined || !Number.isFinite(n)) bad.push(k + ' 需要数字')
441
+ else if (n < lo || n > hi) bad.push(k + ' 需要 ' + lo + '-' + hi + ',当前 ' + n)
442
+ else clean[k] = n
443
+ continue
444
+ }
445
+ if (typeof base === 'boolean') { if (typeof v !== 'boolean') bad.push(k); else clean[k] = v; continue }
446
+ if (typeof v !== 'string') bad.push(k); else clean[k] = v
447
+ }
448
+ if (bad.length) return json(res, 400, { ok: false, error: '字段值不合法:' + bad.join('、'), fields: bad })
449
+ if (!Object.keys(clean).length) return json(res, 400, { ok: false, error: '没有可写入的字段' })
450
+ const { writePendingConfig } = await import('./config-sync.js')
451
+ const out = writePendingConfig(process.env, clean)
452
+ hub.broadcast('config', { at: out.at, source: 'standalone', applied: Object.keys(clean) })
453
+ return json(res, 200, {
454
+ ok: true, applied: Object.keys(clean), pending: Object.keys(clean), retried: false, at: out.at,
455
+ note: '已写入待应用:DSH 端会自动同步(DSH 未运行时下次启动生效)',
456
+ })
457
+ }
458
+ json(res, 200, { ok: true, config: safe, revision: 0, writable: true, readonly: false, viaPending: true, version: env.MEMORY_ETERNAL_VERSION || '' })
459
+ return
460
+ }
461
+ case '/compress': {
462
+ // 记忆侧「压缩产物」接口:供 harness 在会话内压缩旧轮次时调用,返回一段可注入的摘要。
463
+ const cfg = getSettings() ?? {}
464
+ const body = new URLSearchParams(query)
465
+ const text = body.get('text') || ''
466
+ const maxChars = Math.min(Number(body.get('max')) || 2400, 6000)
467
+ if (!text.trim()) return json(res, 400, { ok: false, error: '缺少 text' })
468
+ const compressed = await compressExcerpt(text, maxChars)
469
+ json(res, 200, { ok: true, compressed, budgetChars: cfg.sessionBudgetChars ?? 80000 })
470
+ return
471
+ }
472
+ case '/backup': {
473
+ const r = await backupDb(vaultRoot, { maxKeep: 7 })
474
+ json(res, r.ok ? 200 : 500, r)
475
+ return
476
+ }
477
+ default:
478
+ json(res, 404, { ok: false, error: '未知接口' })
479
+ }
480
+ }
481
+ }
482
+
483
+ export function json(res, status, body) {
484
+ const payload = JSON.stringify(body)
485
+ const headers = {
486
+ 'Content-Type': 'application/json; charset=utf-8',
487
+ 'Cache-Control': 'no-store',
488
+ 'Vary': 'Accept-Encoding',
489
+ }
490
+ const { body: out, encoding } = encodeBody(res, Buffer.from(payload, 'utf8'))
491
+ if (encoding) headers['Content-Encoding'] = encoding
492
+ headers['Content-Length'] = out.length
493
+ res.writeHead(status, headers)
494
+ res.end(out)
495
+ }
496
+
497
+ /** 请求方的 Accept-Encoding(`res.req` 由 Node 的 http server 挂上;取不到当不支持)。 */
498
+ export function acceptEncoding(res) {
499
+ try {
500
+ const req = res && (res.req || res.__meReq)
501
+ return String((req && req.headers && req.headers['accept-encoding']) || '').toLowerCase()
502
+ } catch { return '' }
503
+ }
504
+
505
+ /** 请求方是否接受 gzip。 */
506
+ export function acceptsGzip(res) {
507
+ return acceptEncoding(res).includes('gzip')
508
+ }
509
+
510
+ /**
511
+ * 协商压缩大响应体:
512
+ * - 支持 brotli 就优先 br(质量 4 = 在线压缩的速度/体积平衡点,比 gzip 再小约 30%)
513
+ * - 否则退 gzip
514
+ * - 都不支持、体积太小、或压完反而更大 → 明文
515
+ */
516
+ export function encodeBody(res, buf) {
517
+ if (buf.length <= 2048) return { body: buf, encoding: '' }
518
+ const ae = acceptEncoding(res)
519
+ const wantBr = /(^|[,\s])br($|[,\s;])/.test(ae)
520
+ const wantGzip = ae.includes('gzip')
521
+ // 大响应体同时压 br + gzip 取更小的那个:br q5 在图谱这种高重复 JSON 上比 gzip 小 35%,
522
+ // 但在高熵的 JS/小 JSON 上偶尔略大 —— 取小者保证永不劣化(成本仅几毫秒)。
523
+ if (buf.length >= 16 * 1024) {
524
+ let br = null
525
+ if (wantBr) {
526
+ try { br = brotliCompressSync(buf, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5, [zlibConstants.BROTLI_PARAM_SIZE_HINT]: buf.length } }) } catch { br = null }
527
+ }
528
+ let gz = null
529
+ if (wantGzip) { try { gz = gzipSync(buf) } catch { gz = null } }
530
+ const best = [br && { body: br, encoding: 'br' }, gz && { body: gz, encoding: 'gzip' }]
531
+ .filter(Boolean)
532
+ .filter((x) => x.body.length < buf.length)
533
+ .sort((a, b) => a.body.length - b.body.length)[0]
534
+ if (best) return best
535
+ return { body: buf, encoding: '' }
536
+ }
537
+ if (wantBr) {
538
+ try {
539
+ const out = brotliCompressSync(buf, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5, [zlibConstants.BROTLI_PARAM_SIZE_HINT]: buf.length } })
540
+ if (out.length < buf.length) return { body: out, encoding: 'br' }
541
+ } catch { /* 落到 gzip */ }
542
+ }
543
+ if (wantGzip) {
544
+ const out = gzipSync(buf)
545
+ if (out.length < buf.length) return { body: out, encoding: 'gzip' }
546
+ }
547
+ return { body: buf, encoding: '' }
548
+ }
549
+
550
+ /** 读请求体(POST body),返回解析后的 JSON 对象或空。 */
551
+ export async function readBody(req) {
552
+ try {
553
+ const chunks = []
554
+ for await (const chunk of req) chunks.push(chunk)
555
+ const raw = Buffer.concat(chunks).toString('utf8')
556
+ return raw ? JSON.parse(raw) : {}
557
+ } catch { return {} }
558
+ }