memory-eternal 0.10.0 → 0.10.3

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 CHANGED
@@ -1,166 +1,207 @@
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
- }
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, countCards } 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 张」永远是匹配总数;limit 只限制列出条数,被截断时会明确写出还有多少张未显示。' +
62
+ '用于主动提醒用户「有 N 张待审卡」;批准/驳回必须由人在审核中心面板或 CLI(dsh-memory audit approve/reject)显式发起。',
63
+ inputSchema: {
64
+ type: 'object',
65
+ properties: {
66
+ status: { type: 'string', description: 'pending(默认)| rejected | all(= pending + rejected)' },
67
+ limit: { type: 'number', description: '列出条数上限,默认 20(最大 500);总数不受它影响' },
68
+ },
69
+ },
70
+ },
71
+ ]
72
+
73
+ function toolResult(text, isError = false) {
74
+ return { content: [{ type: 'text', text: String(text) }], ...(isError ? { isError: true } : {}) }
75
+ }
76
+
77
+ export async function callTool(name, args, vaultRoot, meta = {}) {
78
+ switch (name) {
79
+ case 'memory_recall': {
80
+ const query = String(args?.query || '').trim()
81
+ if (!query) return toolResult('(未提供检索词)')
82
+ const limit = Math.min(Math.max(Number(args?.limit) || 5, 1), 20)
83
+ const hits = await search(vaultRoot, query, { limit, minScore: 2 })
84
+ if (hits.length === 0) return toolResult(`记忆库中没有与「${query}」相关的内容。`)
85
+ const lines = hits.map((h, i) => {
86
+ const tags = h.tags.length ? ` [${h.tags.join(', ')}]` : ''
87
+ const snippet = String(h.summary || '').replace(/\s+/g, ' ').trim().slice(0, 130)
88
+ return `### ${i + 1}. ${h.title}${tags}\n路径:${h.path}\n${snippet}`
89
+ })
90
+ return toolResult(`从记忆核心检索到 ${hits.length} 条相关卡片:\n\n${lines.join('\n\n')}`)
91
+ }
92
+ case 'memory_capture': {
93
+ const text = String(args?.text || '')
94
+ const out = await runStandaloneCapture(vaultRoot, text, {
95
+ source: String(args?.source || meta.submittedBy || 'mcp'),
96
+ })
97
+ if (!out.ok && out.action === 'failed') return toolResult(`沉淀失败:${out.reason}`, true)
98
+ return toolResult(JSON.stringify(out))
99
+ }
100
+ case 'memory_audit_list': {
101
+ await ensureVault(vaultRoot)
102
+ const wanted = String(args?.status || 'pending').toLowerCase()
103
+ const STATUS = { pending: ['pending'], rejected: ['rejected'], approved: ['approved'], deleted: ['deleted'], all: ['pending', 'rejected'], queue: ['pending', 'rejected'] }
104
+ const status = STATUS[wanted] || ['pending']
105
+ // issue #20:默认上限只为控制上下文(不给 agent 灌 500 张卡),但**不能把截断后的条数
106
+ // 当总数报出去** —— 总数取匹配总数,截断时明确写出「还有 N 张未显示」。
107
+ const rawLimit = Number(args?.limit)
108
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), 500) : 20
109
+ const total = await countCards(vaultRoot, { status })
110
+ if (!total) return toolResult(`记忆库里没有 ${wanted} 状态的卡片(vault: ${vaultRoot})。`)
111
+ const cards = await listCards(vaultRoot, { status, limit })
112
+ const hidden = Math.max(0, total - cards.length)
113
+ const lines = cards.map((c, i) => `${i + 1}. [${c.status}] ${c.kind} · ${c.title}\n 路径:${c.path}`)
114
+ const head = hidden > 0
115
+ ? `待处理 ${total} 张,显示前 ${cards.length} 张(status=${wanted},vault: ${vaultRoot}):`
116
+ : `待处理 ${total} 张(status=${wanted},vault: ${vaultRoot}):`
117
+ const tail = hidden > 0 ? `\n\n… 还有 ${hidden} 张未显示(可加大 limit,最大 500)。` : ''
118
+ return toolResult(
119
+ `${head}\n\n${lines.join('\n')}${tail}\n\n` +
120
+ '(只读列表。批准/驳回请由用户在「审核中心」面板或运行 dsh-memory audit approve/reject 显式完成。)',
121
+ )
122
+ }
123
+ case 'memory_stats': {
124
+ await ensureVault(vaultRoot)
125
+ const ov = await overview(vaultRoot)
126
+ const s = await stats(vaultRoot)
127
+ return toolResult(JSON.stringify({ vaultDir: vaultRoot, total: ov.total, byKind: ov.byKind, tags: s.tags, week: s.week }, null, 2))
128
+ }
129
+ default:
130
+ throw new Error(`未知工具:${name}`)
131
+ }
132
+ }
133
+
134
+ export function startMcpServer({ vaultRoot = defaultVaultDir() } = {}) {
135
+ const rl = readline.createInterface({ input: process.stdin })
136
+ const write = (msg) => process.stdout.write(JSON.stringify(msg) + '\n')
137
+ let pendingCalls = 0
138
+ let closing = false
139
+ // MCP 客户端信息:initialize 时记录(agent 名),供 memory_capture 署名用
140
+ let clientInfo = {}
141
+ const maybeExit = () => { if (closing && pendingCalls === 0) process.exit(0) }
142
+
143
+ rl.on('line', (line) => {
144
+ const raw = line.trim()
145
+ if (!raw) return
146
+ let msg
147
+ try {
148
+ msg = JSON.parse(raw)
149
+ } catch {
150
+ write({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })
151
+ return
152
+ }
153
+ const { id, method, params } = msg
154
+ // notification(无 id)不回包
155
+ if (id === undefined || id === null) {
156
+ if (method === 'notifications/initialized' || method === 'notifications/cancelled') return
157
+ return
158
+ }
159
+ try {
160
+ if (method === 'initialize') {
161
+ const requested = String(params?.protocolVersion || '')
162
+ const negotiated = SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : DEFAULT_PROTOCOL_VERSION
163
+ clientInfo = params?.clientInfo || {}
164
+ write({
165
+ jsonrpc: '2.0',
166
+ id,
167
+ result: {
168
+ protocolVersion: negotiated,
169
+ capabilities: { tools: {} },
170
+ serverInfo: { name: 'memory-eternal', version: '1.0.0' },
171
+ },
172
+ })
173
+ return
174
+ }
175
+ if (method === 'ping') {
176
+ write({ jsonrpc: '2.0', id, result: {} })
177
+ return
178
+ }
179
+ if (method === 'tools/list') {
180
+ write({ jsonrpc: '2.0', id, result: { tools: MCP_TOOLS } })
181
+ return
182
+ }
183
+ if (method === 'tools/call') {
184
+ const name = params?.name
185
+ const args = params?.arguments ?? {}
186
+ const sub = clientInfo?.name ? `${clientInfo.name}${clientInfo.version ? '@' + clientInfo.version : ''}` : 'mcp'
187
+ pendingCalls++
188
+ callTool(name, args, vaultRoot, { submittedBy: sub })
189
+ .then((result) => write({ jsonrpc: '2.0', id, result }))
190
+ .catch((error) => write({ jsonrpc: '2.0', id, result: toolResult(String(error?.message || error), true) }))
191
+ .finally(() => { pendingCalls--; maybeExit() })
192
+ return
193
+ }
194
+ write({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } })
195
+ } catch (error) {
196
+ write({ jsonrpc: '2.0', id, error: { code: -32603, message: String(error?.message || error) } })
197
+ }
198
+ })
199
+
200
+ rl.on('close', () => { closing = true; maybeExit() })
201
+ process.stderr.write(`[memory-eternal] MCP server ready (vault: ${vaultRoot})\n`)
202
+ }
203
+
204
+ // 作为脚本直接运行(node lib/mcp.js / node bin/dsh-memory.mjs mcp 的转发目标)时自动启动。
205
+ if (process.argv[1] && /[\\/]mcp\.js$/.test(process.argv[1])) {
206
+ startMcpServer()
207
+ }
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
+ }