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/.claude-plugin/plugin.json +34 -0
- package/.codex-plugin/plugin.json +29 -0
- package/.cursor-plugin/plugin.json +28 -0
- package/.mcp.json +9 -0
- package/LICENSE +21 -0
- package/README.en.md +224 -0
- package/README.md +236 -0
- package/bin/dsh-memory.mjs +183 -0
- package/cordis.patch.yml +15 -0
- package/hooks/capture.mjs +77 -0
- package/hooks/claude-session.js +56 -0
- package/hooks/hooks.claude.json +12 -0
- package/hooks/hooks.codex.json +15 -0
- package/hooks/hooks.cursor.json +11 -0
- package/index.js +565 -0
- package/lib/api.js +321 -0
- package/lib/capture-run.js +98 -0
- package/lib/capture.js +267 -0
- package/lib/client.js +186 -0
- package/lib/llm-openai.js +84 -0
- package/lib/mcp.js +166 -0
- package/lib/setup.js +470 -0
- package/lib/sweep.js +56 -0
- package/lib/vault.js +802 -0
- package/lib/watchdog.js +201 -0
- package/lib/web.js +149 -0
- package/package.json +101 -0
- package/skills/memory-recall/SKILL.md +14 -0
- package/web/app.js +230 -0
- package/web/index.html +46 -0
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
|
+
}
|