memory-eternal 0.9.6 → 0.9.8
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 +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.en.md +35 -6
- package/README.md +36 -7
- package/index.js +71 -19
- package/lib/capture-run.js +29 -3
- package/lib/capture.js +75 -13
- package/lib/client.js +55 -14
- package/lib/node-bin.js +99 -0
- package/lib/stall.js +30 -0
- package/lib/vault.js +76 -8
- package/lib/watchdog.js +65 -3
- package/lib/web.js +10 -3
- package/package.json +6 -4
- package/web/app.js +59 -18
package/lib/node-bin.js
ADDED
|
@@ -0,0 +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
|
+
}
|
package/lib/stall.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// 记忆核心 · 「轮次收尾事件是否真的没来」判定
|
|
2
|
+
//
|
|
3
|
+
// 旧判据:turnsStarted > turnsStopped && claimIdleMs > 15min && idleMs > 15min。两个毛病:
|
|
4
|
+
// 1) 两个计数**累积且永不归零**,而被取消/被拒的轮次不发 turn-stopping(子代理轮次同样不计)
|
|
5
|
+
// → 差额一旦出现就永久为真,判据退化成「只要空闲 15 分钟就报一次」(issue #4 的
|
|
6
|
+
// 「已开始 16 / 已收尾 12,且 15 分钟无任何沉淀活动」就是这么来的);
|
|
7
|
+
// 2) 真故障的特征恰好相反:轮次**持续开始**、只是没有收尾 —— 此时 claimed 一直新鲜,
|
|
8
|
+
// claimIdleMs 条件不满足 → 真故障反而不报。
|
|
9
|
+
//
|
|
10
|
+
// 现在改成滑动窗口:只看「窗口内开始了几个、收尾了几个」。
|
|
11
|
+
// 持续开始 + 零收尾 → 判停滞;用户离开(窗口内 0 开始)→ 不报。
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} input
|
|
15
|
+
* @param {number[]} input.claimedAt 轮次开始时间戳(ms)
|
|
16
|
+
* @param {number[]} input.stoppedAt 轮次收尾时间戳(ms)
|
|
17
|
+
* @param {number} [input.now] 当前时间
|
|
18
|
+
* @param {number} [input.windowMs] 观察窗口,默认 20 分钟
|
|
19
|
+
* @param {number} [input.minStarted] 窗口内至少开始几个轮次才判停滞,默认 3
|
|
20
|
+
* @returns {{alert:boolean, started:number, stopped:number, windowMs:number}}
|
|
21
|
+
*/
|
|
22
|
+
export function evaluateStall({ claimedAt = [], stoppedAt = [], now = Date.now(), windowMs = 20 * 60 * 1000, minStarted = 3 } = {}) {
|
|
23
|
+
const from = now - windowMs
|
|
24
|
+
const started = claimedAt.filter((t) => t >= from).length
|
|
25
|
+
const stopped = stoppedAt.filter((t) => t >= from).length
|
|
26
|
+
return { alert: started >= minStarted && stopped === 0, started, stopped, windowMs }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 时间戳数组的裁剪上限:只保留窗口相关的近邻,避免长跑无界增长。 */
|
|
30
|
+
export const MAX_STAMPS = 200
|
package/lib/vault.js
CHANGED
|
@@ -561,26 +561,94 @@ export async function graph(root) {
|
|
|
561
561
|
if (arr) arr.push(c.path)
|
|
562
562
|
else tagIndex.set(t, [c.path])
|
|
563
563
|
}
|
|
564
|
+
// 标签边:同标签「全连接」会爆炸 —— 593 卡时 2.4 万条边,占总 payload 的 99%,前端每帧都要遍历。
|
|
565
|
+
// 改为「按共享标签数取 Top-K」:语义仍是「靠共享标签相邻」,但边数降一个数量级。
|
|
566
|
+
// 对每张卡用标签倒排收集候选与其共享标签数,只保留权重最高的 K 个(双向去重后约 n*K/2 条)。
|
|
567
|
+
const TAG_TOPK = 6
|
|
568
|
+
const pairWeight = new Map() // `a\u0000b`(排序后)-> 已加边的共享标签数
|
|
564
569
|
for (const card of cards) {
|
|
570
|
+
const weights = new Map()
|
|
565
571
|
for (const tag of card.tags) {
|
|
566
572
|
for (const other of tagIndex.get(tag) || []) {
|
|
567
|
-
if (other
|
|
573
|
+
if (other === card.path) continue
|
|
574
|
+
weights.set(other, (weights.get(other) || 0) + 1)
|
|
568
575
|
}
|
|
569
576
|
}
|
|
577
|
+
// 候选不超过 K 时全连(小库/小标签簇),超过才截断 Top-K
|
|
578
|
+
const ranked = [...weights.entries()].sort((x, y) => y[1] - x[1])
|
|
579
|
+
const top = weights.size <= TAG_TOPK ? ranked : ranked.slice(0, TAG_TOPK)
|
|
580
|
+
for (const [other, w] of top) {
|
|
581
|
+
const [x, y] = [card.path, other].sort()
|
|
582
|
+
const key = `${x}\u0000${y}`
|
|
583
|
+
if ((pairWeight.get(key) || 0) >= w) continue // 已按更高权重加过这条边
|
|
584
|
+
pairWeight.set(key, w)
|
|
585
|
+
const xc = byPath.get(x)
|
|
586
|
+
const yc = byPath.get(y)
|
|
587
|
+
const shared = xc && yc ? xc.tags.find((t) => yc.tags.includes(t)) : ''
|
|
588
|
+
addEdge(x, y, shared ? `tag:${shared}` : 'tag')
|
|
589
|
+
}
|
|
570
590
|
}
|
|
571
591
|
|
|
572
592
|
// 相似度关联:bigram 集合只算一次(原先每对都重新分词两段正文=指数级白烧),
|
|
573
593
|
// 再用数学上界剪枝(J ≤ min/max)跳过长度比过小的配对。
|
|
574
594
|
const SIM_THRESHOLD = 0.42
|
|
575
595
|
const bigramsByPath = new Map()
|
|
576
|
-
|
|
596
|
+
// df = 有多少张卡含这个 bigram。常见 bigram(df 很大)几乎均匀铺满全库,
|
|
597
|
+
// 撑不起 0.42 的 Jaccard;只用稀有 bigram 建倒排即可生成候选,避免 5.8 万对全量比对。
|
|
598
|
+
const DF_CAP = 24
|
|
599
|
+
const RARE_CAP = 120
|
|
600
|
+
const gramDf = new Map()
|
|
601
|
+
for (const c of cards) {
|
|
602
|
+
const s = bigramSet(bodyMap.get(c.path) || '')
|
|
603
|
+
bigramsByPath.set(c.path, s)
|
|
604
|
+
for (const g of s) gramDf.set(g, (gramDf.get(g) || 0) + 1)
|
|
605
|
+
}
|
|
606
|
+
const rareIndex = new Map()
|
|
607
|
+
const rareByPath = new Map()
|
|
608
|
+
for (const c of cards) {
|
|
609
|
+
const rare = []
|
|
610
|
+
for (const g of bigramsByPath.get(c.path)) if ((gramDf.get(g) || 0) <= DF_CAP) rare.push(g)
|
|
611
|
+
// 只取「最稀有」的 RARE_CAP 个参与候选生成:近重复卡一定共享大量稀有 gram,
|
|
612
|
+
// 而这一步是冷启动的真正热点(每张卡 × 每个稀有 gram 的倒排表)。
|
|
613
|
+
rare.sort((x, y) => (gramDf.get(x) || 0) - (gramDf.get(y) || 0))
|
|
614
|
+
rareByPath.set(c.path, rare.length <= RARE_CAP ? rare : rare.slice(0, RARE_CAP))
|
|
615
|
+
for (const g of rare) {
|
|
616
|
+
const arr = rareIndex.get(g)
|
|
617
|
+
if (arr) arr.push(c.path)
|
|
618
|
+
else rareIndex.set(g, [c.path])
|
|
619
|
+
}
|
|
620
|
+
}
|
|
577
621
|
const similarCount = {}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
622
|
+
// 标签集合预先建成 Set:原先每个配对都做 tags.some(includes) 是 O(标签数²),配对 5.8 万次很可观。
|
|
623
|
+
const tagSetByPath = new Map(cards.map((c) => [c.path, new Set(c.tags)]))
|
|
624
|
+
// 候选生成:按共享稀有 bigram 数降序,只对候选做精确 Jaccard。
|
|
625
|
+
// 每张卡最多保留 1 条 similar 边(与旧行为一致)。
|
|
626
|
+
const sharedRare = new Map()
|
|
627
|
+
for (const card of cards) {
|
|
628
|
+
for (const gram of rareByPath.get(card.path)) {
|
|
629
|
+
for (const other of rareIndex.get(gram) || []) {
|
|
630
|
+
if (other !== card.path) sharedRare.set(other, (sharedRare.get(other) || 0) + 1)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (!sharedRare.size) continue
|
|
634
|
+
// 候选上限:共享稀有 gram 最多的 40 张(近重复卡一定排在最前面,召回不受影响)
|
|
635
|
+
const ranked = [...sharedRare.entries()].sort((x, y) => y[1] - x[1])
|
|
636
|
+
const cands = ranked.length <= 40 ? ranked : ranked.slice(0, 40)
|
|
637
|
+
sharedRare.clear()
|
|
638
|
+
const A = bigramsByPath.get(card.path)
|
|
639
|
+
for (const [other, shared] of cands) {
|
|
640
|
+
if ((similarCount[card.path] || 0) >= 1) break
|
|
641
|
+
if ((similarCount[other] || 0) >= 1) continue
|
|
642
|
+
const b = byPath.get(other)
|
|
643
|
+
if (!b) continue
|
|
644
|
+
if (b.kind !== card.kind && !card.tags.some((t) => tagSetByPath.get(other).has(t))) continue
|
|
645
|
+
const B = bigramsByPath.get(other)
|
|
646
|
+
const lo = Math.min(A.size, B.size)
|
|
647
|
+
const hi = Math.max(A.size, B.size)
|
|
648
|
+
if (!hi || lo / hi < SIM_THRESHOLD) continue // 无损上界:J ≤ min/max
|
|
649
|
+
const sim = jaccardSets(A, B, SIM_THRESHOLD)
|
|
650
|
+
if (sim >= SIM_THRESHOLD) { addEdge(card.path, other, 'similar'); addDegree(card.path); addDegree(other); similarCount[card.path] = 1; similarCount[other] = 1; break }
|
|
651
|
+
}
|
|
584
652
|
}
|
|
585
653
|
const value = { nodes, edges }
|
|
586
654
|
graphCache.set(root, { fp, value, at: Date.now() })
|
package/lib/watchdog.js
CHANGED
|
@@ -15,14 +15,64 @@
|
|
|
15
15
|
|
|
16
16
|
import http from 'node:http'
|
|
17
17
|
import net from 'node:net'
|
|
18
|
+
import fs from 'node:fs'
|
|
19
|
+
import os from 'node:os'
|
|
18
20
|
import { spawn } from 'node:child_process'
|
|
19
21
|
import path from 'node:path'
|
|
20
22
|
import { fileURLToPath } from 'node:url'
|
|
21
23
|
import process from 'node:process'
|
|
24
|
+
import { nodeBinary, childEnv } from './node-bin.js'
|
|
22
25
|
|
|
23
26
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
24
27
|
const WEB_JS = path.join(__dirname, 'web.js')
|
|
25
28
|
const API_PREFIX = '/memory-eternal/api'
|
|
29
|
+
// -- 单例锁 -------------------------------------------------------------------
|
|
30
|
+
// 每个 DSH 启动都会 spawn 一个 detached watchdog,而旧代码没有任何存活检查 ——
|
|
31
|
+
// 多次重启就堆积多个 watchdog 常驻轮询同一端口(dsh-memory-eternal#6)。
|
|
32
|
+
// 这里用一个 pid 锁文件:同端口已有活着的 watchdog 就让它直接退出。
|
|
33
|
+
const LOCK_NAME = 'memory-eternal-watchdog.json'
|
|
34
|
+
|
|
35
|
+
/** 锁文件路径($DSH_HOME 下,与共享配置同处)。 */
|
|
36
|
+
export function watchdogLockPath(env = process.env) {
|
|
37
|
+
return path.join(env.DSH_HOME || path.join(os.homedir(), '.dsh'), LOCK_NAME)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 进程是否存活(Windows 下 process.kill(pid,0) 同样可用;EPERM 说明进程在但无权限)。 */
|
|
41
|
+
export function isPidAlive(pid) {
|
|
42
|
+
const n = Number(pid)
|
|
43
|
+
if (!n || n <= 0) return false
|
|
44
|
+
if (n === process.pid) return true
|
|
45
|
+
try { process.kill(n, 0); return true } catch (error) { return error?.code === 'EPERM' }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 读取锁文件;损坏或不存在返回 null。 */
|
|
49
|
+
export function readWatchdogLock(env = process.env) {
|
|
50
|
+
try { return JSON.parse(fs.readFileSync(watchdogLockPath(env), 'utf8')) } catch { return null }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 尝试取得看门狗锁。
|
|
55
|
+
* @returns {{acquired:boolean, file:string, previous:object|null}} acquired=false 表示「同端口已有活着的看门狗」,调用方应直接退出。
|
|
56
|
+
*/
|
|
57
|
+
export function acquireWatchdogLock({ env = process.env, port, vault = '', pid = process.pid } = {}) {
|
|
58
|
+
const file = watchdogLockPath(env)
|
|
59
|
+
const prev = readWatchdogLock(env)
|
|
60
|
+
const samePortOwnerAlive = prev && prev.pid !== pid && Number(prev.port) === Number(port) && isPidAlive(prev.pid)
|
|
61
|
+
if (samePortOwnerAlive) return { acquired: false, file, previous: prev }
|
|
62
|
+
const stale = prev && !isPidAlive(prev.pid) ? prev : null
|
|
63
|
+
try {
|
|
64
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
65
|
+
fs.writeFileSync(file, JSON.stringify({ pid, port: Number(port), vault, startedAt: new Date().toISOString() }))
|
|
66
|
+
} catch { /* 写不了锁不阻塞保活 */ }
|
|
67
|
+
return { acquired: true, file, previous: stale }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 释放锁(只清自己的,避免误删别人刚写的)。 */
|
|
71
|
+
export function releaseWatchdogLock({ env = process.env, pid = process.pid } = {}) {
|
|
72
|
+
const cur = readWatchdogLock(env)
|
|
73
|
+
if (!cur || cur.pid !== pid) return false
|
|
74
|
+
try { fs.unlinkSync(watchdogLockPath(env)); return true } catch { return false }
|
|
75
|
+
}
|
|
26
76
|
|
|
27
77
|
const ts = () => new Date().toISOString().slice(11, 19) // HH:MM:SS
|
|
28
78
|
const log = (...args) => process.stderr.write(`[${ts()}] [watchdog] ${args.join(' ')}\n`)
|
|
@@ -93,13 +143,17 @@ export function startWatchdog({ port = 7999, vaultRoot, interval = 5000, maxRest
|
|
|
93
143
|
const targetPort = await findFreePort(port)
|
|
94
144
|
const args = [WEB_JS, '--port', String(targetPort)]
|
|
95
145
|
if (vaultRoot) args.push('--vault', vaultRoot)
|
|
96
|
-
log(`spawn web:
|
|
97
|
-
const c = spawn(
|
|
146
|
+
log(`spawn web: ${nodeBinary()} ${args.join(' ')}`)
|
|
147
|
+
const c = spawn(nodeBinary(), args, {
|
|
98
148
|
detached: true,
|
|
99
149
|
stdio: 'ignore',
|
|
100
|
-
env: {
|
|
150
|
+
env: childEnv({ MEMORY_VAULT_DIR: vaultRoot || process.env.MEMORY_VAULT_DIR || '' }),
|
|
101
151
|
windowsHide: true,
|
|
102
152
|
})
|
|
153
|
+
// spawn 失败默认静默,显式记录(Electron 宿主下曾因此完全无迹可查)
|
|
154
|
+
c.on('error', (error) => {
|
|
155
|
+
log(`web spawn failed (${nodeBinary()}): ${error?.message || error}`)
|
|
156
|
+
})
|
|
103
157
|
c.on('exit', (code, sig) => {
|
|
104
158
|
spawned.delete(c)
|
|
105
159
|
if (!stopped && code !== 0) log(`web exited code=${code} sig=${sig}`)
|
|
@@ -221,5 +275,13 @@ if (process.argv[1] && /[\\/]watchdog\.js$/.test(process.argv[1])) {
|
|
|
221
275
|
const maxRestart = Number(argOf('--max-restart')) || 10
|
|
222
276
|
const vaultRoot = argOf('--vault') || undefined
|
|
223
277
|
const autoStart = !argv.includes('--no-restart')
|
|
278
|
+
const lock = acquireWatchdogLock({ port, vault: vaultRoot || '' })
|
|
279
|
+
if (!lock.acquired) {
|
|
280
|
+
log('同端口已有看门狗在跑(pid ' + (lock.previous && lock.previous.pid) + ',启动于 ' + (lock.previous && lock.previous.startedAt) + ')→ 本次启动退出,避免进程堆积')
|
|
281
|
+
process.exit(0)
|
|
282
|
+
}
|
|
283
|
+
if (lock.previous) log('清理陈旧看门狗记录(pid ' + lock.previous.pid + ' 已不存在)')
|
|
284
|
+
process.on('exit', () => releaseWatchdogLock())
|
|
224
285
|
startWatchdog({ port, interval, maxRestart, vaultRoot, autoStart })
|
|
286
|
+
log('看门狗已启动(pid ' + process.pid + ',端口 ' + port + ')')
|
|
225
287
|
}
|
package/lib/web.js
CHANGED
|
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
17
17
|
import { createApi, API_PREFIX, encodeBody } from './api.js'
|
|
18
18
|
import { defaultVaultDir, configFilePath } from './capture-run.js'
|
|
19
19
|
import { readCaptureLog, deriveHealth } from './capture-log.js'
|
|
20
|
+
import { nodeBinary, childEnv } from './node-bin.js'
|
|
20
21
|
|
|
21
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
22
23
|
const WEB_ROOT = path.join(__dirname, '..', 'web')
|
|
@@ -112,13 +113,19 @@ export async function ensureWebServer({ port = DEFAULT_WEB_PORT, vaultRoot = def
|
|
|
112
113
|
}
|
|
113
114
|
if (found) return found
|
|
114
115
|
|
|
115
|
-
// detached spawn:独立进程,父进程退出不影响(常驻);stdio ignore
|
|
116
|
+
// detached spawn:独立进程,父进程退出不影响(常驻);stdio ignore 防阻塞。
|
|
117
|
+
// 注意用 nodeBinary() 而不是 process.execPath:在 Electron 宿主里后者是
|
|
118
|
+
// Electron 主程序,spawn 出来不会执行 web.js(见 lib/node-bin.js)。
|
|
116
119
|
const { spawn } = await import('node:child_process')
|
|
117
120
|
const child = spawn(
|
|
118
|
-
|
|
121
|
+
nodeBinary(),
|
|
119
122
|
[path.join(__dirname, 'web.js'), '--port', String(target), '--vault', vaultRoot],
|
|
120
|
-
{ detached: true, stdio: 'ignore', env: { ...
|
|
123
|
+
{ detached: true, stdio: 'ignore', env: childEnv({ ...spawnEnv, MEMORY_VAULT_DIR: vaultRoot }), windowsHide: true },
|
|
121
124
|
)
|
|
125
|
+
// spawn 失败(ENOENT/EACCES/...)默认是静默的,这里显式暴露出来
|
|
126
|
+
child.on('error', (error) => {
|
|
127
|
+
process.stderr.write(`[memory-eternal] web server spawn failed (${nodeBinary()}): ${error?.message || error}\n`)
|
|
128
|
+
})
|
|
122
129
|
child.unref()
|
|
123
130
|
|
|
124
131
|
const deadline = Date.now() + totalTimeoutMs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-eternal",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
4
4
|
"description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"build": "node build.mjs",
|
|
61
61
|
"build:client": "node build.mjs",
|
|
62
62
|
"prepublishOnly": "node build.mjs",
|
|
63
|
-
"test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/api.test.mjs && node tests/settings-compat.test.mjs && node tests/ssr.test.mjs"
|
|
63
|
+
"test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/api.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/ssr.test.mjs"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
@@ -89,7 +89,8 @@
|
|
|
89
89
|
},
|
|
90
90
|
"marketplace": {
|
|
91
91
|
"profiles": [
|
|
92
|
-
"web"
|
|
92
|
+
"web",
|
|
93
|
+
"desktop"
|
|
93
94
|
],
|
|
94
95
|
"requiresBuildApproval": false,
|
|
95
96
|
"requiresRestart": true,
|
|
@@ -122,7 +123,8 @@
|
|
|
122
123
|
},
|
|
123
124
|
"node": ">=22",
|
|
124
125
|
"profiles": [
|
|
125
|
-
"web"
|
|
126
|
+
"web",
|
|
127
|
+
"desktop"
|
|
126
128
|
]
|
|
127
129
|
}
|
|
128
130
|
}
|