memory-eternal 0.10.6 → 0.10.7

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.
@@ -1,72 +1,72 @@
1
- // 宿主心跳:让「独立 Web 端」知道本机此刻有没有活着的 DSH 宿主(issue #21 的宿主无关写路径)。
2
- //
3
- // 为什么需要它:配置只有一个权威写入者 —— DSH 宿主(插件用 settings.update 应用,再由
4
- // index.js 的 syncConfigFile 写共享配置文件)。独立 Web 进程里没有 settings 服务,只能把改动
5
- // 写进「待应用」文件、等宿主来 drain。但**非 DSH 宿主**(Codex / Claude Code / Cursor,
6
- // 或只跑 `dsh-memory serve` 的用户)根本没有宿主来 drain:改动会永远停在 pending 文件里,
7
- // 保存等于没保存 —— 而界面上那句「下次启动生效」在那类环境下是错的(不会有"下次启动 DSH")。
8
- //
9
- // 判据:宿主活着时周期性刷新 `<共享配置>.host.json`(pid + 版本 + 时间戳)。
10
- // 独立端保存后若「没人消费 pending」且「心跳不新鲜」,即判定本机没有宿主 → 直接原子写共享配置。
11
- // 反之(宿主活着)**绝不**绕过宿主写文件:那会与宿主的 volatile 配置分叉,下一次
12
- // syncConfigFile() 就会把改动盖回去。
13
- import fs from 'node:fs'
14
- import { configFilePath } from './capture-run.js'
15
- import { writeFileAtomicSync } from './config-sync.js'
16
-
17
- /** 心跳文件路径(与共享配置同目录:`xxx.json` → `xxx.host.json`)。 */
18
- export function hostMarkerPath(env = process.env) {
19
- return String(configFilePath(env)).replace(/\.json$/i, '.host.json')
20
- }
21
-
22
- /** 进程存活判定(EPERM = 存在但无权发信号,仍算活着)。 */
23
- export function isProcessAlive(pid) {
24
- const n = Number(pid)
25
- if (!Number.isFinite(n) || n <= 0) return false
26
- try {
27
- process.kill(n, 0)
28
- return true
29
- } catch (error) {
30
- return Boolean(error && error.code === 'EPERM')
31
- }
32
- }
33
-
34
- /** 写/刷新心跳(原子写:这是跨进程判据,读方可能正好在写)。 */
35
- export function writeHostMarker(env = process.env, info = {}, now = Date.now()) {
36
- const data = { pid: process.pid, at: now, ...info }
37
- writeFileAtomicSync(hostMarkerPath(env), JSON.stringify(data, null, 2))
38
- return data
39
- }
40
-
41
- /** 读心跳;文件不存在 / 内容损坏都返回 null。 */
42
- export function readHostMarker(env = process.env) {
43
- try {
44
- const data = JSON.parse(fs.readFileSync(hostMarkerPath(env), 'utf8'))
45
- if (!data || typeof data !== 'object') return null
46
- return {
47
- pid: Number(data.pid) || 0,
48
- at: Number(data.at) || 0,
49
- version: String(data.version || ''),
50
- }
51
- } catch { return null }
52
- }
53
-
54
- /** 删除心跳(插件 dispose 时尽力而为;宿主被硬杀时靠新鲜度判定兜底)。 */
55
- export function clearHostMarker(env = process.env) {
56
- try { fs.unlinkSync(hostMarkerPath(env)); return true } catch { return false }
57
- }
58
-
59
- /**
60
- * 本机此刻有没有活着的 DSH 宿主:心跳新鲜(默认 45s,宿主每 15s 刷一次)**且**那个 pid 还在。
61
- * 两个条件都要,避免「宿主崩了但心跳文件还在」被误判成有宿主。
62
- *
63
- * @param {Record<string,string>} [env]
64
- * @param {{maxAgeMs?:number, now?:number, isAlive?:(pid:number)=>boolean}} [opts]
65
- * @returns {boolean}
66
- */
67
- export function hostAlive(env = process.env, { maxAgeMs = 45000, now = Date.now(), isAlive = isProcessAlive } = {}) {
68
- const marker = readHostMarker(env)
69
- if (!marker || !marker.pid || !marker.at) return false
70
- if (now - marker.at > Math.max(0, Number(maxAgeMs) || 0)) return false
71
- return isAlive(marker.pid)
72
- }
1
+ // 宿主心跳:让「独立 Web 端」知道本机此刻有没有活着的 DSH 宿主(issue #21 的宿主无关写路径)。
2
+ //
3
+ // 为什么需要它:配置只有一个权威写入者 —— DSH 宿主(插件用 settings.update 应用,再由
4
+ // index.js 的 syncConfigFile 写共享配置文件)。独立 Web 进程里没有 settings 服务,只能把改动
5
+ // 写进「待应用」文件、等宿主来 drain。但**非 DSH 宿主**(Codex / Claude Code / Cursor,
6
+ // 或只跑 `dsh-memory serve` 的用户)根本没有宿主来 drain:改动会永远停在 pending 文件里,
7
+ // 保存等于没保存 —— 而界面上那句「下次启动生效」在那类环境下是错的(不会有"下次启动 DSH")。
8
+ //
9
+ // 判据:宿主活着时周期性刷新 `<共享配置>.host.json`(pid + 版本 + 时间戳)。
10
+ // 独立端保存后若「没人消费 pending」且「心跳不新鲜」,即判定本机没有宿主 → 直接原子写共享配置。
11
+ // 反之(宿主活着)**绝不**绕过宿主写文件:那会与宿主的 volatile 配置分叉,下一次
12
+ // syncConfigFile() 就会把改动盖回去。
13
+ import fs from 'node:fs'
14
+ import { configFilePath } from './capture-run.js'
15
+ import { writeFileAtomicSync } from './config-sync.js'
16
+
17
+ /** 心跳文件路径(与共享配置同目录:`xxx.json` → `xxx.host.json`)。 */
18
+ export function hostMarkerPath(env = process.env) {
19
+ return String(configFilePath(env)).replace(/\.json$/i, '.host.json')
20
+ }
21
+
22
+ /** 进程存活判定(EPERM = 存在但无权发信号,仍算活着)。 */
23
+ export function isProcessAlive(pid) {
24
+ const n = Number(pid)
25
+ if (!Number.isFinite(n) || n <= 0) return false
26
+ try {
27
+ process.kill(n, 0)
28
+ return true
29
+ } catch (error) {
30
+ return Boolean(error && error.code === 'EPERM')
31
+ }
32
+ }
33
+
34
+ /** 写/刷新心跳(原子写:这是跨进程判据,读方可能正好在写)。 */
35
+ export function writeHostMarker(env = process.env, info = {}, now = Date.now()) {
36
+ const data = { pid: process.pid, at: now, ...info }
37
+ writeFileAtomicSync(hostMarkerPath(env), JSON.stringify(data, null, 2))
38
+ return data
39
+ }
40
+
41
+ /** 读心跳;文件不存在 / 内容损坏都返回 null。 */
42
+ export function readHostMarker(env = process.env) {
43
+ try {
44
+ const data = JSON.parse(fs.readFileSync(hostMarkerPath(env), 'utf8'))
45
+ if (!data || typeof data !== 'object') return null
46
+ return {
47
+ pid: Number(data.pid) || 0,
48
+ at: Number(data.at) || 0,
49
+ version: String(data.version || ''),
50
+ }
51
+ } catch { return null }
52
+ }
53
+
54
+ /** 删除心跳(插件 dispose 时尽力而为;宿主被硬杀时靠新鲜度判定兜底)。 */
55
+ export function clearHostMarker(env = process.env) {
56
+ try { fs.unlinkSync(hostMarkerPath(env)); return true } catch { return false }
57
+ }
58
+
59
+ /**
60
+ * 本机此刻有没有活着的 DSH 宿主:心跳新鲜(默认 45s,宿主每 15s 刷一次)**且**那个 pid 还在。
61
+ * 两个条件都要,避免「宿主崩了但心跳文件还在」被误判成有宿主。
62
+ *
63
+ * @param {Record<string,string>} [env]
64
+ * @param {{maxAgeMs?:number, now?:number, isAlive?:(pid:number)=>boolean}} [opts]
65
+ * @returns {boolean}
66
+ */
67
+ export function hostAlive(env = process.env, { maxAgeMs = 45000, now = Date.now(), isAlive = isProcessAlive } = {}) {
68
+ const marker = readHostMarker(env)
69
+ if (!marker || !marker.pid || !marker.at) return false
70
+ if (now - marker.at > Math.max(0, Number(maxAgeMs) || 0)) return false
71
+ return isAlive(marker.pid)
72
+ }
package/lib/llm-openai.js CHANGED
@@ -1,84 +1,84 @@
1
- // 记忆核心 · OpenAI 兼容 LLM 适配器(独立进程用)。
2
- //
3
- // 在没有 DSH llm 服务的环境(MCP server / CLI / web / hooks)里,为
4
- // lib/capture.js 的 summarizeTurn 提供同形状的 llm 对象:
5
- // - listProviders() / listModels() → 供 resolveRoute 解析路由
6
- // - stream(options) → async iterable,yield 纯文本 chunk(由 capture.js
7
- // fallback BlockAssembler 拼装)
8
- //
9
- // 配置(环境变量,或显式传参覆盖):
10
- // MEMORY_LLM_BASE_URL OpenAI 兼容端点,如 https://api.deepseek.com、
11
- // http://127.0.0.1:11434/v1(Ollama)等
12
- // MEMORY_LLM_KEY API key(本地端点可留空)
13
- // MEMORY_LLM_MODEL 模型名,如 deepseek-chat
14
- // 未配置时 createLlmFromEnv() 返回 null:调用方应降级(recall 不受影响,
15
- // capture 退化为存原文卡)。
16
-
17
- function flatContent(content) {
18
- if (typeof content === 'string') return content
19
- if (Array.isArray(content)) {
20
- return content
21
- .map((c) => (typeof c === 'string' ? c : c?.text ?? ''))
22
- .filter(Boolean)
23
- .join('\n')
24
- }
25
- return String(content ?? '')
26
- }
27
-
28
- export function createOpenAiLlm({ baseUrl, apiKey = '', model, purpose = 'memory-capture' } = {}) {
29
- if (!baseUrl || !model) return null
30
- const endpoint = baseUrl.replace(/\/+$/, '') + '/chat/completions'
31
-
32
- return {
33
- purpose,
34
-
35
- listProviders() {
36
- return [{ id: 'openai-compat' }]
37
- },
38
-
39
- async listModels() {
40
- return [{ id: model }]
41
- },
42
-
43
- /**
44
- * 非流式调用,整体作为单个 chunk yield(capture 的蒸馏一次性输出 JSON,
45
- * 无需逐 token 流式)。signal 超时/中断会向上抛出。
46
- */
47
- async *stream(options = {}) {
48
- const messages = []
49
- if (options.system) messages.push({ role: 'system', content: options.system })
50
- for (const m of options.messages ?? []) {
51
- messages.push({ role: m?.role ?? 'user', content: flatContent(m?.content) })
52
- }
53
- const body = {
54
- model: options.model || model,
55
- messages,
56
- max_tokens: options.maxTokens ?? 900,
57
- stream: false,
58
- }
59
- const headers = { 'Content-Type': 'application/json' }
60
- if (apiKey) headers.Authorization = `Bearer ${apiKey}`
61
- const res = await fetch(endpoint, {
62
- method: 'POST',
63
- headers,
64
- body: JSON.stringify(body),
65
- signal: options.signal ?? undefined,
66
- })
67
- if (!res.ok) {
68
- const detail = await res.text().catch(() => '')
69
- throw new Error(`[memory-eternal] LLM ${res.status}: ${detail.slice(0, 300)}`)
70
- }
71
- const data = await res.json()
72
- const text = data?.choices?.[0]?.message?.content ?? ''
73
- if (text) yield text
74
- },
75
- }
76
- }
77
-
78
- export function createLlmFromEnv(env = process.env) {
79
- const baseUrl = env.MEMORY_LLM_BASE_URL || env.OPENAI_BASE_URL || ''
80
- const apiKey = env.MEMORY_LLM_KEY || env.OPENAI_API_KEY || ''
81
- const model = env.MEMORY_LLM_MODEL || ''
82
- if (!baseUrl || !model) return null
83
- return createOpenAiLlm({ baseUrl, apiKey, model })
84
- }
1
+ // 记忆核心 · OpenAI 兼容 LLM 适配器(独立进程用)。
2
+ //
3
+ // 在没有 DSH llm 服务的环境(MCP server / CLI / web / hooks)里,为
4
+ // lib/capture.js 的 summarizeTurn 提供同形状的 llm 对象:
5
+ // - listProviders() / listModels() → 供 resolveRoute 解析路由
6
+ // - stream(options) → async iterable,yield 纯文本 chunk(由 capture.js
7
+ // fallback BlockAssembler 拼装)
8
+ //
9
+ // 配置(环境变量,或显式传参覆盖):
10
+ // MEMORY_LLM_BASE_URL OpenAI 兼容端点,如 https://api.deepseek.com、
11
+ // http://127.0.0.1:11434/v1(Ollama)等
12
+ // MEMORY_LLM_KEY API key(本地端点可留空)
13
+ // MEMORY_LLM_MODEL 模型名,如 deepseek-chat
14
+ // 未配置时 createLlmFromEnv() 返回 null:调用方应降级(recall 不受影响,
15
+ // capture 退化为存原文卡)。
16
+
17
+ function flatContent(content) {
18
+ if (typeof content === 'string') return content
19
+ if (Array.isArray(content)) {
20
+ return content
21
+ .map((c) => (typeof c === 'string' ? c : c?.text ?? ''))
22
+ .filter(Boolean)
23
+ .join('\n')
24
+ }
25
+ return String(content ?? '')
26
+ }
27
+
28
+ export function createOpenAiLlm({ baseUrl, apiKey = '', model, purpose = 'memory-capture' } = {}) {
29
+ if (!baseUrl || !model) return null
30
+ const endpoint = baseUrl.replace(/\/+$/, '') + '/chat/completions'
31
+
32
+ return {
33
+ purpose,
34
+
35
+ listProviders() {
36
+ return [{ id: 'openai-compat' }]
37
+ },
38
+
39
+ async listModels() {
40
+ return [{ id: model }]
41
+ },
42
+
43
+ /**
44
+ * 非流式调用,整体作为单个 chunk yield(capture 的蒸馏一次性输出 JSON,
45
+ * 无需逐 token 流式)。signal 超时/中断会向上抛出。
46
+ */
47
+ async *stream(options = {}) {
48
+ const messages = []
49
+ if (options.system) messages.push({ role: 'system', content: options.system })
50
+ for (const m of options.messages ?? []) {
51
+ messages.push({ role: m?.role ?? 'user', content: flatContent(m?.content) })
52
+ }
53
+ const body = {
54
+ model: options.model || model,
55
+ messages,
56
+ max_tokens: options.maxTokens ?? 900,
57
+ stream: false,
58
+ }
59
+ const headers = { 'Content-Type': 'application/json' }
60
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`
61
+ const res = await fetch(endpoint, {
62
+ method: 'POST',
63
+ headers,
64
+ body: JSON.stringify(body),
65
+ signal: options.signal ?? undefined,
66
+ })
67
+ if (!res.ok) {
68
+ const detail = await res.text().catch(() => '')
69
+ throw new Error(`[memory-eternal] LLM ${res.status}: ${detail.slice(0, 300)}`)
70
+ }
71
+ const data = await res.json()
72
+ const text = data?.choices?.[0]?.message?.content ?? ''
73
+ if (text) yield text
74
+ },
75
+ }
76
+ }
77
+
78
+ export function createLlmFromEnv(env = process.env) {
79
+ const baseUrl = env.MEMORY_LLM_BASE_URL || env.OPENAI_BASE_URL || ''
80
+ const apiKey = env.MEMORY_LLM_KEY || env.OPENAI_API_KEY || ''
81
+ const model = env.MEMORY_LLM_MODEL || ''
82
+ if (!baseUrl || !model) return null
83
+ return createOpenAiLlm({ baseUrl, apiKey, model })
84
+ }
package/lib/mcp.js CHANGED
@@ -25,7 +25,9 @@ export const MCP_TOOLS = [
25
25
  name: 'memory_recall',
26
26
  description:
27
27
  '从本地记忆核心(Markdown 知识库)检索相关知识卡。需要项目背景、历史决策、之前讨论过的方案、' +
28
- '或领域知识时调用;返回最相关的卡片摘要。用 query 描述要找的内容,支持中文整词与字符片段检索。',
28
+ '或领域知识时调用;返回最相关的卡片摘要。用 query 描述要找的内容,支持中文整词与字符片段检索。' +
29
+ '需要 API key / token / 令牌等**凭证**时也先查这里(查「密钥 目录」):记忆里存的是目录' +
30
+ '(名字、存放位置、取用命令),**值不在记忆库里**,请按卡片给的本机命令取用,不要直接读凭据文件。',
29
31
  inputSchema: {
30
32
  type: 'object',
31
33
  properties: {
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
+ }