memory-eternal 0.1.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/index.js ADDED
@@ -0,0 +1,565 @@
1
+ // 记忆核心(host 侧):自动沉淀 + 自动召回 + 知识库 JSON API。
2
+ //
3
+ // 职责:
4
+ // 1. 注册 `memory-eternal` 设置命名空间(enabled / autoCapture / autoRecall /
5
+ // vaultDir / dedupThreshold / captureMinChars / captureCooldownMs)。
6
+ // 2. 监听 `agent/turn-stopping`:每轮对话结束自动把「值得长期复用的内容」
7
+ // 压缩成知识卡写入本地 Markdown Vault(去重守卫:相似卡拒绝新建、改为
8
+ // 追加更新记录)。零人工干预。
9
+ // 3. 注入 systemPrompt 分段:告知 Agent 它有一块记忆核心、可随时
10
+ // memory_recall 召回历史上下文;并注册 `memory_recall` 工具。
11
+ // 4. 注册 `/memory-eternal/api/*` JSON 路由:供客户端设置页渲染统计 / 卡片 /
12
+ // 知识图谱 / 检索。
13
+ //
14
+ // 存储全部落在本地 Markdown Vault(默认 $DSH_HOME/memory-vault),不依赖
15
+ // 外部数据库;卡是普通 .md 文件,可手动编辑、可 git 管理。
16
+
17
+ import { promises as fs, readFileSync } from 'node:fs'
18
+ import path from 'node:path'
19
+ import os from 'node:os'
20
+ import { spawn } from 'node:child_process'
21
+ import { fileURLToPath } from 'node:url'
22
+ const __filename = fileURLToPath(import.meta.url)
23
+ const PACKAGE_ROOT = path.resolve(path.dirname(__filename))
24
+ // 插件版本号(供「记忆配置」页面展示)
25
+ const versionRef = (() => { try { const p = JSON.parse(readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')); return p.version } catch { return '' } })()
26
+ import z from '@deepseek-ai/schemastery'
27
+ import { defineTool } from '@deepseek-ai/dsh-tools'
28
+ import { ensureVault, search, generateDailyBrief } from './lib/vault.js'
29
+ import { summarizeTurn, extractLastTurn, sliceNewEvents, resolveRoute, captureCard, captureUpdate, pickNeighbors } from './lib/capture.js'
30
+ import { createApi, json } from './lib/api.js'
31
+
32
+ export const name = 'memory-eternal'
33
+ export const inject = ['systemPrompt', 'settings']
34
+
35
+ export const Config = z.object({
36
+ enabled: z.boolean().default(true),
37
+ autoCapture: z.boolean().default(true),
38
+ autoRecall: z.boolean().default(true),
39
+ vaultDir: z.string().default(''),
40
+ dedupThreshold: z.number().min(0).max(1).default(0.62),
41
+ captureMinChars: z.number().default(200),
42
+ captureCooldownMs: z.number().default(5 * 60 * 1000),
43
+ maxCardsPerDay: z.number().default(60),
44
+ // 成本控制(v0.6.2):让用户精控 LLM token / 蒸馏调用 / 召回注入量
45
+ // 蒸馏:true=LLM 压缩成知识卡;false=只存原文卡,零 LLM 消耗(最大省钱)
46
+ distillEnabled: z.boolean().default(true),
47
+ // 语义去重:true=把已有卡索引喂 LLM 决定「新建 vs 追加」;false=纯词法去重(省一次蒸馏前的 LLM 调用)
48
+ dedupByLLM: z.boolean().default(true),
49
+ // 蒸馏单次输出上限(token),越高越准越贵
50
+ captureMaxTokens: z.number().min(100).max(4000).default(900),
51
+ // 召回相关性阈值(minScore),越高召回越少越精越省
52
+ recallMinScore: z.number().min(0).max(50).default(2),
53
+ // 注入体积可配置(召回)
54
+ recallLimit: z.number().min(1).max(20).default(5),
55
+ recallSummaryLen: z.number().min(40).max(400).default(130),
56
+ recallIncludeBody: z.boolean().default(false),
57
+ // 多 Vault / 多 Profile:命名分库,当前激活一个
58
+ vaultProfiles: z.array(z.object({ name: z.string(), path: z.string() })).default([]),
59
+ activeVault: z.string().default(''),
60
+ // 语义召回(可选 embedding provider,默认空=零依赖 bigram + LLM 判定兜底)
61
+ recallEmbedding: z.string().default(''),
62
+ // 会话级 token 预算(字符),供 harness 触发压缩/轮换;记忆侧提供估算与阈值
63
+ sessionBudgetChars: z.number().default(80000),
64
+ // 多宿主:激活时自动把 MCP 挂载到本机已装的 Claude Code/Codex/Cursor(幂等,
65
+ // MEMORY_ETERNAL_SKIP_AUTO=1 可完全禁用)。**默认 false**——不碰外部配置,需要时显式开启。
66
+ autoMcpSetup: z.boolean().default(false),
67
+ autoWeb: z.boolean().default(true),
68
+ // web server 保活模式:
69
+ // init = DSH 激活时拉起一次(默认;最低开销,DSH 死后 web 仍活但无人看守)
70
+ // interval= DSH 进程内 setInterval 周期探活+自动拉起(额外 0 内存;DSH 死则停保活)
71
+ // manual = 完全不自动拉起;只在 `dsh-memory open` 时 ensure-alive(最保守)
72
+ autoWebMode: z.union([z.const('init'), z.const('interval'), z.const('manual')]).default('init'),
73
+ webPort: z.number().min(1).max(65535).default(7999),
74
+ webCheckIntervalMs: z.number().min(1000).max(600000).default(5000),
75
+ webMaxRestart: z.number().min(1).max(1000).default(10),
76
+ // 是否 spawn 独立 watchdog 进程(与 DSH 解耦,7×24 保活;额外 ~47 MB 常驻)
77
+ // **v0.6.0 起默认 true**——DSH 进程内 setInterval 在 DSH 退出后失效;
78
+ // 常驻 web 场景需要独立 watchdog;代价是 ~47 MB 额外常驻内存。
79
+ watchdogAutoSpawn: z.boolean().default(true),
80
+ // 回收站保留天数:软删卡超过此天数自动永久删除(默认 30)
81
+ recycleRetentionDays: z.number().min(1).max(3650).default(30),
82
+ // 自动审核配置
83
+ // auditMode: 'all'=全部要审(默认) | 'none'=全部免审直接入库
84
+ // auditExemptAgents: 免审的智能体名列表(如 codex / claude-code / 本地 DSH)
85
+ // auditExemptKinds: 免审的知识类型列表(如 tool / mistake)
86
+ // 命中任一免审条件 → 新卡直接 approved 入库,否则进 pending 待审
87
+ auditMode: z.union([z.const('all'), z.const('none')]).default('all'),
88
+ auditExemptAgents: z.array(z.string()).default([]),
89
+ auditExemptKinds: z.array(z.string()).default([]),
90
+ })
91
+
92
+ const API_PREFIX = '/memory-eternal/api'
93
+ // DSH 宿主自动沉淀卡的署名:用可读名而非 agent 会话 id,便于在智能体筛选中归组。
94
+ const DSH_AGENT = 'deepseek-harness'
95
+
96
+ export function apply(ctx, config) {
97
+ const settings = ctx.settings.register('memory-eternal', Config, { base: config ?? {} })
98
+
99
+ const vaultDir = () => {
100
+ const cfg = settings.get() ?? {}
101
+ // 多 Vault:若配了 vaultProfiles 且选中了 activeVault,则用该 profile 的目录。
102
+ const profiles = Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : []
103
+ const active = String(cfg.activeVault || '').trim()
104
+ const hit = active && profiles.find((p) => p.name === active)
105
+ if (hit && hit.path && hit.path.trim()) return path.resolve(hit.path.trim())
106
+ if (cfg.vaultDir && cfg.vaultDir.trim()) return path.resolve(cfg.vaultDir.trim())
107
+ const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
108
+ return path.join(home, 'memory-vault')
109
+ }
110
+
111
+ // 所有 profile 目录(当前激活 + 其余命名的),供跨库聚合。
112
+ const vaultRoots = () => {
113
+ const cfg = settings.get() ?? {}
114
+ const active = vaultDir()
115
+ const roots = [{ name: '', root: active }]
116
+ const seen = new Set([active])
117
+ const profiles = Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : []
118
+ for (const p of profiles) {
119
+ if (!p || !p.path || !p.path.trim()) continue
120
+ const r = path.resolve(p.path.trim())
121
+ if (seen.has(r)) continue
122
+ seen.add(r)
123
+ roots.push({ name: p.name || r, root: r })
124
+ }
125
+ return roots
126
+ }
127
+ // 把完整配置写入共享文件,使独立 web / MCP hook 捕获与 DSH 设置同步(不同步修复)。
128
+ const syncConfigFile = async () => {
129
+ try {
130
+ const cfg = settings.get() ?? {}
131
+ const { configFilePath } = await import('./lib/capture-run.js')
132
+ await (await import('node:fs')).promises.writeFile(configFilePath(process.env), JSON.stringify(cfg, null, 2), 'utf8')
133
+ } catch { /* 静默 */ }
134
+ }
135
+ syncConfigFile()
136
+ // agent/turn-stopping 是 serial 事件:不在监听器里 await LLM(会拖慢收尾),
137
+ // 同步抓取增量事件快照后,把真正的捕获调度到后台队列执行。
138
+ const pending = new Map() // sessionId -> merged events array
139
+ let captureQueue = Promise.resolve()
140
+
141
+ const scheduleCapture = (agent, events, lastSeq) => {
142
+ const cfg = settings.get() ?? {}
143
+ if (!cfg.enabled || !cfg.autoCapture) return
144
+ const sessionId = agent?.session?.id ?? agent?.id ?? 'unknown'
145
+ const newEvents = sliceNewEvents(events, lastSeq)
146
+ if (newEvents.length === 0) return
147
+ const existing = pending.get(sessionId)
148
+ if (existing) {
149
+ // 连续轮次合并:把新事件接到待处理队列尾,一次 LLM 调用处理。
150
+ pending.set(sessionId, [...existing, ...newEvents])
151
+ return
152
+ }
153
+ pending.set(sessionId, newEvents)
154
+ captureQueue = captureQueue.then(() => runCapture(agent, pending.get(sessionId) ?? newEvents))
155
+ }
156
+
157
+ // 自动审核:根据配置决定新卡 status(免审→approved,否则 pending)
158
+ const resolveAuditStatus = (cfg, kind, submittedBy) => {
159
+ if (cfg.auditMode === 'none') return 'approved'
160
+ const agents = Array.isArray(cfg.auditExemptAgents) ? cfg.auditExemptAgents : []
161
+ const kinds = Array.isArray(cfg.auditExemptKinds) ? cfg.auditExemptKinds : []
162
+ if (agents.includes('__all__') || agents.includes(submittedBy)) return 'approved'
163
+ if (kinds.includes('__all__') || kinds.includes(kind)) return 'approved'
164
+ return 'pending'
165
+ }
166
+
167
+ const runCapture = async (agent, events) => {
168
+ try {
169
+ const cfg = settings.get() ?? {}
170
+ if (!cfg.enabled || !cfg.autoCapture) return
171
+ const llm = ctx.get('llm')
172
+ const text = extractLastTurn(events)
173
+ if (text.length < (cfg.captureMinChars ?? 200)) return
174
+ // 日配额:防止一次大扫荡烧光 token。
175
+ if (!(await underDailyQuota(cfg.maxCardsPerDay))) return
176
+ // 成本控制:distillEnabled=false 时不调 LLM,直接存原文卡(零蒸馏成本)
177
+ const source = DSH_AGENT
178
+ if (cfg.distillEnabled === false || !llm) {
179
+ await captureCard(vaultDir(), {
180
+ kind: 'content',
181
+ title: text.replace(/\s+/g, ' ').slice(0, 40) || '未命名记录',
182
+ tags: ['raw'],
183
+ body: text,
184
+ source,
185
+ status: resolveAuditStatus(cfg, 'content', source || 'unknown'),
186
+ submittedBy: source || 'unknown',
187
+ severity: 'info',
188
+ reason: 'AI 自动沉淀(原文卡)',
189
+ }, { threshold: cfg.dedupThreshold })
190
+ return
191
+ }
192
+ const route = await resolveRoute(llm)
193
+ if (!route) return
194
+ // 语义去重近邻:把已有卡片索引喂给模型,让模型决定新建 vs 追加。
195
+ // 成本控制:dedupByLLM=false 时跳过喂 LLM 的近邻采样(纯词法去重兜底)。
196
+ const draft = { title: '', body: text.slice(0, 400) }
197
+ const neighbors = cfg.dedupByLLM === false ? [] : await pickNeighbors(vaultDir(), draft, 8)
198
+ const result = await summarizeTurn(llm, route, text, { signal: AbortSignal.timeout(45000), existing: neighbors, maxTokens: cfg.captureMaxTokens ?? 900 })
199
+ if (!result || result.save !== true) return
200
+ if (result.append_to) {
201
+ // 模型判定属于已有卡 → 追加更新记录,不新建(boujoy 语义)。
202
+ await captureUpdate(vaultDir(), result.append_to, result.update, { threshold: cfg.dedupThreshold })
203
+ return
204
+ }
205
+ const card = {
206
+ kind: result.kind,
207
+ title: result.title,
208
+ tags: result.tags,
209
+ body: result.body,
210
+ source: DSH_AGENT,
211
+ status: resolveAuditStatus(cfg, result.kind, DSH_AGENT),
212
+ submittedBy: DSH_AGENT,
213
+ severity: 'info',
214
+ reason: 'AI 自动沉淀(蒸馏卡)',
215
+ }
216
+ const out = await captureCard(vaultDir(), card, { threshold: cfg.dedupThreshold })
217
+ if (!out.ok && out.duplicate) {
218
+ // 词法兜底:高度相似 → 追加更新记录而不是再建一张重复卡。
219
+ await captureUpdate(vaultDir(), out.duplicate.path, `${result.title}:${result.body.slice(0, 400)}`, {
220
+ threshold: cfg.dedupThreshold,
221
+ })
222
+ }
223
+ } catch (error) {
224
+ console.error('[memory-eternal] capture failed:', error)
225
+ } finally {
226
+ const sessionId = agent?.session?.id ?? agent?.id ?? 'unknown'
227
+ pending.delete(sessionId)
228
+ }
229
+ }
230
+
231
+ const lastDayStamps = []
232
+ const underDailyQuota = async (max) => {
233
+ const now = Date.now()
234
+ const dayStart = now - 86400000
235
+ while (lastDayStamps.length && lastDayStamps[0] < dayStart) lastDayStamps.shift()
236
+ if (lastDayStamps.length >= (max ?? 60)) return false
237
+ lastDayStamps.push(now)
238
+ return true
239
+ }
240
+
241
+ const lastSeqs = new Map() // sessionId -> last processed seq
242
+ ctx.on('agent/turn-stopping', ({ agent }) => {
243
+ const events = agent?.session?.events
244
+ if (!Array.isArray(events)) return
245
+ const sessionId = agent?.session?.id ?? agent?.id ?? 'unknown'
246
+ const lastSeq = lastSeqs.get(sessionId) ?? 0
247
+ scheduleCapture(agent, events, lastSeq)
248
+ lastSeqs.set(sessionId, events.length ? events[events.length - 1].seq : lastSeq)
249
+ })
250
+
251
+ // -- 2. 自动召回:systemPrompt 分段 + memory_recall 工具 -----------------
252
+ ctx.effect(() => {
253
+ let disposeSection = null
254
+ const refresh = (cfg) => {
255
+ if (disposeSection) {
256
+ const dispose = disposeSection
257
+ disposeSection = null
258
+ dispose()
259
+ }
260
+ if (!cfg || cfg.enabled === false || cfg.autoRecall !== true) return
261
+ const text = [
262
+ '你拥有一个本地「记忆核心」(Markdown 知识库,位于 ' + vaultDir() + ')。',
263
+ '规则:',
264
+ '1. 每轮对话结束后,值得长期复用的内容会被自动沉淀成知识卡,你无需询问用户、也无需手动保存。',
265
+ '2. 当任务需要项目背景、历史决策、之前讨论过的方案或领域知识时,先调用 memory_recall 检索相关卡片,再作答。',
266
+ '3. 若检索结果为空,就诚实说明当前记忆库没有相关内容,不要编造。',
267
+ '4. 知识卡是普通 Markdown 文件,你可以用文件工具直接读写它。',
268
+ ].join('\n')
269
+ disposeSection = ctx.systemPrompt.section({
270
+ name: 'memory-eternal: auto-recall',
271
+ order: 600,
272
+ text,
273
+ })
274
+ }
275
+ refresh(settings.get())
276
+ const unwatch = settings.watch(refresh)
277
+ return () => {
278
+ if (typeof unwatch === 'function') unwatch()
279
+ if (disposeSection) disposeSection()
280
+ }
281
+ }, 'memory-eternal: recall section')
282
+
283
+ const tools = ctx.get('tools')
284
+ if (tools !== undefined) {
285
+ tools.register(defineTool({
286
+ name: 'memory_recall',
287
+ description:
288
+ '从本地记忆核心(Markdown 知识库)检索相关知识卡。需要项目背景、历史决策、之前讨论过的方案、' +
289
+ '或领域知识时调用;返回最相关的卡片摘要。用 query 描述要找的内容,支持中文整词与字符片段检索。',
290
+ parameters: {
291
+ query: { type: 'string', required: true, description: '检索关键词或自然语言描述,如「数据库选型」「用户偏好」' },
292
+ limit: { type: 'number', description: '返回卡片数上限,默认 5' },
293
+ },
294
+ output: {
295
+ schema: { type: 'string' },
296
+ render(_a, v) { return [{ type: 'text', text: v }] },
297
+ },
298
+ timeoutMs: 20000,
299
+ async execute(args) {
300
+ const cfg = settings.get() ?? {}
301
+ if (!cfg.enabled) return '(记忆核心已禁用)'
302
+ const query = String(args.query || '').trim()
303
+ if (!query) return '(未提供检索词)'
304
+ const cfg2 = settings.get() ?? {}
305
+ const defLimit = Number(cfg2.recallLimit) || 5
306
+ const defLen = Number(cfg2.recallSummaryLen) || 130
307
+ const includeBody = cfg2.recallIncludeBody === true
308
+ const limit = Math.min(Math.max(Number(args.limit) || defLimit, 1), 20)
309
+ const hits = await search(vaultDir(), query, { limit, minScore: 2 })
310
+ if (hits.length === 0) return `记忆库中没有与「${query}」相关的内容。`
311
+ const lines = hits.map((h, i) => {
312
+ const tags = h.tags.length ? ` [${h.tags.join(', ')}]` : ''
313
+ const snippet = String(includeBody ? h.summary : h.summary || '')
314
+ .replace(/\s+/g, ' ').trim().slice(0, defLen)
315
+ const body = includeBody ? `\n${String(h.excerpt || '')}` : ''
316
+ return `### ${i + 1}. ${h.title}${tags}\n路径:${h.path}\n${snippet}${body}`
317
+ })
318
+ return `从记忆核心检索到 ${hits.length} 条相关卡片:\n\n${lines.join('\n\n')}`
319
+ },
320
+ }))
321
+ }
322
+
323
+ // 每日回顾:每 30 分钟检查一次,跨天就生成当日简报文件(幂等,凌晨/首日各一次)。
324
+ let lastBriefDate = ''
325
+ const briefTimer = setInterval(() => {
326
+ const d = new Date()
327
+ const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
328
+ if (lastBriefDate === date) return
329
+ lastBriefDate = date
330
+ generateDailyBrief(vaultDir()).catch(() => {})
331
+ }, 30 * 60 * 1000)
332
+ ctx.effect(() => () => clearInterval(briefTimer), 'memory-eternal: daily brief timer')
333
+
334
+ // 回收站清理:每 30 分钟永久删除超过保留期(默认 30 天)的软删卡。
335
+ const purgeTimer = setInterval(() => {
336
+ const cfg = settings.get() ?? {}
337
+ const days = Number(cfg.recycleRetentionDays) || 30
338
+ import('./lib/vault.js').then((v) => v.purgeExpired(vaultDir(), days)).catch(() => {})
339
+ }, 30 * 60 * 1000)
340
+ ctx.effect(() => () => clearInterval(purgeTimer), 'memory-eternal: recycle purge timer')
341
+
342
+ // -- 3. 知识库 JSON API(客户端设置页数据源) ----------------------------
343
+ // 多宿主状态:web server 常驻地址(ensureWebServer 的结果,client 壳经
344
+ // /web-info 读取后用 iframe 渲染 web 端 UI——DSH 渲染也走 web,单一真源)。
345
+ let webInfo = { url: 'http://127.0.0.1:7999', port: 7999, alive: false }
346
+ const refreshWebInfo = (info) => { if (info && info.url) webInfo = { ...info, alive: true } }
347
+
348
+ const webServer = ctx.get('webServer')
349
+ if (webServer !== undefined) {
350
+ const handleApi = createApi({
351
+ vaultDir, vaultRoots, getSettings: settings.get,
352
+ getDshInfo: () => ({
353
+ name: 'deepseek-harness',
354
+ label: 'DeepSeek Harness(当前宿主)',
355
+ installed: true,
356
+ memoryRecallTool: !!ctx.get('tools'),
357
+ autoCapture: (settings.get() ?? {}).autoCapture !== false,
358
+ autoRecall: (settings.get() ?? {}).autoRecall !== false,
359
+ vaultDir: vaultDir(),
360
+ version: versionRef,
361
+ }),
362
+ })
363
+ webServer.register({
364
+ kind: 'prefix',
365
+ path: API_PREFIX,
366
+ handler: async (req, res) => {
367
+ try {
368
+ const pathname = new URL(req.url, 'http://localhost').pathname
369
+ if (pathname === API_PREFIX + '/web-info') {
370
+ return json(res, 200, { ok: true, ...webInfo })
371
+ }
372
+ if (pathname === API_PREFIX + '/setup-run') {
373
+ // 「补全 MCP」:真正执行 runSetup(写外部 agent 配置),返回每项结果,成功/失败可见
374
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
375
+ try {
376
+ const { runSetup } = await import('./lib/setup.js')
377
+ const out = await runSetup({ log: () => {}, enabled: true })
378
+ json(res, 200, { ok: true, results: out.results })
379
+ } catch (e) {
380
+ json(res, 500, { ok: false, error: String(e?.message || e) })
381
+ }
382
+ return
383
+ }
384
+ if (pathname === API_PREFIX + '/mcp/action') {
385
+ // 单智能体安装/卸载 MCP:POST {agent, action}
386
+ if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需 POST' })
387
+ try {
388
+ let raw = ''
389
+ for await (const chunk of req) raw += chunk
390
+ const body = JSON.parse(raw || '{}')
391
+ const agent = String(body.agent || '')
392
+ const action = String(body.action || '')
393
+ const { mcpAgentAction } = await import('./lib/setup.js')
394
+ const out = await mcpAgentAction(agent, action, { log: () => {} })
395
+ json(res, 200, out)
396
+ } catch (e) {
397
+ json(res, 500, { ok: false, error: String(e?.message || e) })
398
+ }
399
+ return
400
+ }
401
+ if (pathname === API_PREFIX + '/config') {
402
+ const method = req.method || 'GET'
403
+ if (method === 'GET') {
404
+ const cfg = settings.get() ?? {}
405
+ // 只暴露可安全展示/回填的字段
406
+ const safe = {
407
+ autoCapture: cfg.autoCapture, autoRecall: cfg.autoRecall, recallLimit: cfg.recallLimit, recallSummaryLen: cfg.recallSummaryLen, recallIncludeBody: cfg.recallIncludeBody,
408
+ captureMinChars: cfg.captureMinChars, captureCooldownMs: cfg.captureCooldownMs, dedupThreshold: cfg.dedupThreshold, maxCardsPerDay: cfg.maxCardsPerDay,
409
+ distillEnabled: cfg.distillEnabled, dedupByLLM: cfg.dedupByLLM, captureMaxTokens: cfg.captureMaxTokens, recallMinScore: cfg.recallMinScore,
410
+ autoWeb: cfg.autoWeb, autoWebMode: cfg.autoWebMode, webPort: cfg.webPort, webCheckIntervalMs: cfg.webCheckIntervalMs, webMaxRestart: cfg.webMaxRestart, watchdogAutoSpawn: cfg.watchdogAutoSpawn, autoMcpSetup: cfg.autoMcpSetup,
411
+ auditMode: cfg.auditMode ?? 'all', auditExemptAgents: cfg.auditExemptAgents || [], auditExemptKinds: cfg.auditExemptKinds || [], recycleRetentionDays: cfg.recycleRetentionDays ?? 30,
412
+ }
413
+ const descriptor = (ctx.get('settings') ?? {}).describe?.({ redactSecrets: true }) ?? []
414
+ const me = descriptor.find((d) => d.ns === 'memory-eternal')
415
+ const dshInfo = {
416
+ name: 'deepseek-harness',
417
+ label: 'DeepSeek Harness(当前宿主)',
418
+ installed: true,
419
+ memoryRecallTool: !!ctx.get('tools'),
420
+ autoCapture: cfg.autoCapture !== false,
421
+ autoRecall: cfg.autoRecall !== false,
422
+ vaultDir: vaultDir(),
423
+ version: versionRef,
424
+ }
425
+ return json(res, 200, { ok: true, config: safe, revision: me?.revision ?? 0, writable: true, readonly: false, schema: me?.schema ?? null, dsh: dshInfo, version: versionRef })
426
+ }
427
+ if (method === 'POST') {
428
+ let raw = ''
429
+ for await (const chunk of req) raw += chunk
430
+ let body = {}
431
+ try { body = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
432
+ const patch = body.patch ?? {}
433
+ const expectedRevision = Number.isInteger(body.expectedRevision) ? body.expectedRevision : undefined
434
+ // 仅允许写入 Config 中声明过的键(白名单,防注入)。schemastery 用 .dict 存 object schema 字段表。
435
+ const allowed = new Set(Object.keys(Config.dict || {}))
436
+ const clean = {}
437
+ for (const k of Object.keys(patch)) { if (allowed.has(k)) clean[k] = patch[k] }
438
+ if (Object.keys(clean).length === 0) return json(res, 400, { ok: false, error: '无可写入字段' })
439
+ if (typeof settings.update === 'function') {
440
+ try {
441
+ await settings.update(clean)
442
+ // 把完整配置写入共享文件,让独立 web / MCP hook 与 DSH 设置同步(不同步修复)
443
+ syncConfigFile()
444
+ return json(res, 200, { ok: true, applied: Object.keys(clean), note: '已保存。autoWebMode/watchdogAutoSpawn 等需重启 DSH 生效' })
445
+ } catch (e) {
446
+ if (e && e.code === 'SETTINGS_CONFLICT') return json(res, 409, { ok: false, error: '配置已被外部修改,请刷新后重试(revision conflict)' })
447
+ return json(res, 500, { ok: false, error: String(e?.message || e) })
448
+ }
449
+ }
450
+ return json(res, 501, { ok: false, error: '当前环境不支持写配置' })
451
+ }
452
+ return json(res, 405, { ok: false, error: 'method not allowed' })
453
+ }
454
+ // DSH host 同源配置页 UI:/memory-eternal/ui/config + /memory-eternal/ui/app.js
455
+ // 让 DSH iframe 的「配置」页在 host 同源加载 → /config API 同源可读写(修复独立 web 7979 /config 404 导致的「一直加载中」)
456
+ if (pathname === API_PREFIX + '/ui/config' || pathname === API_PREFIX + '/ui/app.js') {
457
+ const { readFile } = fs
458
+ const webRoot = path.join(PACKAGE_ROOT, 'web')
459
+ if (pathname.endsWith('app.js')) {
460
+ const buf = await readFile(path.join(webRoot, 'app.js'))
461
+ res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store' })
462
+ return res.end(buf)
463
+ }
464
+ const buf = await readFile(path.join(webRoot, 'index.html'))
465
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' })
466
+ return res.end(buf)
467
+ }
468
+ await handleApi(req, res)
469
+ } catch (error) {
470
+ json(res, 500, { ok: false, error: String(error?.message || error) })
471
+ }
472
+ },
473
+ })
474
+ }
475
+
476
+ // -- 4. 多宿主常驻:MCP 挂载 + web server 保活(按设置项分层) -----------------
477
+ // 全部后台异步、静默失败:插件激活不能被外部环境问题卡住。
478
+ let intervalTimer = null
479
+ let watchdogProc = null
480
+ ctx.effect(() => {
481
+ const cfg0 = settings.get() ?? {}
482
+ if (cfg0.enabled === false) return () => {}
483
+
484
+ // (1) MCP 自动挂载:默认关闭;只有用户显式开启才动外部配置。
485
+ if (cfg0.autoMcpSetup === true && process.env.MEMORY_ETERNAL_SKIP_AUTO !== '1') {
486
+ import('./lib/setup.js')
487
+ .then((m) => m.runSetup({ log: () => {} }))
488
+ .catch(() => {})
489
+ }
490
+
491
+ // (2) web server:根据 autoWeb + autoWebMode 决策
492
+ const mode = cfg0.autoWebMode || 'init'
493
+ const port = Number(cfg0.webPort) || 7999
494
+ const ensureOpts = { port, vaultRoot: vaultDir() }
495
+ const ensureWeb = () => import('./lib/web.js')
496
+ .then((m) => m.ensureWebServer(ensureOpts))
497
+ .then((info) => { refreshWebInfo(info); return info })
498
+ .catch((error) => console.error('[memory-eternal] web server failed:', error?.message || error))
499
+
500
+ if (cfg0.autoWeb === true) {
501
+ if (mode === 'manual') {
502
+ // manual:不自动拉起;只在 dsh-memory open / WebFrame 触发 ensure
503
+ import('./lib/web.js')
504
+ .then((m) => m.probeWebServer(port))
505
+ .then((alive) => { if (alive) refreshWebInfo({ url: `http://127.0.0.1:${port}`, port, spawned: false }) })
506
+ .catch(() => {})
507
+ } else if (mode === 'init') {
508
+ // init:拉起一次(首次);之后不管(用户已设了,那看门狗都不开)
509
+ ensureWeb()
510
+ } else if (mode === 'interval') {
511
+ // interval:DSH 进程内 setInterval 周期保活
512
+ const intervalMs = Number(cfg0.webCheckIntervalMs) || 5000
513
+ const maxRestart = Number(cfg0.webMaxRestart) || 10
514
+ let restartCount = 0
515
+ const tick = async () => {
516
+ if (restartCount >= maxRestart) {
517
+ console.error(`[memory-eternal] web 已连续重启 ${maxRestart} 次,停止保活`)
518
+ if (intervalTimer) { clearInterval(intervalTimer); intervalTimer = null }
519
+ return
520
+ }
521
+ const alive = await import('./lib/web.js').then((m) => m.probeWebServer(port)).catch(() => null)
522
+ if (!alive) {
523
+ restartCount++
524
+ console.error(`[memory-eternal] web 离线 → 第 ${restartCount}/${maxRestart} 次拉起`)
525
+ await ensureWeb()
526
+ } else {
527
+ // 探到活则重置计数
528
+ restartCount = 0
529
+ }
530
+ }
531
+ // 立即跑一次(首启 + 间隔循环)
532
+ ensureWeb()
533
+ intervalTimer = setInterval(tick, intervalMs)
534
+ }
535
+ }
536
+
537
+ // (3) 看门狗独立进程:默认不 spawn;开启时启动一个与 DSH 解耦的 node watchdog。
538
+ if (cfg0.watchdogAutoSpawn === true && cfg0.autoWeb !== false) {
539
+ import('./lib/watchdog.js')
540
+ .then((m) => {
541
+ const port = Number(cfg0.webPort) || 7999
542
+ const wd = spawn(
543
+ process.execPath,
544
+ [path.join(PACKAGE_ROOT, 'lib', 'watchdog.js'), '--port', String(port), '--interval', String(cfg0.webCheckIntervalMs || 5000), '--max-restart', String(cfg0.webMaxRestart || 10)],
545
+ { detached: true, stdio: 'ignore', env: { ...process.env, MEMORY_VAULT_DIR: vaultDir() }, windowsHide: true },
546
+ )
547
+ wd.unref()
548
+ watchdogProc = wd
549
+ console.error(`[memory-eternal] watchdog spawned pid=${wd.pid} port=${port}`)
550
+ })
551
+ .catch(() => {})
552
+ }
553
+
554
+ return () => {
555
+ if (intervalTimer) { clearInterval(intervalTimer); intervalTimer = null }
556
+ // 注意:watchdog 进程是独立的,故意不杀(7×24 用法)—— 配置改变由下次重启 DSH 时重新 spawn 替换
557
+ }
558
+ }, 'memory-eternal: multi-host ensure')
559
+
560
+ // 首次激活时确保 vault 目录存在。
561
+ ctx.effect(() => {
562
+ const root = vaultDir()
563
+ ensureVault(root).catch((error) => console.error('[memory-eternal] ensureVault failed:', error))
564
+ }, 'memory-eternal: ensure vault')
565
+ }