memory-eternal 0.10.4 → 0.10.6

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.
@@ -98,6 +98,62 @@ export function clearPendingConfig(env = process.env) {
98
98
  try { fs.unlinkSync(pendingConfigPath(env)); return true } catch { return false }
99
99
  }
100
100
 
101
+ /**
102
+ * 直接把 patch 合并进**共享配置文件**(宿主无关路径:本机没有 DSH 宿主时用)。
103
+ *
104
+ * 只在「确认没有活着的 DSH 宿主」时调用(见 lib/host-heartbeat.js):宿主活着时共享文件是
105
+ * 它的派生镜像,绕过它直写会与 volatile 配置分叉,下一次 syncConfigFile() 就会盖回来。
106
+ * 合并(而不是整体替换)是有意为之:老版本写下的键、以及其它工具手工加的键都要留住。
107
+ *
108
+ * @returns {object} 合并后的完整配置
109
+ */
110
+ export function applySharedConfigDirect(env, patch) {
111
+ const file = configFilePath(env)
112
+ let current = {}
113
+ try {
114
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
115
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) current = parsed
116
+ } catch { current = {} }
117
+ const next = { ...current, ...(patch && typeof patch === 'object' ? patch : {}) }
118
+ writeFileAtomicSync(file, JSON.stringify(next, null, 2))
119
+ return next
120
+ }
121
+
122
+ // 宿主守卫类报错的特征串 —— 同一族缺陷在不同宿主/不同服务上的措辞不同(issue #21):
123
+ // · Cordis HMR 宿主(官方桌面版):runExclusive 拒绝嵌套事务
124
+ // · dsh-tui 一类宿主:能力守卫在 settings.update 内部的 describe() 上抛
125
+ const HOST_GUARD_PATTERNS = [
126
+ /HMR transactions cannot be nested/i,
127
+ /is unavailable from a plugin activation/i,
128
+ ]
129
+
130
+ /** 这个报错是不是「宿主不允许插件从自己的回调上下文写配置」。 */
131
+ export function isHostGuardError(message) {
132
+ const s = String(message || '')
133
+ return HOST_GUARD_PATTERNS.some((re) => re.test(s))
134
+ }
135
+
136
+ /**
137
+ * 把「应用失败」翻译成给用户看的话(诊断信息 / 自动沉淀日志 / 独立页提示共用)。
138
+ *
139
+ * host-guard 类**不是配置内容的问题**,所以说清「去哪改」比重复报错有用得多:
140
+ * 宿主自己的设置页走的写路径由宿主实现,不受插件上下文限制。
141
+ *
142
+ * @returns {{kind:'host-guard'|'other', hint:string}}
143
+ */
144
+ export function describeApplyFailure(message) {
145
+ const raw = String(message || '')
146
+ if (!raw) return { kind: 'other', hint: '' }
147
+ if (isHostGuardError(raw)) {
148
+ return {
149
+ kind: 'host-guard',
150
+ hint: '此宿主不允许插件从自己的回调上下文里写配置(原报错:' + raw + ')—— 与配置内容无关。请到「DSH 设置 → 记忆」里改同一项(那条写路径由宿主自己实现,不受此限制),或直接编辑 profile 的配置补丁层。',
151
+ }
152
+ }
153
+ return { kind: 'other', hint: '原报错:' + raw + ';可到「DSH 设置 → 记忆」重试,或查看「反馈异常」里的诊断信息。' }
154
+ }
155
+
156
+
101
157
  /**
102
158
  * 监听待应用文件的变化(毫秒级触发),用于把「独立页保存 → DSH 应用」从 5 秒轮询
103
159
  * 提升到准即时。监听失败(平台/权限)时退化为不监听 —— 仍有 5 秒轮询兜底。
@@ -224,7 +280,11 @@ export async function drainPendingConfig(env, apply, maxTries = 5) {
224
280
  const reason = String((error && error.message) || error)
225
281
  if (tries >= maxTries) {
226
282
  writePendingConfig(env, pending.patch, pending.at, tries, { dropped: true, lastError: reason })
227
- const dropped = new Error('待应用配置连续失败 ' + tries + ' 次已放弃(原报错:' + reason + ');改动仍保留在 ' + pendingConfigPath(env) + ',可修正后重新保存')
283
+ // 失败原因分两类:宿主守卫类(不是配置内容的问题,要说清去哪改)与其它。
284
+ const advice = describeApplyFailure(reason)
285
+ const dropped = new Error('待应用配置连续失败 ' + tries + ' 次已放弃(原报错:' + reason + ')'
286
+ + (advice.kind === 'host-guard' ? '。' + advice.hint : '')
287
+ + ';改动仍保留在 ' + pendingConfigPath(env) + ',可修正后重新保存')
228
288
  dropped.dropped = true
229
289
  dropped.lastError = reason
230
290
  throw dropped
@@ -0,0 +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
+ }
package/lib/watchdog.js CHANGED
@@ -263,6 +263,15 @@ export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000,
263
263
  if (!found || !found.pid) continue
264
264
  warnings.push(`端口 ${p} 仍被 pid ${found.pid} 占用(${found.command || '命令行未知'});status 显示的版本可能不是端口上真正服务的版本`)
265
265
  }
266
+ // 顺带回收「上一代退让到 port+1… 的孤儿 web」:它们不在锁里,配置端口的兜底永远够不到它们
267
+ // (8001/8002 上常驻的那两个就是这么来的)。三道门由 sweepOrphanWebs 把握,只收自家的。
268
+ for (const p of ports) {
269
+ try {
270
+ const sweep = await sweepOrphanWebs({ env, port: p, findListener, kill: killFn, isAlive: alive, lock: { watchdogs: readWatchdogLock(env).watchdogs } })
271
+ for (const pid of sweep.killed) portStopped.push(pid)
272
+ for (const w of sweep.warnings) warnings.push(w)
273
+ } catch { /* 回收失败不影响 stop 主流程 */ }
274
+ }
266
275
  }
267
276
  return {
268
277
  stopped: [...new Set(stopped.filter((p) => !failed.includes(p)))],
@@ -489,6 +498,66 @@ export function looksLikeOurWeb(command, { webJs = WEB_JS } = {}) {
489
498
  return Boolean(target) && norm(s).includes(target)
490
499
  }
491
500
 
501
+ /**
502
+ * 退让端口窗口:配置端口 + 紧随其后的若干个。
503
+ *
504
+ * web 在目标端口被占时会退到 port+1…(见 startWatchdog 的 findFreePort),这些落点正是
505
+ * 「孤儿 web」的诞生地:退让出来的那个 web 一旦失去托管它的 watchdog,就再没人来认领它。
506
+ */
507
+ export function fallbackPortWindow(port, span = 9) {
508
+ const base = Number(port) || 0
509
+ const n = Math.max(0, Number(span) || 0)
510
+ const out = []
511
+ for (let i = 1; i <= n; i++) out.push(base + i)
512
+ return out
513
+ }
514
+
515
+ /**
516
+ * 收掉「本插件拉起、但没有任何托管者认领」的孤儿 web。
517
+ *
518
+ * 为什么必须单独做这件事:web 子进程是 detached 启动的(stdio ignore + unref),watchdog 被
519
+ * 硬终止时(Windows 上 process.kill(pid,'SIGTERM') 就是无条件终止)它的退出处理器不会执行;
520
+ * 若该 web 当初退让到了 port+1,登记它的锁条目也随 watchdog 一起消失 —— 于是它会一直占着
521
+ * 8001/8002 之类的端口,`status` 只探配置端口,永远发现不了它。0.10.4 起 stop/restart 会按
522
+ * 「端口占用者」兜底,但那只覆盖配置端口本身。
523
+ *
524
+ * 三道门缺一不可(宁可漏收,绝不误杀):
525
+ * ① 端口落在 [port+1, port+span] 窗口内 —— 配置端口上的服务不归它管;
526
+ * ② 命令行确实是本插件的 web(复用收紧过的 looksLikeOurWeb,别人的 node web.js 不动);
527
+ * ③ 该 pid 不在锁文件里任何 watchdog 的 pid / webPid 上 —— 登记在案的一律不动。
528
+ *
529
+ * @param {{env?:object, port:number, span?:number, kill?:Function, isAlive?:Function, findListener?:Function, lock?:object}} opts
530
+ * @returns {Promise<{killed:number[], kept:Array<{pid:number,port:number,reason:string}>, warnings:string[]}>}
531
+ */
532
+ export async function sweepOrphanWebs({ env = process.env, port, span = 9, kill, isAlive, findListener, lock } = {}) {
533
+ const alive = isAlive || isPidAlive
534
+ const killFn = kill || ((pid) => { try { process.kill(pid, 'SIGTERM'); return true } catch { return false } })
535
+ const probeListener = async (p) => {
536
+ try { return findListener ? await findListener(p) : await findPortListener(p) } catch { return null }
537
+ }
538
+ const entries = lock && Array.isArray(lock.watchdogs) ? lock.watchdogs : readWatchdogLock(env).watchdogs
539
+ const registered = new Set()
540
+ for (const w of entries) {
541
+ if (Number(w.pid) > 0) registered.add(Number(w.pid))
542
+ if (Number(w.webPid) > 0) registered.add(Number(w.webPid))
543
+ }
544
+ const killed = []
545
+ const kept = []
546
+ const warnings = []
547
+ for (const p of fallbackPortWindow(port, span)) {
548
+ const found = await probeListener(p)
549
+ if (!found || !found.pid) continue
550
+ const pid = Number(found.pid)
551
+ const command = String(found.command || '')
552
+ if (!looksLikeOurWeb(command)) { kept.push({ pid, port: p, reason: 'not-ours' }); continue }
553
+ if (registered.has(pid)) { kept.push({ pid, port: p, reason: 'registered' }); continue }
554
+ if (!alive(pid)) { kept.push({ pid, port: p, reason: 'already-exited' }); continue }
555
+ if (!killFn(pid)) { warnings.push(`端口 ${p} 上的孤儿 web(pid ${pid})未能终止,请手工处理`); continue }
556
+ killed.push(pid)
557
+ }
558
+ return { killed, kept, warnings }
559
+ }
560
+
492
561
  /** 探测端口是否有任何 TCP 监听(即便不是我们的服务也占用)。 */
493
562
  export async function probeTcp(port, timeoutMs = 600) {
494
563
  return new Promise((resolve) => {
@@ -530,6 +599,16 @@ export function startWatchdog({ port = 7999, vaultRoot, interval = 5000, maxRest
530
599
  if (child && !child.killed) {
531
600
  try { child.kill('SIGTERM') } catch {}
532
601
  }
602
+ // 目标端口上已经有**本插件自己的** web(上一代 watchdog 留下的、或正在启动还没来得及回
603
+ // /overview 的那个)→ 本轮不重复拉起,更不退让到 port+1:退让出来的那个一旦失去托管者
604
+ // 就是孤儿(8001/8002 的来历)。保活交给 tick 的探活,等它真的死了再起。
605
+ try {
606
+ const found = await findPortListener(port)
607
+ if (found && found.pid && looksLikeOurWeb(found.command)) {
608
+ log(`端口 ${port} 上已有本插件的 web(pid ${found.pid})→ 本轮不重复拉起(不制造退让实例)`)
609
+ return port
610
+ }
611
+ } catch { /* 探测失败按原逻辑继续 */ }
533
612
  const targetPort = await findFreePort(port)
534
613
  const args = [WEB_JS, '--port', String(targetPort)]
535
614
  if (vaultRoot) args.push('--vault', vaultRoot)
@@ -633,6 +712,16 @@ export function startWatchdog({ port = 7999, vaultRoot, interval = 5000, maxRest
633
712
  process.exit(0)
634
713
  }
635
714
 
715
+ // 启动时顺手回收「上一代退让到 port+1… 的孤儿 web」——不这么做的话,那些 web 会一直占着
716
+ // 8001/8002 之类的端口,而 tick 只探配置端口,永远发现不了它们(它们当初的托管者已退出)。
717
+ ;(async () => {
718
+ try {
719
+ const sweep = await sweepOrphanWebs({ port })
720
+ if (sweep.killed.length) log(`已回收上一代的孤儿 web:pid ${sweep.killed.join(', ')}(端口 ${fallbackPortWindow(port).join('/')} 窗口)`)
721
+ for (const w of sweep.warnings) log(`⚠ ${w}`)
722
+ } catch { /* 回收失败不影响保活主流程 */ }
723
+ })()
724
+
636
725
  // 首次探测
637
726
  ;(async () => {
638
727
  const alive = await probe(port)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
4
4
  "description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -64,7 +64,7 @@
64
64
  "prepublishOnly": "node build.mjs",
65
65
  "release": "node scripts/release.mjs",
66
66
  "release:dry": "node scripts/release.mjs --dry-run",
67
- "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/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.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/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/ssr.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs && node tests/issue-fixes-2.test.mjs"
67
+ "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/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.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/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/ssr.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs && node tests/issue-fixes-2.test.mjs && node tests/issue-fixes-3.test.mjs && node tests/watchdog-orphans.test.mjs"
68
68
  },
69
69
  "dependencies": {
70
70
  "@deepseek-ai/schemastery": "^3.18.1"