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/lib/mcp.js ADDED
@@ -0,0 +1,166 @@
1
+ // 记忆核心 · MCP server(零依赖 stdio JSON-RPC 2.0)。
2
+ //
3
+ // 让 Claude Code / Codex CLI / Cursor / 任何 MCP 客户端共享同一个记忆库。
4
+ // 传输:MCP stdio —— stdin/stdout 每行一条 JSON-RPC 消息;日志一律走 stderr
5
+ // (stdout 是协议通道,被 console.log 污染会破坏握手)。
6
+ //
7
+ // 支持的协议版本:2025-06-18 / 2025-03-26 / 2024-11-05(协商取请求版本)。
8
+ // 工具:memory_recall / memory_capture / memory_stats。
9
+ //
10
+ // 启动:node bin/dsh-memory.mjs mcp (或 node lib/mcp.js)
11
+
12
+ import readline from 'node:readline'
13
+ import { search, overview, stats, ensureVault } from './vault.js'
14
+ import { runStandaloneCapture, defaultVaultDir } from './capture-run.js'
15
+
16
+ const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05']
17
+ const DEFAULT_PROTOCOL_VERSION = '2024-11-05'
18
+
19
+ export const MCP_TOOLS = [
20
+ {
21
+ name: 'memory_recall',
22
+ description:
23
+ '从本地记忆核心(Markdown 知识库)检索相关知识卡。需要项目背景、历史决策、之前讨论过的方案、' +
24
+ '或领域知识时调用;返回最相关的卡片摘要。用 query 描述要找的内容,支持中文整词与字符片段检索。',
25
+ inputSchema: {
26
+ type: 'object',
27
+ properties: {
28
+ query: { type: 'string', description: '检索关键词或自然语言描述,如「数据库选型」「用户偏好」' },
29
+ limit: { type: 'number', description: '返回卡片数上限,默认 5' },
30
+ },
31
+ required: ['query'],
32
+ },
33
+ },
34
+ {
35
+ name: 'memory_capture',
36
+ description:
37
+ '把一段值得长期复用的对话/知识沉淀进记忆库。配置了 MEMORY_LLM_* 环境变量时会用 LLM 蒸馏压缩成知识卡;' +
38
+ '未配置时降级为原文卡。通常由会话结束 hook 自动调用,手动调用用于显式保存重要结论。',
39
+ inputSchema: {
40
+ type: 'object',
41
+ properties: {
42
+ text: { type: 'string', description: '要沉淀的对话文本或知识内容(建议 >120 字符)' },
43
+ source: { type: 'string', description: '来源标记,如 session:xxx / claude-code / codex' },
44
+ },
45
+ required: ['text'],
46
+ },
47
+ },
48
+ {
49
+ name: 'memory_stats',
50
+ description: '查看记忆库概览:总卡数、分类分布、最近新增、vault 目录路径。',
51
+ inputSchema: { type: 'object', properties: {} },
52
+ },
53
+ ]
54
+
55
+ function toolResult(text, isError = false) {
56
+ return { content: [{ type: 'text', text: String(text) }], ...(isError ? { isError: true } : {}) }
57
+ }
58
+
59
+ async function callTool(name, args, vaultRoot, meta = {}) {
60
+ switch (name) {
61
+ case 'memory_recall': {
62
+ const query = String(args?.query || '').trim()
63
+ if (!query) return toolResult('(未提供检索词)')
64
+ const limit = Math.min(Math.max(Number(args?.limit) || 5, 1), 20)
65
+ const hits = await search(vaultRoot, query, { limit, minScore: 2 })
66
+ if (hits.length === 0) return toolResult(`记忆库中没有与「${query}」相关的内容。`)
67
+ const lines = hits.map((h, i) => {
68
+ const tags = h.tags.length ? ` [${h.tags.join(', ')}]` : ''
69
+ const snippet = String(h.summary || '').replace(/\s+/g, ' ').trim().slice(0, 130)
70
+ return `### ${i + 1}. ${h.title}${tags}\n路径:${h.path}\n${snippet}`
71
+ })
72
+ return toolResult(`从记忆核心检索到 ${hits.length} 条相关卡片:\n\n${lines.join('\n\n')}`)
73
+ }
74
+ case 'memory_capture': {
75
+ const text = String(args?.text || '')
76
+ const out = await runStandaloneCapture(vaultRoot, text, {
77
+ source: String(args?.source || meta.submittedBy || 'mcp'),
78
+ })
79
+ if (!out.ok && out.action === 'failed') return toolResult(`沉淀失败:${out.reason}`, true)
80
+ return toolResult(JSON.stringify(out))
81
+ }
82
+ case 'memory_stats': {
83
+ await ensureVault(vaultRoot)
84
+ const ov = await overview(vaultRoot)
85
+ const s = await stats(vaultRoot)
86
+ return toolResult(JSON.stringify({ vaultDir: vaultRoot, total: ov.total, byKind: ov.byKind, tags: s.tags, week: s.week }, null, 2))
87
+ }
88
+ default:
89
+ throw new Error(`未知工具:${name}`)
90
+ }
91
+ }
92
+
93
+ export function startMcpServer({ vaultRoot = defaultVaultDir() } = {}) {
94
+ const rl = readline.createInterface({ input: process.stdin })
95
+ const write = (msg) => process.stdout.write(JSON.stringify(msg) + '\n')
96
+ let pendingCalls = 0
97
+ let closing = false
98
+ // MCP 客户端信息:initialize 时记录(agent 名),供 memory_capture 署名用
99
+ let clientInfo = {}
100
+ const maybeExit = () => { if (closing && pendingCalls === 0) process.exit(0) }
101
+
102
+ rl.on('line', (line) => {
103
+ const raw = line.trim()
104
+ if (!raw) return
105
+ let msg
106
+ try {
107
+ msg = JSON.parse(raw)
108
+ } catch {
109
+ write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })
110
+ return
111
+ }
112
+ const { id, method, params } = msg
113
+ // notification(无 id)不回包
114
+ if (id === undefined || id === null) {
115
+ if (method === 'notifications/initialized' || method === 'notifications/cancelled') return
116
+ return
117
+ }
118
+ try {
119
+ if (method === 'initialize') {
120
+ const requested = String(params?.protocolVersion || '')
121
+ const negotiated = SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : DEFAULT_PROTOCOL_VERSION
122
+ clientInfo = params?.clientInfo || {}
123
+ write({
124
+ jsonrpc: '2.0',
125
+ id,
126
+ result: {
127
+ protocolVersion: negotiated,
128
+ capabilities: { tools: {} },
129
+ serverInfo: { name: 'memory-eternal', version: '1.0.0' },
130
+ },
131
+ })
132
+ return
133
+ }
134
+ if (method === 'ping') {
135
+ write({ jsonrpc: '2.0', id, result: {} })
136
+ return
137
+ }
138
+ if (method === 'tools/list') {
139
+ write({ jsonrpc: '2.0', id, result: { tools: MCP_TOOLS } })
140
+ return
141
+ }
142
+ if (method === 'tools/call') {
143
+ const name = params?.name
144
+ const args = params?.arguments ?? {}
145
+ const sub = clientInfo?.name ? `${clientInfo.name}${clientInfo.version ? '@' + clientInfo.version : ''}` : 'mcp'
146
+ pendingCalls++
147
+ callTool(name, args, vaultRoot, { submittedBy: sub })
148
+ .then((result) => write({ jsonrpc: '2.0', id, result }))
149
+ .catch((error) => write({ jsonrpc: '2.0', id, result: toolResult(String(error?.message || error), true) }))
150
+ .finally(() => { pendingCalls--; maybeExit() })
151
+ return
152
+ }
153
+ write({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } })
154
+ } catch (error) {
155
+ write({ jsonrpc: '2.0', id, error: { code: -32603, message: String(error?.message || error) } })
156
+ }
157
+ })
158
+
159
+ rl.on('close', () => { closing = true; maybeExit() })
160
+ process.stderr.write(`[memory-eternal] MCP server ready (vault: ${vaultRoot})\n`)
161
+ }
162
+
163
+ // 作为脚本直接运行(node lib/mcp.js / node bin/dsh-memory.mjs mcp 的转发目标)时自动启动。
164
+ if (process.argv[1] && /[\\/]mcp\.js$/.test(process.argv[1])) {
165
+ startMcpServer()
166
+ }
package/lib/setup.js ADDED
@@ -0,0 +1,470 @@
1
+ // 记忆核心 · 一键自动挂载器。
2
+ //
3
+ // 安装/激活时自动检测本机已装的 MCP 宿主(Claude Code / Codex CLI / Cursor),
4
+ // 幂等写入各自 MCP 配置,指向本包 bin/dsh-memory.mjs —— 实现「装完即用」。
5
+ //
6
+ // 安全约定:
7
+ // - 幂等:目标配置已存在且内容一致 → 跳过;不一致 → 先备份(.memory-eternal-bak-<ts>)再覆盖
8
+ // - 透明:每一步动作输出到 stdout
9
+ // - 逃生:环境变量 MEMORY_ETERNAL_SKIP_AUTO=1 或 opts.enabled=false 时完全不动外部文件
10
+ // - 只碰自己的键(mcpServers.memory / hooks 里 id 标记的条目),不改其他内容
11
+
12
+ import { promises as fs } from 'node:fs'
13
+ import path from 'node:path'
14
+ import os from 'node:os'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
18
+ export const PACKAGE_ROOT = path.join(__dirname, '..')
19
+ export const BIN_PATH = path.join(PACKAGE_ROOT, 'bin', 'dsh-memory.mjs')
20
+
21
+ const home = os.homedir()
22
+
23
+ async function exists(p) {
24
+ try { await fs.access(p); return true } catch { return false }
25
+ }
26
+
27
+ async function backupOnce(file) {
28
+ const bak = `${file}.memory-eternal-bak-${Date.now()}`
29
+ await fs.copyFile(file, bak)
30
+ return bak
31
+ }
32
+
33
+ export function mcpCommand(nodePath = process.execPath) {
34
+ return { command: nodePath, args: [BIN_PATH, 'mcp'] }
35
+ }
36
+
37
+ function sameMcpEntry(existing, want) {
38
+ if (!existing || typeof existing !== 'object') return false
39
+ if (existing.type === 'stdio' || existing.type === undefined) {
40
+ return existing.command === want.command
41
+ && JSON.stringify(existing.args ?? []) === JSON.stringify(want.args)
42
+ }
43
+ return false
44
+ }
45
+
46
+ // 是否已写入我们(capture.mjs)的会话结束 hook。
47
+ // marker 用于区分宿主:codex 走 hooks.json 的 Stop;cursor 走 hooks.json 的 stop/sessionEnd。
48
+ async function hookState(hooksJson, agent, cursorStyle = false) {
49
+ try {
50
+ const cfg = JSON.parse(await fs.readFile(hooksJson, 'utf8'))
51
+ const hooks = cfg.hooks || {}
52
+ // 遍历所有事件下命令里是否含 capture.mjs
53
+ const all = Object.values(hooks)
54
+ for (const arr of all) {
55
+ if (!Array.isArray(arr)) continue
56
+ for (const h of arr) {
57
+ const cmd = typeof h === 'object' ? (h.command || h.hooks?.map((x) => x.command).join(' ')) : String(h)
58
+ if (cmd && cmd.includes('capture.mjs')) return 'configured'
59
+ }
60
+ }
61
+ return 'missing'
62
+ } catch {
63
+ return 'missing'
64
+ }
65
+ }
66
+
67
+ // -- Claude Code:~/.claude.json 的 mcpServers + ~/.claude/settings.json 的 hooks --
68
+
69
+ export async function setupClaude({ nodePath, withHooks = true, dryRun = false, log = () => {} } = {}) {
70
+ const results = []
71
+ const claudeJson = path.join(home, '.claude.json')
72
+ if (await exists(claudeJson)) {
73
+ const want = mcpCommand(nodePath)
74
+ let cfg
75
+ try { cfg = JSON.parse(await fs.readFile(claudeJson, 'utf8')) } catch { cfg = null }
76
+ if (cfg && typeof cfg === 'object') {
77
+ cfg.mcpServers = cfg.mcpServers ?? {}
78
+ if (sameMcpEntry(cfg.mcpServers.memory, want)) {
79
+ log('✓ Claude Code MCP 已是最新,跳过')
80
+ } else if (!dryRun) {
81
+ if (cfg.mcpServers.memory) await backupOnce(claudeJson)
82
+ cfg.mcpServers.memory = { type: 'stdio', ...want }
83
+ await fs.writeFile(claudeJson, JSON.stringify(cfg, null, 2), 'utf8')
84
+ log('✓ Claude Code MCP 已写入 ~/.claude.json')
85
+ } else {
86
+ log('[dry-run] 将写入 Claude Code MCP ~/.claude.json')
87
+ }
88
+ results.push({ agent: 'claude-code', ok: true })
89
+ }
90
+ } else {
91
+ log('✗ 未检测到 Claude Code(~/.claude.json 不存在)')
92
+ results.push({ agent: 'claude-code', ok: false, reason: 'not installed' })
93
+ }
94
+
95
+ if (withHooks) results.push(await setupClaudeHooks({ nodePath, dryRun, log }))
96
+ return results
97
+ }
98
+
99
+ async function setupClaudeHooks({ nodePath, dryRun, log }) {
100
+ const hookScript = path.join(PACKAGE_ROOT, 'hooks', 'capture.mjs')
101
+ if (!(await exists(hookScript))) {
102
+ log('✗ hooks/capture.mjs 缺失,跳过 hooks')
103
+ return { agent: 'claude-hooks', ok: false, reason: 'hook script missing' }
104
+ }
105
+ const settingsJson = path.join(home, '.claude', 'settings.json')
106
+ let cfg = {}
107
+ if (await exists(settingsJson)) {
108
+ try { cfg = JSON.parse(await fs.readFile(settingsJson, 'utf8')) } catch { cfg = {} }
109
+ }
110
+ const want = {
111
+ matcher: '',
112
+ hooks: [{ type: 'command', command: `${JSON.stringify(nodePath)} ${JSON.stringify(hookScript)} claude` }],
113
+ }
114
+ const hookKey = 'SessionEnd'
115
+ cfg.hooks = cfg.hooks ?? {}
116
+ cfg.hooks[hookKey] = Array.isArray(cfg.hooks[hookKey]) ? cfg.hooks[hookKey] : []
117
+ const already = cfg.hooks[hookKey].some((h) => h?.hooks?.some((c) => c?.command?.includes('capture.mjs')))
118
+ if (already) {
119
+ log('✓ Claude Code SessionEnd hook 已存在,跳过')
120
+ return { agent: 'claude-hooks', ok: true }
121
+ }
122
+ if (!dryRun) {
123
+ if (Object.keys(cfg).length > 0) await backupOnce(settingsJson).catch(() => {})
124
+ cfg.hooks[hookKey].push(want)
125
+ await fs.mkdir(path.dirname(settingsJson), { recursive: true })
126
+ await fs.writeFile(settingsJson, JSON.stringify(cfg, null, 2), 'utf8')
127
+ log('✓ Claude Code SessionEnd hook 已写入 ~/.claude/settings.json')
128
+ } else {
129
+ log('[dry-run] 将写入 Claude Code hook')
130
+ }
131
+ return { agent: 'claude-hooks', ok: true }
132
+ }
133
+
134
+ // -- Codex CLI:~/.codex/config.toml 的 [mcp_servers.memory] --
135
+
136
+ export async function setupCodex({ nodePath, dryRun = false, log = () => {} } = {}) {
137
+ const codexToml = path.join(home, '.codex', 'config.toml')
138
+ if (!(await exists(codexToml))) {
139
+ log('✗ 未检测到 Codex CLI(~/.codex/config.toml 不存在)')
140
+ return [{ agent: 'codex', ok: false, reason: 'not installed' }]
141
+ }
142
+ const want = mcpCommand(nodePath)
143
+ const text = await fs.readFile(codexToml, 'utf8')
144
+ const sectionRe = /^\[mcp_servers\.memory\]$/m
145
+ if (sectionRe.test(text)) {
146
+ // 已存在:检查 command 是否与当前 node 一致,不一致则重写为当前 node(修复 dsh-desktop vs node 路径漂移)
147
+ const curCmd = /command\s*=\s*"([^"]*)"/.exec(text)?.[1]
148
+ if (curCmd && curCmd === want.command) {
149
+ log('✓ Codex MCP 已是最新,跳过')
150
+ return [{ agent: 'codex', ok: true, reason: 'section exists' }]
151
+ }
152
+ // node 路径不一致 → 重写 command 行
153
+ const newText = text.replace(/command\s*=\s*"[^"]*"/, `command = ${JSON.stringify(want.command)}`)
154
+ if (!dryRun) {
155
+ await backupOnce(codexToml)
156
+ await fs.writeFile(codexToml, newText, 'utf8')
157
+ log(`✓ Codex MCP node 路径已更新:${want.command}`)
158
+ } else {
159
+ log('[dry-run] 将更新 Codex MCP node 路径')
160
+ }
161
+ return [{ agent: 'codex', ok: true, reason: 'node path updated' }]
162
+ }
163
+ const block = [
164
+ '',
165
+ '# memory-eternal(自动写入;移除可运行 dsh-memory setup --codex-only --remove)',
166
+ '[mcp_servers.memory]',
167
+ `command = ${JSON.stringify(want.command)}`,
168
+ `args = ${JSON.stringify(want.args)}`,
169
+ '',
170
+ ].join('\n')
171
+ if (!dryRun) {
172
+ await backupOnce(codexToml)
173
+ await fs.writeFile(codexToml, text.replace(/\s*$/, '\n') + block, 'utf8')
174
+ log('✓ Codex MCP 已写入 ~/.codex/config.toml')
175
+ } else {
176
+ log('[dry-run] 将写入 Codex MCP ~/.codex/config.toml')
177
+ }
178
+ return [{ agent: 'codex', ok: true }]
179
+ }
180
+
181
+ // -- Cursor:~/.cursor/mcp.json --
182
+
183
+ export async function setupCursor({ nodePath, dryRun = false, log = () => {} } = {}) {
184
+ const cursorJson = path.join(home, '.cursor', 'mcp.json')
185
+ let cfg = { mcpServers: {} }
186
+ if (await exists(cursorJson)) {
187
+ try { cfg = JSON.parse(await fs.readFile(cursorJson, 'utf8')) } catch {
188
+ log('✗ ~/.cursor/mcp.json 解析失败,跳过')
189
+ return [{ agent: 'cursor', ok: false, reason: 'parse error' }]
190
+ }
191
+ } else {
192
+ // Cursor 目录不存在 = 未安装(保守判断:只报提示,不写文件)
193
+ const cursorDir = path.join(home, '.cursor')
194
+ if (!(await exists(cursorDir))) {
195
+ log('✗ 未检测到 Cursor(~/.cursor 不存在)')
196
+ return [{ agent: 'cursor', ok: false, reason: 'not installed' }]
197
+ }
198
+ }
199
+ const want = mcpCommand(nodePath)
200
+ cfg.mcpServers = cfg.mcpServers ?? {}
201
+ if (sameMcpEntry(cfg.mcpServers.memory, want)) {
202
+ log('✓ Cursor MCP 已是最新,跳过')
203
+ return [{ agent: 'cursor', ok: true }]
204
+ }
205
+ if (!dryRun) {
206
+ if (await exists(cursorJson)) await backupOnce(cursorJson)
207
+ cfg.mcpServers.memory = want
208
+ await fs.mkdir(path.dirname(cursorJson), { recursive: true })
209
+ await fs.writeFile(cursorJson, JSON.stringify(cfg, null, 2), 'utf8')
210
+ log('✓ Cursor MCP 已写入 ~/.cursor/mcp.json')
211
+ } else {
212
+ log('[dry-run] 将写入 Cursor MCP ~/.cursor/mcp.json')
213
+ }
214
+ return [{ agent: 'cursor', ok: true }]
215
+ }
216
+
217
+ // -- 总入口 -------------------------------------------------------------------
218
+
219
+ export async function runSetup(opts = {}) {
220
+ const {
221
+ only = [], // ['claude','codex','cursor'];空 = 全部
222
+ withHooks = true,
223
+ dryRun = false,
224
+ log = (m) => console.log(m),
225
+ enabled = process.env.MEMORY_ETERNAL_SKIP_AUTO !== '1',
226
+ } = opts
227
+ if (!enabled) {
228
+ log('⏭ MEMORY_ETERNAL_SKIP_AUTO=1,跳过自动挂载')
229
+ return { ok: true, results: [] }
230
+ }
231
+ const all = []
232
+ const want = (name) => only.length === 0 || only.includes(name)
233
+ if (want('claude')) all.push(...await setupClaude({ nodePath: process.execPath, withHooks, dryRun, log }))
234
+ if (want('codex')) all.push(...await setupCodex({ nodePath: process.execPath, dryRun, log }))
235
+ if (want('cursor')) all.push(...await setupCursor({ nodePath: process.execPath, dryRun, log }))
236
+ return { ok: true, results: all }
237
+ }
238
+
239
+ /**
240
+ * 只读查询各 agent 的 MCP 配置状态。不修改任何文件。
241
+ * @returns {Promise<{ok:true, agents: Array, lastCheckedAt: number}>}
242
+ */
243
+ export async function getSetupStatus({ nodePath = process.execPath } = {}) {
244
+ const agents = []
245
+ const want = mcpCommand(nodePath)
246
+ // claude-code
247
+ const claudeJson = path.join(home, '.claude.json')
248
+ if (!(await exists(claudeJson))) {
249
+ agents.push({ name: 'claude-code', installed: false, mcpConfigured: false, hook: 'unknown' })
250
+ } else {
251
+ let cfg = null
252
+ try { cfg = JSON.parse(await fs.readFile(claudeJson, 'utf8')) } catch {}
253
+ const entry = cfg?.mcpServers?.memory
254
+ const mcpExists = !!entry
255
+ agents.push({
256
+ name: 'claude-code',
257
+ installed: true,
258
+ mcpConfigured: mcpExists,
259
+ mcpMatchesCurrentNode: mcpExists && sameMcpEntry(entry, want),
260
+ currentNodePath: nodePath,
261
+ configuredNodePath: entry?.command || '',
262
+ mcpPath: claudeJson,
263
+ })
264
+ // hook
265
+ const settingsJson = path.join(home, '.claude', 'settings.json')
266
+ if (await exists(settingsJson)) {
267
+ try {
268
+ const scfg = JSON.parse(await fs.readFile(settingsJson, 'utf8'))
269
+ const list = scfg?.hooks?.SessionEnd || []
270
+ const has = list.some((h) => h?.hooks?.some((c) => c?.command?.includes('capture.mjs')))
271
+ agents[agents.length - 1].hook = has ? 'configured' : 'missing'
272
+ } catch { agents[agents.length - 1].hook = 'unknown' }
273
+ } else {
274
+ agents[agents.length - 1].hook = 'missing'
275
+ }
276
+ }
277
+ // codex
278
+ const codexToml = path.join(home, '.codex', 'config.toml')
279
+ if (!(await exists(codexToml))) {
280
+ agents.push({ name: 'codex', installed: false, mcpConfigured: false })
281
+ } else {
282
+ const text = await fs.readFile(codexToml, 'utf8')
283
+ const has = /^\[mcp_servers\.memory\]$/m.test(text)
284
+ let configuredNodePath = ''
285
+ if (has) { const m = /command\s*=\s*"([^"]+)"/.exec(text); if (m) configuredNodePath = m[1] }
286
+ agents.push({
287
+ name: 'codex',
288
+ installed: true,
289
+ mcpConfigured: has,
290
+ mcpMatchesCurrentNode: has && configuredNodePath === nodePath,
291
+ currentNodePath: nodePath,
292
+ configuredNodePath,
293
+ mcpPath: codexToml,
294
+ hook: await hookState(path.join(home, '.codex', 'hooks.json'), 'codex'),
295
+ })
296
+ }
297
+ // cursor
298
+ const cursorJson = path.join(home, '.cursor', 'mcp.json')
299
+ const cursorDir = path.join(home, '.cursor')
300
+ if (!(await exists(cursorDir))) {
301
+ agents.push({ name: 'cursor', installed: false, mcpConfigured: false, reason: '~/.cursor 不存在' })
302
+ } else if (!(await exists(cursorJson))) {
303
+ agents.push({ name: 'cursor', installed: true, mcpConfigured: false, mcpPath: cursorJson })
304
+ } else {
305
+ try {
306
+ const cfg = JSON.parse(await fs.readFile(cursorJson, 'utf8'))
307
+ const entry = cfg?.mcpServers?.memory
308
+ const mcpExists = !!entry
309
+ agents.push({
310
+ name: 'cursor',
311
+ installed: true,
312
+ mcpConfigured: mcpExists,
313
+ mcpMatchesCurrentNode: mcpExists && sameMcpEntry(entry, want),
314
+ currentNodePath: nodePath,
315
+ configuredNodePath: entry?.command || '',
316
+ mcpPath: cursorJson,
317
+ hook: await hookState(path.join(home, '.cursor', 'hooks.json'), 'cursor', true),
318
+ })
319
+ } catch { agents.push({ name: 'cursor', installed: true, mcpConfigured: false, mcpPath: cursorJson, reason: 'JSON 解析失败' }) }
320
+ }
321
+ return { ok: true, agents, lastCheckedAt: Date.now(), want: { command: nodePath, args: want.args } }
322
+ }
323
+
324
+ // -- 会话结束自动沉淀 hook(connect <agent>,agentmemory 的 `connect --with-hooks` 兜底路径)--
325
+ // 不依赖 plugin(Codex Desktop 也适用),只写「用户级 hooks.json + 绝对路径指向 capture.mjs」,
326
+ // 幂等 + 备份,只碰 capture.mjs 标记的条目,绝不动其它用户条目。
327
+
328
+ /** capture.mjs 的完整调用(绝对路径,避免升级后路径内嵌版本号而失效)。 */
329
+ function captureCmd(agent) {
330
+ const node = process.execPath
331
+ const script = path.join(PACKAGE_ROOT, 'hooks', 'capture.mjs')
332
+ return `${JSON.stringify(node)} ${JSON.stringify(script)} ${agent}`
333
+ }
334
+
335
+ /** 写一个幂等 hooks.json:events → [{command}](cursor 风格)或 {hooks:[{type,command}]}(codex 风格)。
336
+ * 幂等 + 路径迁移:已有 capture.mjs 条目但路径不同 → 就地改为当前包路径;没有 → 追加;正确 → 跳过。 */
337
+ async function writeHooksJson(hooksJson, evEntries, { dryRun = false, log = () => {} } = {}) {
338
+ let cfg = { version: 1, hooks: {} }
339
+ if (await exists(hooksJson)) {
340
+ try { cfg = JSON.parse(await fs.readFile(hooksJson, 'utf8')) } catch {
341
+ log(`✗ ${hooksJson} 解析失败,跳过`)
342
+ return { ok: false, reason: 'parse error' }
343
+ }
344
+ }
345
+ cfg.hooks = cfg.hooks ?? {}
346
+ let changed = false
347
+ for (const ev of evEntries) {
348
+ const arr = cfg.hooks[ev.event] = Array.isArray(cfg.hooks[ev.event]) ? cfg.hooks[ev.event] : []
349
+ const wantCmd = ev.cmd
350
+ // 找已存在的 capture.mjs 条目(可能是本脚本旧路径,或其它包的同脚本名)
351
+ const idx = arr.findIndex((h) => JSON.stringify(h).includes('capture.mjs'))
352
+ if (idx >= 0) {
353
+ const entry = ev.codexStyle ? (arr[idx].hooks || []).find((x) => x.command && x.command.includes('capture.mjs')) : arr[idx]
354
+ if (entry && entry.command === wantCmd) continue // 已正确
355
+ if (entry) { entry.command = wantCmd; changed = true } // 路径迁移
356
+ else { arr[idx] = ev.codexStyle ? { hooks: [{ type: 'command', command: wantCmd, timeout: 60, statusMessage: 'memory: save session' }] } : { command: wantCmd }; changed = true }
357
+ } else {
358
+ arr.push(ev.codexStyle ? { hooks: [{ type: 'command', command: wantCmd, timeout: 60, statusMessage: 'memory: save session' }] } : { command: wantCmd })
359
+ changed = true
360
+ }
361
+ }
362
+ if (!changed) { log(`✓ ${hooksJson} 已是最新,跳过`); return { ok: true, skipped: true } }
363
+ if (dryRun) { log(`[dry-run] 将写入 ${hooksJson}`); return { ok: true, dry: true } }
364
+ if (await exists(hooksJson)) await backupOnce(hooksJson)
365
+ await fs.mkdir(path.dirname(hooksJson), { recursive: true })
366
+ await fs.writeFile(hooksJson, JSON.stringify(cfg, null, 2), 'utf8')
367
+ log(`✓ 会话结束 hook 已写入 ${hooksJson}`)
368
+ return { ok: true }
369
+ }
370
+
371
+ /** Codex:用户级 ~/.codex/hooks.json 的 Stop 事件(CLI 与 Desktop 都可用)。 */
372
+ export async function connectCodexHooks(opts = {}) {
373
+ return writeHooksJson(path.join(home, '.codex', 'hooks.json'), [{ event: 'Stop', codexStyle: true, cmd: captureCmd('codex') }], opts)
374
+ }
375
+
376
+ /** Cursor:~/.cursor/hooks.json 的 stop / sessionEnd 事件。 */
377
+ export async function connectCursorHooks(opts = {}) {
378
+ return writeHooksJson(path.join(home, '.cursor', 'hooks.json'), [{ event: 'stop', cmd: captureCmd('cursor') }, { event: 'sessionEnd', cmd: captureCmd('cursor') }], opts)
379
+ }
380
+
381
+ /** 统一入口:claude 复用 setupClaude 的 SessionEnd(capture.mjs);codex/cursor 写 capture.mjs hooks。 */
382
+ export async function connectAgentHooks(agent, opts = {}) {
383
+ if (agent === 'claude' || agent === 'claude-code') {
384
+ const results = await setupClaude({ nodePath: process.execPath, withHooks: true, log: opts.log })
385
+ return { ok: results.every((r) => r.ok), results }
386
+ }
387
+ if (agent === 'codex') return connectCodexHooks(opts)
388
+ if (agent === 'cursor') return connectCursorHooks(opts)
389
+ return { ok: false, error: `未知 agent: ${agent}` }
390
+ }
391
+
392
+ // -- 单智能体 MCP 安装 / 卸载 ----------------------------------------------------
393
+
394
+ /** 卸载 Claude Code 的 memory MCP + SessionEnd hook。 */
395
+ export async function removeClaudeMcp({ dryRun = false, log = () => {} } = {}) {
396
+ const claudeJson = path.join(home, '.claude.json')
397
+ let removed = false
398
+ if (await exists(claudeJson)) {
399
+ try {
400
+ const cfg = JSON.parse(await fs.readFile(claudeJson, 'utf8'))
401
+ if (cfg.mcpServers && cfg.mcpServers.memory) {
402
+ if (!dryRun) { await backupOnce(claudeJson); delete cfg.mcpServers.memory; await fs.writeFile(claudeJson, JSON.stringify(cfg, null, 2), 'utf8') }
403
+ removed = true
404
+ }
405
+ } catch {}
406
+ }
407
+ // 移除 SessionEnd hook
408
+ const settingsJson = path.join(home, '.claude', 'settings.json')
409
+ if (await exists(settingsJson)) {
410
+ try {
411
+ const scfg = JSON.parse(await fs.readFile(settingsJson, 'utf8'))
412
+ if (scfg.hooks?.SessionEnd) {
413
+ const kept = scfg.hooks.SessionEnd.filter((h) => !(h.hooks || []).some((c) => c?.command?.includes('capture.mjs')))
414
+ if (kept.length !== scfg.hooks.SessionEnd.length) {
415
+ if (!dryRun) { await backupOnce(settingsJson); scfg.hooks.SessionEnd = kept; await fs.writeFile(settingsJson, JSON.stringify(scfg, null, 2), 'utf8') }
416
+ removed = true
417
+ }
418
+ }
419
+ } catch {}
420
+ }
421
+ log(removed ? (dryRun ? '[dry-run] 将卸载 Claude Code MCP' : '✓ 已卸载 Claude Code MCP') : '✓ Claude Code MCP 未配置')
422
+ return { agent: 'claude-code', ok: true, removed }
423
+ }
424
+
425
+ /** 卸载 Codex 的 memory MCP 段。 */
426
+ export async function removeCodexMcp({ dryRun = false, log = () => {} } = {}) {
427
+ const codexToml = path.join(home, '.codex', 'config.toml')
428
+ if (!(await exists(codexToml))) return { agent: 'codex', ok: false, reason: 'not installed' }
429
+ const text = await fs.readFile(codexToml, 'utf8')
430
+ const sectionRe = /^\s*\[mcp_servers\.memory\]\s*$/m
431
+ if (!sectionRe.test(text)) { log('✓ Codex MCP 未配置'); return { agent: 'codex', ok: true, removed: false } }
432
+ if (!dryRun) {
433
+ const newText = text.replace(/^\s*# memory-eternal.*\n?/m, '').replace(/^\s*\[mcp_servers\.memory\]\s*\n(?:command\s*=.*\n|args\s*=.*\n)+\s*/m, '')
434
+ await backupOnce(codexToml)
435
+ await fs.writeFile(codexToml, newText.replace(/\n{3,}/g, '\n\n'), 'utf8')
436
+ }
437
+ log(dryRun ? '[dry-run] 将卸载 Codex MCP' : '✓ 已卸载 Codex MCP')
438
+ return { agent: 'codex', ok: true, removed: true }
439
+ }
440
+
441
+ /** 卸载 Cursor 的 memory MCP。 */
442
+ export async function removeCursorMcp({ dryRun = false, log = () => {} } = {}) {
443
+ const cursorJson = path.join(home, '.cursor', 'mcp.json')
444
+ if (!(await exists(cursorJson))) return { agent: 'cursor', ok: false, reason: 'not installed' }
445
+ try {
446
+ const cfg = JSON.parse(await fs.readFile(cursorJson, 'utf8'))
447
+ if (cfg.mcpServers && cfg.mcpServers.memory) {
448
+ if (!dryRun) { await backupOnce(cursorJson); delete cfg.mcpServers.memory; await fs.writeFile(cursorJson, JSON.stringify(cfg, null, 2), 'utf8') }
449
+ log(dryRun ? '[dry-run] 将卸载 Cursor MCP' : '✓ 已卸载 Cursor MCP')
450
+ return { agent: 'cursor', ok: true, removed: true }
451
+ }
452
+ } catch {}
453
+ log('✓ Cursor MCP 未配置')
454
+ return { agent: 'cursor', ok: true, removed: false }
455
+ }
456
+
457
+ /** 对单个智能体执行安装/卸载 MCP。action: 'install' | 'uninstall'。agent: 'claude-code'|'codex'|'cursor' */
458
+ export async function mcpAgentAction(agent, action, { dryRun = false, log = () => {} } = {}) {
459
+ if (action === 'install') {
460
+ const nodePath = process.execPath
461
+ if (agent === 'claude-code') return { ok: true, results: await setupClaude({ nodePath, dryRun, log }) }
462
+ if (agent === 'codex') return { ok: true, results: await setupCodex({ nodePath, dryRun, log }) }
463
+ if (agent === 'cursor') return { ok: true, results: await setupCursor({ nodePath, dryRun, log }) }
464
+ } else if (action === 'uninstall') {
465
+ if (agent === 'claude-code') return { ok: true, results: [await removeClaudeMcp({ dryRun, log })] }
466
+ if (agent === 'codex') return { ok: true, results: [await removeCodexMcp({ dryRun, log })] }
467
+ if (agent === 'cursor') return { ok: true, results: [await removeCursorMcp({ dryRun, log })] }
468
+ }
469
+ return { ok: false, error: `未知 agent/action: ${agent}/${action}` }
470
+ }