memory-eternal 0.9.23 → 0.10.2
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 +2 -16
- package/.codex-plugin/plugin.json +2 -16
- package/.cursor-plugin/plugin.json +2 -16
- package/.mcp.json +9 -9
- package/LICENSE +21 -21
- package/README.en.md +27 -7
- package/README.md +40 -7
- package/bin/dsh-memory.mjs +318 -188
- package/examples/mcp-memory/memory-eternal.cordis.yml +59 -0
- package/hooks/capture.mjs +77 -77
- package/hooks/claude-session.js +56 -56
- package/hooks/hooks.claude.json +12 -12
- package/hooks/hooks.codex.json +15 -15
- package/hooks/hooks.cursor.json +11 -11
- package/index.js +187 -32
- package/lib/api.js +592 -536
- package/lib/capture-log.js +65 -59
- package/lib/capture-run.js +134 -126
- package/lib/capture.js +278 -14
- package/lib/client.js +20 -17
- package/lib/config-sync.js +161 -89
- package/lib/db.js +359 -212
- package/lib/feedback.js +214 -205
- package/lib/llm-openai.js +84 -84
- package/lib/mcp.js +197 -166
- package/lib/node-bin.js +99 -99
- package/lib/setup.js +470 -470
- package/lib/sse.js +51 -51
- package/lib/stall.js +30 -30
- package/lib/sweep.js +56 -56
- package/lib/vault-resolve.js +84 -84
- package/lib/vault.js +347 -106
- package/lib/watchdog.js +527 -392
- package/lib/web-assets.js +41 -0
- package/lib/web.js +316 -176
- package/package.json +12 -7
- package/scripts/release.mjs +204 -0
- package/skills/memory-recall/SKILL.md +14 -14
- package/web/app.js +20 -17
- package/web/index.html +46 -46
package/lib/mcp.js
CHANGED
|
@@ -1,166 +1,197 @@
|
|
|
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
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return toolResult(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 / memory_audit_list。
|
|
9
|
+
//
|
|
10
|
+
// 审核边界(issue #17):MCP 侧**只读**。新增的 memory_audit_list 只列待审/驳回,
|
|
11
|
+
// 不提供任何 approve/reject —— 否则任何接了 MCP 的智能体都能批准自己刚写进去的卡,
|
|
12
|
+
// 「人类把关」就形同不存在(写入路径留在审核中心面板与 CLI:dsh-memory audit)。
|
|
13
|
+
//
|
|
14
|
+
// 启动:node bin/dsh-memory.mjs mcp (或 node lib/mcp.js)
|
|
15
|
+
|
|
16
|
+
import readline from 'node:readline'
|
|
17
|
+
import { search, overview, stats, ensureVault, listCards } from './vault.js'
|
|
18
|
+
import { runStandaloneCapture, defaultVaultDir } from './capture-run.js'
|
|
19
|
+
|
|
20
|
+
const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05']
|
|
21
|
+
const DEFAULT_PROTOCOL_VERSION = '2024-11-05'
|
|
22
|
+
|
|
23
|
+
export const MCP_TOOLS = [
|
|
24
|
+
{
|
|
25
|
+
name: 'memory_recall',
|
|
26
|
+
description:
|
|
27
|
+
'从本地记忆核心(Markdown 知识库)检索相关知识卡。需要项目背景、历史决策、之前讨论过的方案、' +
|
|
28
|
+
'或领域知识时调用;返回最相关的卡片摘要。用 query 描述要找的内容,支持中文整词与字符片段检索。',
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
query: { type: 'string', description: '检索关键词或自然语言描述,如「数据库选型」「用户偏好」' },
|
|
33
|
+
limit: { type: 'number', description: '返回卡片数上限,默认 5' },
|
|
34
|
+
},
|
|
35
|
+
required: ['query'],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'memory_capture',
|
|
40
|
+
description:
|
|
41
|
+
'把一段值得长期复用的对话/知识沉淀进记忆库。配置了 MEMORY_LLM_* 环境变量时会用 LLM 蒸馏压缩成知识卡;' +
|
|
42
|
+
'未配置时降级为原文卡。通常由会话结束 hook 自动调用,手动调用用于显式保存重要结论。',
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
text: { type: 'string', description: '要沉淀的对话文本或知识内容(建议 >120 字符)' },
|
|
47
|
+
source: { type: 'string', description: '来源标记,如 session:xxx / claude-code / codex' },
|
|
48
|
+
},
|
|
49
|
+
required: ['text'],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'memory_stats',
|
|
54
|
+
description: '查看记忆库概览:总卡数、分类分布、最近新增、vault 目录路径。',
|
|
55
|
+
inputSchema: { type: 'object', properties: {} },
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'memory_audit_list',
|
|
59
|
+
description:
|
|
60
|
+
'只读:列出记忆库里待审核 / 已驳回的卡片(不会批准或驳回任何卡)。' +
|
|
61
|
+
'用于主动提醒用户「有 N 张待审卡」;批准/驳回必须由人在审核中心面板或 CLI(dsh-memory audit approve/reject)显式发起。',
|
|
62
|
+
inputSchema: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
status: { type: 'string', description: 'pending(默认)| rejected | all' },
|
|
66
|
+
limit: { type: 'number', description: '返回条数上限,默认 20(最大 100)' },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
function toolResult(text, isError = false) {
|
|
73
|
+
return { content: [{ type: 'text', text: String(text) }], ...(isError ? { isError: true } : {}) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function callTool(name, args, vaultRoot, meta = {}) {
|
|
77
|
+
switch (name) {
|
|
78
|
+
case 'memory_recall': {
|
|
79
|
+
const query = String(args?.query || '').trim()
|
|
80
|
+
if (!query) return toolResult('(未提供检索词)')
|
|
81
|
+
const limit = Math.min(Math.max(Number(args?.limit) || 5, 1), 20)
|
|
82
|
+
const hits = await search(vaultRoot, query, { limit, minScore: 2 })
|
|
83
|
+
if (hits.length === 0) return toolResult(`记忆库中没有与「${query}」相关的内容。`)
|
|
84
|
+
const lines = hits.map((h, i) => {
|
|
85
|
+
const tags = h.tags.length ? ` [${h.tags.join(', ')}]` : ''
|
|
86
|
+
const snippet = String(h.summary || '').replace(/\s+/g, ' ').trim().slice(0, 130)
|
|
87
|
+
return `### ${i + 1}. ${h.title}${tags}\n路径:${h.path}\n${snippet}`
|
|
88
|
+
})
|
|
89
|
+
return toolResult(`从记忆核心检索到 ${hits.length} 条相关卡片:\n\n${lines.join('\n\n')}`)
|
|
90
|
+
}
|
|
91
|
+
case 'memory_capture': {
|
|
92
|
+
const text = String(args?.text || '')
|
|
93
|
+
const out = await runStandaloneCapture(vaultRoot, text, {
|
|
94
|
+
source: String(args?.source || meta.submittedBy || 'mcp'),
|
|
95
|
+
})
|
|
96
|
+
if (!out.ok && out.action === 'failed') return toolResult(`沉淀失败:${out.reason}`, true)
|
|
97
|
+
return toolResult(JSON.stringify(out))
|
|
98
|
+
}
|
|
99
|
+
case 'memory_audit_list': {
|
|
100
|
+
await ensureVault(vaultRoot)
|
|
101
|
+
const wanted = String(args?.status || 'pending').toLowerCase()
|
|
102
|
+
const STATUS = { pending: ['pending'], rejected: ['rejected'], all: ['pending', 'rejected'], queue: ['pending', 'rejected'] }
|
|
103
|
+
const status = STATUS[wanted] || ['pending']
|
|
104
|
+
const limit = Math.min(Math.max(Number(args?.limit) || 20, 1), 100)
|
|
105
|
+
const cards = await listCards(vaultRoot, { status, limit })
|
|
106
|
+
if (!cards.length) return toolResult(`记忆库里没有 ${wanted} 状态的卡片(vault: ${vaultRoot})。`)
|
|
107
|
+
const lines = cards.map((c, i) => `${i + 1}. [${c.status}] ${c.kind} · ${c.title}\n 路径:${c.path}`)
|
|
108
|
+
return toolResult(
|
|
109
|
+
`待处理 ${cards.length} 张(status=${wanted},vault: ${vaultRoot}):\n\n${lines.join('\n')}\n\n` +
|
|
110
|
+
'(只读列表。批准/驳回请由用户在「审核中心」面板或运行 dsh-memory audit approve/reject 显式完成。)',
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
case 'memory_stats': {
|
|
114
|
+
await ensureVault(vaultRoot)
|
|
115
|
+
const ov = await overview(vaultRoot)
|
|
116
|
+
const s = await stats(vaultRoot)
|
|
117
|
+
return toolResult(JSON.stringify({ vaultDir: vaultRoot, total: ov.total, byKind: ov.byKind, tags: s.tags, week: s.week }, null, 2))
|
|
118
|
+
}
|
|
119
|
+
default:
|
|
120
|
+
throw new Error(`未知工具:${name}`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function startMcpServer({ vaultRoot = defaultVaultDir() } = {}) {
|
|
125
|
+
const rl = readline.createInterface({ input: process.stdin })
|
|
126
|
+
const write = (msg) => process.stdout.write(JSON.stringify(msg) + '\n')
|
|
127
|
+
let pendingCalls = 0
|
|
128
|
+
let closing = false
|
|
129
|
+
// MCP 客户端信息:initialize 时记录(agent 名),供 memory_capture 署名用
|
|
130
|
+
let clientInfo = {}
|
|
131
|
+
const maybeExit = () => { if (closing && pendingCalls === 0) process.exit(0) }
|
|
132
|
+
|
|
133
|
+
rl.on('line', (line) => {
|
|
134
|
+
const raw = line.trim()
|
|
135
|
+
if (!raw) return
|
|
136
|
+
let msg
|
|
137
|
+
try {
|
|
138
|
+
msg = JSON.parse(raw)
|
|
139
|
+
} catch {
|
|
140
|
+
write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const { id, method, params } = msg
|
|
144
|
+
// notification(无 id)不回包
|
|
145
|
+
if (id === undefined || id === null) {
|
|
146
|
+
if (method === 'notifications/initialized' || method === 'notifications/cancelled') return
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
if (method === 'initialize') {
|
|
151
|
+
const requested = String(params?.protocolVersion || '')
|
|
152
|
+
const negotiated = SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : DEFAULT_PROTOCOL_VERSION
|
|
153
|
+
clientInfo = params?.clientInfo || {}
|
|
154
|
+
write({
|
|
155
|
+
jsonrpc: '2.0',
|
|
156
|
+
id,
|
|
157
|
+
result: {
|
|
158
|
+
protocolVersion: negotiated,
|
|
159
|
+
capabilities: { tools: {} },
|
|
160
|
+
serverInfo: { name: 'memory-eternal', version: '1.0.0' },
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
if (method === 'ping') {
|
|
166
|
+
write({ jsonrpc: '2.0', id, result: {} })
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
if (method === 'tools/list') {
|
|
170
|
+
write({ jsonrpc: '2.0', id, result: { tools: MCP_TOOLS } })
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
if (method === 'tools/call') {
|
|
174
|
+
const name = params?.name
|
|
175
|
+
const args = params?.arguments ?? {}
|
|
176
|
+
const sub = clientInfo?.name ? `${clientInfo.name}${clientInfo.version ? '@' + clientInfo.version : ''}` : 'mcp'
|
|
177
|
+
pendingCalls++
|
|
178
|
+
callTool(name, args, vaultRoot, { submittedBy: sub })
|
|
179
|
+
.then((result) => write({ jsonrpc: '2.0', id, result }))
|
|
180
|
+
.catch((error) => write({ jsonrpc: '2.0', id, result: toolResult(String(error?.message || error), true) }))
|
|
181
|
+
.finally(() => { pendingCalls--; maybeExit() })
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
write({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } })
|
|
185
|
+
} catch (error) {
|
|
186
|
+
write({ jsonrpc: '2.0', id, error: { code: -32603, message: String(error?.message || error) } })
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
rl.on('close', () => { closing = true; maybeExit() })
|
|
191
|
+
process.stderr.write(`[memory-eternal] MCP server ready (vault: ${vaultRoot})\n`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 作为脚本直接运行(node lib/mcp.js / node bin/dsh-memory.mjs mcp 的转发目标)时自动启动。
|
|
195
|
+
if (process.argv[1] && /[\\/]mcp\.js$/.test(process.argv[1])) {
|
|
196
|
+
startMcpServer()
|
|
197
|
+
}
|
package/lib/node-bin.js
CHANGED
|
@@ -1,99 +1,99 @@
|
|
|
1
|
-
// 记忆核心 · 子进程用的 node 可执行文件解析
|
|
2
|
-
//
|
|
3
|
-
// 背景(真实故障):
|
|
4
|
-
// 在 Electron 宿主里(例如 DSH Desktop),`process.execPath` 指向的是
|
|
5
|
-
// Electron 主程序,而不是 node。此前 `ensureWebServer()` / watchdog / 插件的
|
|
6
|
-
// 保活逻辑都用 `spawn(process.execPath, ['.../web.js', ...])` 拉起子进程,
|
|
7
|
-
// 于是在 Electron 宿主下 spawn 出来的是「第二个 Electron 实例」:
|
|
8
|
-
// 它不会执行 web.js,而是立刻退出(或被单实例锁挡掉),而调用方用的是
|
|
9
|
-
// `stdio: 'ignore'` + `.catch(() => {})`,失败被彻底吞掉——表现就是侧边栏
|
|
10
|
-
// 「记忆」弹窗里那个指向 127.0.0.1:7999 的 iframe 永远白屏。
|
|
11
|
-
//
|
|
12
|
-
// 本模块把「找出一个真正能跑 node 脚本的子进程入口」收敛到一处:
|
|
13
|
-
// 1. MEMORY_ETERNAL_NODE 显式指定(逃生口,便于测试与非常规宿主);
|
|
14
|
-
// 2. 非 Electron 宿主:process.execPath 本身就是 node,直接用;
|
|
15
|
-
// 3. Electron 宿主:优先从 PATH 找真正的 node;
|
|
16
|
-
// 4. 都找不到时退回 process.execPath,并由 childEnv() 打开
|
|
17
|
-
// ELECTRON_RUN_AS_NODE,让 Electron 二进制以纯 node 模式运行。
|
|
18
|
-
|
|
19
|
-
import fs from 'node:fs'
|
|
20
|
-
import path from 'node:path'
|
|
21
|
-
|
|
22
|
-
let cachedBinary = null
|
|
23
|
-
let cachedSource = null
|
|
24
|
-
|
|
25
|
-
/** 在 PATH 中查找真正的 node 可执行文件;找不到返回 null。 */
|
|
26
|
-
function findNodeOnPath() {
|
|
27
|
-
const names = process.platform === 'win32' ? ['node.exe', 'node.cmd', 'node'] : ['node', 'node.exe']
|
|
28
|
-
const dirs = String(process.env.PATH || '').split(path.delimiter)
|
|
29
|
-
for (const dir of dirs) {
|
|
30
|
-
if (!dir) continue
|
|
31
|
-
for (const name of names) {
|
|
32
|
-
const candidate = path.join(dir, name)
|
|
33
|
-
try {
|
|
34
|
-
if (fs.statSync(candidate).isFile()) return candidate
|
|
35
|
-
} catch {
|
|
36
|
-
// 目录不存在或无权访问:继续找下一个
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
return null
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* 返回用于 spawn 子进程(跑 .js 脚本)的可执行文件路径。
|
|
45
|
-
* @returns {string}
|
|
46
|
-
*/
|
|
47
|
-
export function nodeBinary() {
|
|
48
|
-
if (cachedBinary) return cachedBinary
|
|
49
|
-
|
|
50
|
-
if (process.env.MEMORY_ETERNAL_NODE) {
|
|
51
|
-
cachedBinary = process.env.MEMORY_ETERNAL_NODE
|
|
52
|
-
cachedSource = 'env'
|
|
53
|
-
return cachedBinary
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (!process.versions.electron) {
|
|
57
|
-
cachedBinary = process.execPath
|
|
58
|
-
cachedSource = 'execPath'
|
|
59
|
-
return cachedBinary
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const found = findNodeOnPath()
|
|
63
|
-
if (found) {
|
|
64
|
-
cachedBinary = found
|
|
65
|
-
cachedSource = 'path'
|
|
66
|
-
return cachedBinary
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
cachedBinary = process.execPath
|
|
70
|
-
cachedSource = 'electron-run-as-node'
|
|
71
|
-
return cachedBinary
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** 说明 nodeBinary() 最终选了哪条路径(诊断用,不参与逻辑)。 */
|
|
75
|
-
export function nodeBinarySource() {
|
|
76
|
-
nodeBinary()
|
|
77
|
-
return cachedSource
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* 子进程环境:始终打开 ELECTRON_RUN_AS_NODE。
|
|
82
|
-
* 真正的 node 会忽略这个变量,所以无条件设置是安全的;而当 nodeBinary()
|
|
83
|
-
* 只能退回 Electron 二进制时,它就是让子进程以 node 模式运行的关键。
|
|
84
|
-
* @param {Record<string, string>} [extra]
|
|
85
|
-
*/
|
|
86
|
-
export function childEnv(extra) {
|
|
87
|
-
return { ...process.env, ...(extra || {}), ELECTRON_RUN_AS_NODE: '1' }
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** 是否只能依赖 Electron 二进制的 node 模式(PATH 上没有真 node)。 */
|
|
91
|
-
export function usesElectronBinary() {
|
|
92
|
-
return nodeBinarySource() === 'electron-run-as-node'
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** 仅测试用:清掉进程内缓存。 */
|
|
96
|
-
export function resetNodeBinaryCache() {
|
|
97
|
-
cachedBinary = null
|
|
98
|
-
cachedSource = null
|
|
99
|
-
}
|
|
1
|
+
// 记忆核心 · 子进程用的 node 可执行文件解析
|
|
2
|
+
//
|
|
3
|
+
// 背景(真实故障):
|
|
4
|
+
// 在 Electron 宿主里(例如 DSH Desktop),`process.execPath` 指向的是
|
|
5
|
+
// Electron 主程序,而不是 node。此前 `ensureWebServer()` / watchdog / 插件的
|
|
6
|
+
// 保活逻辑都用 `spawn(process.execPath, ['.../web.js', ...])` 拉起子进程,
|
|
7
|
+
// 于是在 Electron 宿主下 spawn 出来的是「第二个 Electron 实例」:
|
|
8
|
+
// 它不会执行 web.js,而是立刻退出(或被单实例锁挡掉),而调用方用的是
|
|
9
|
+
// `stdio: 'ignore'` + `.catch(() => {})`,失败被彻底吞掉——表现就是侧边栏
|
|
10
|
+
// 「记忆」弹窗里那个指向 127.0.0.1:7999 的 iframe 永远白屏。
|
|
11
|
+
//
|
|
12
|
+
// 本模块把「找出一个真正能跑 node 脚本的子进程入口」收敛到一处:
|
|
13
|
+
// 1. MEMORY_ETERNAL_NODE 显式指定(逃生口,便于测试与非常规宿主);
|
|
14
|
+
// 2. 非 Electron 宿主:process.execPath 本身就是 node,直接用;
|
|
15
|
+
// 3. Electron 宿主:优先从 PATH 找真正的 node;
|
|
16
|
+
// 4. 都找不到时退回 process.execPath,并由 childEnv() 打开
|
|
17
|
+
// ELECTRON_RUN_AS_NODE,让 Electron 二进制以纯 node 模式运行。
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
|
|
22
|
+
let cachedBinary = null
|
|
23
|
+
let cachedSource = null
|
|
24
|
+
|
|
25
|
+
/** 在 PATH 中查找真正的 node 可执行文件;找不到返回 null。 */
|
|
26
|
+
function findNodeOnPath() {
|
|
27
|
+
const names = process.platform === 'win32' ? ['node.exe', 'node.cmd', 'node'] : ['node', 'node.exe']
|
|
28
|
+
const dirs = String(process.env.PATH || '').split(path.delimiter)
|
|
29
|
+
for (const dir of dirs) {
|
|
30
|
+
if (!dir) continue
|
|
31
|
+
for (const name of names) {
|
|
32
|
+
const candidate = path.join(dir, name)
|
|
33
|
+
try {
|
|
34
|
+
if (fs.statSync(candidate).isFile()) return candidate
|
|
35
|
+
} catch {
|
|
36
|
+
// 目录不存在或无权访问:继续找下一个
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 返回用于 spawn 子进程(跑 .js 脚本)的可执行文件路径。
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function nodeBinary() {
|
|
48
|
+
if (cachedBinary) return cachedBinary
|
|
49
|
+
|
|
50
|
+
if (process.env.MEMORY_ETERNAL_NODE) {
|
|
51
|
+
cachedBinary = process.env.MEMORY_ETERNAL_NODE
|
|
52
|
+
cachedSource = 'env'
|
|
53
|
+
return cachedBinary
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!process.versions.electron) {
|
|
57
|
+
cachedBinary = process.execPath
|
|
58
|
+
cachedSource = 'execPath'
|
|
59
|
+
return cachedBinary
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const found = findNodeOnPath()
|
|
63
|
+
if (found) {
|
|
64
|
+
cachedBinary = found
|
|
65
|
+
cachedSource = 'path'
|
|
66
|
+
return cachedBinary
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
cachedBinary = process.execPath
|
|
70
|
+
cachedSource = 'electron-run-as-node'
|
|
71
|
+
return cachedBinary
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 说明 nodeBinary() 最终选了哪条路径(诊断用,不参与逻辑)。 */
|
|
75
|
+
export function nodeBinarySource() {
|
|
76
|
+
nodeBinary()
|
|
77
|
+
return cachedSource
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 子进程环境:始终打开 ELECTRON_RUN_AS_NODE。
|
|
82
|
+
* 真正的 node 会忽略这个变量,所以无条件设置是安全的;而当 nodeBinary()
|
|
83
|
+
* 只能退回 Electron 二进制时,它就是让子进程以 node 模式运行的关键。
|
|
84
|
+
* @param {Record<string, string>} [extra]
|
|
85
|
+
*/
|
|
86
|
+
export function childEnv(extra) {
|
|
87
|
+
return { ...process.env, ...(extra || {}), ELECTRON_RUN_AS_NODE: '1' }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 是否只能依赖 Electron 二进制的 node 模式(PATH 上没有真 node)。 */
|
|
91
|
+
export function usesElectronBinary() {
|
|
92
|
+
return nodeBinarySource() === 'electron-run-as-node'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 仅测试用:清掉进程内缓存。 */
|
|
96
|
+
export function resetNodeBinaryCache() {
|
|
97
|
+
cachedBinary = null
|
|
98
|
+
cachedSource = null
|
|
99
|
+
}
|