memory-eternal 0.10.6 → 0.10.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 +10 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.mcp.json +9 -9
- package/LICENSE +21 -21
- package/README.en.md +37 -1
- package/README.md +30 -1
- package/bin/dsh-memory.mjs +31 -45
- package/examples/mcp-memory/memory-eternal.cordis.yml +59 -59
- package/hooks/capture.mjs +77 -77
- package/hooks/claude-session.js +56 -56
- package/hooks/hooks.claude.json +23 -12
- package/hooks/hooks.codex.json +15 -15
- package/hooks/hooks.cursor.json +11 -11
- package/hooks/secret-guard.mjs +82 -0
- package/index.js +137 -21
- package/lib/api.js +148 -17
- package/lib/client.js +17 -17
- package/lib/config-sync.js +99 -0
- package/lib/db.js +359 -359
- package/lib/host-heartbeat.js +76 -72
- package/lib/llm-openai.js +84 -84
- package/lib/mcp.js +3 -1
- package/lib/node-bin.js +99 -99
- package/lib/setup.js +515 -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/watchdog.js +241 -11
- package/lib/web-assets.js +41 -41
- package/lib/web.js +2 -0
- package/package.json +2 -2
- package/skills/memory-recall/SKILL.md +19 -14
- package/web/app.js +15 -15
- package/web/index.html +46 -46
package/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import path from 'node:path'
|
|
|
19
19
|
import os from 'node:os'
|
|
20
20
|
import { spawn } from 'node:child_process'
|
|
21
21
|
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import { randomBytes } from 'node:crypto'
|
|
22
23
|
const __filename = fileURLToPath(import.meta.url)
|
|
23
24
|
const PACKAGE_ROOT = path.resolve(path.dirname(__filename))
|
|
24
25
|
// 插件版本号(供「记忆配置」页面展示)
|
|
@@ -76,6 +77,13 @@ export const Config = z.object({
|
|
|
76
77
|
recallLimit: z.number().min(1).max(20).default(5),
|
|
77
78
|
recallSummaryLen: z.number().min(40).max(400).default(130),
|
|
78
79
|
recallIncludeBody: z.boolean().default(false),
|
|
80
|
+
// 凭证取用提示(默认空 = 不注入):写进来的一整句会被追加到每个会话的 systemPrompt 段,
|
|
81
|
+
// 用来让 agent「需要 API key / token 时先想到去查记忆里的目录卡」。
|
|
82
|
+
// 为什么做成配置而不是写死:默认值必须对**所有**用户安全 —— 插件本身不假定任何本机
|
|
83
|
+
// 密钥布局(也不该在公开包里硬编码某台机器的路径);有需要的用户填一句自己的约定即可。
|
|
84
|
+
// 参考句式:需要 API key/token 时先查记忆目录卡(memory_recall「密钥 目录」),
|
|
85
|
+
// 再按卡片给的本机命令取用;禁止直接读凭据文件、禁止把值打印或写进卡片/代码。
|
|
86
|
+
secretHint: z.string().default(''),
|
|
79
87
|
// 多 Vault / 多 Profile:命名分库,当前激活一个
|
|
80
88
|
vaultProfiles: z.array(z.object({ name: z.string(), path: z.string() })).default([]),
|
|
81
89
|
activeVault: z.string().default(''),
|
|
@@ -99,6 +107,12 @@ export const Config = z.object({
|
|
|
99
107
|
// **v0.6.0 起默认 true**——DSH 进程内 setInterval 在 DSH 退出后失效;
|
|
100
108
|
// 常驻 web 场景需要独立 watchdog;代价是 ~47 MB 额外常驻内存。
|
|
101
109
|
watchdogAutoSpawn: z.boolean().default(true),
|
|
110
|
+
// 版本漂移自愈(默认开):常驻 web(7999)比 DSH 活得久 —— 升级 npm 包只换磁盘文件,
|
|
111
|
+
// 端口上那个进程仍跑着启动时加载的旧代码,而「同端口已有活着的 watchdog 就让位」的策略
|
|
112
|
+
// 让重启 DSH 永远换不掉它(issue #19/#23)。开启后,激活时若发现**端口上真正服务的版本
|
|
113
|
+
// ≠ 本机磁盘版本**,就用新代码重启常驻实例(delegate → restart),而不是继续委派给旧实例。
|
|
114
|
+
// 关掉只是不自动做,面板上的「重启常驻实例」按钮与 dsh-memory restart 始终可用。
|
|
115
|
+
autoRestartOnDrift: z.boolean().default(true),
|
|
102
116
|
// 回收站保留天数:软删卡超过此天数自动永久删除(默认 30)
|
|
103
117
|
recycleRetentionDays: z.number().min(1).max(3650).default(30),
|
|
104
118
|
// 自动审核配置
|
|
@@ -350,6 +364,19 @@ export function apply(ctx, config) {
|
|
|
350
364
|
// 否则 5 秒一轮的轮询会把日志刷爆)。
|
|
351
365
|
let lastDrainError = ''
|
|
352
366
|
let lastDrainErrorAt = 0
|
|
367
|
+
// 被本宿主 schema 过滤掉的键(升级期新独立页 + 旧宿主),随 drain 结果回报给独立端
|
|
368
|
+
let lastDrainIgnored = []
|
|
369
|
+
// ---- 方案 A:把「应用 pending」搬进宿主允许的上下文(2026-10-08 实测)---------------------------------
|
|
370
|
+
// 同一个 settings.update:从插件**回调**里调必然被 activation 守卫拒(HMR transactions cannot be
|
|
371
|
+
// nested,一个字节都不提交);从插件**HTTP 请求处理器**里调则真的提交(实测 revision 4→6)。
|
|
372
|
+
// 所以宿主把「自己的监听端口 + 一次性令牌」写进心跳,独立端保存后就地叫醒宿主:
|
|
373
|
+
// POST /memory-eternal/api/drain-pending(见下面的路由 + lib/config-sync.js 的 triggerHostDrain)。
|
|
374
|
+
const hostDrainToken = randomBytes(16).toString('hex')
|
|
375
|
+
let hostDrainPort = 0
|
|
376
|
+
const beatHostMarker = () => {
|
|
377
|
+
// 心跳失败不影响主流程:字段缺省时独立端会回落到「等宿主自己 drain」(旧行为)
|
|
378
|
+
try { writeHostMarker(process.env, { version: versionRef, port: hostDrainPort, token: hostDrainToken }) } catch { /* 心跳失败无妨 */ }
|
|
379
|
+
}
|
|
353
380
|
const reportDrainError = (error) => {
|
|
354
381
|
const dropped = !!(error && error.dropped)
|
|
355
382
|
const msg = String((error && error.message) || error || '未知错误')
|
|
@@ -361,7 +388,10 @@ export function apply(ctx, config) {
|
|
|
361
388
|
? '独立 Web 端的配置改动连续失败已放弃(文件保留以便排查):' + msg
|
|
362
389
|
: '独立 Web 端的配置改动应用失败,将重试:' + msg
|
|
363
390
|
try { console.error('[memory-eternal] ' + why) } catch { /* 日志失败无妨 */ }
|
|
364
|
-
|
|
391
|
+
// 用专用动作而不是 'fail':logCapture 里 `action==='fail'` 会把**自动沉淀健康状态**翻红,
|
|
392
|
+
// 于是「配置同步失败」会被念成「自动沉淀异常」(本会话实测:宿主重启前一直挂着这条假警报)。
|
|
393
|
+
// 配置同步是另一个子系统:照样写进日志/诊断信息,但不冒充沉淀管线坏了。
|
|
394
|
+
try { logCapture('system', dropped ? 'config-fail' : 'config-warn', why) } catch { /* 日志失败无妨 */ }
|
|
365
395
|
}
|
|
366
396
|
// issue #21:dsh-tui 一类宿主的能力守卫会在 settings.update **内部**的 describe() 上抛
|
|
367
397
|
// 「root.events.emit is unavailable from a plugin activation」(守卫只区分「第三方插件 vs
|
|
@@ -374,8 +404,19 @@ export function apply(ctx, config) {
|
|
|
374
404
|
// AsyncLocalStorage.run(token) 包住每次插件回调,并 patch 了 Fiber._execute,定时器 /
|
|
375
405
|
// fs.watch 的每一次回调都带着 activation token(见 ccch1mneyyy/dsh-TUI#1348 的定位)。
|
|
376
406
|
const applyPendingPatch = async (patch) => {
|
|
377
|
-
const { applyPatchVerified } = await import('./lib/config-sync.js')
|
|
378
|
-
|
|
407
|
+
const { applyPatchVerified, filterKnownKeys } = await import('./lib/config-sync.js')
|
|
408
|
+
// 只把**本宿主 schema 里存在的键**交给 settings.update。独立 Web 端(新版)提交的是整张表单,
|
|
409
|
+
// 升级期它可能带上旧宿主还不认识的键(本会话实测:新独立页提交含 secretHint 的整包 → 旧宿主
|
|
410
|
+
// 按白名单丢掉 secretHint → 回读校验必然对不上 → 连续失败 5 次标 dropped,用户还会看到
|
|
411
|
+
// 「HMR transactions cannot be nested」这种与配置内容无关的报错)。过滤后:旧宿主只应用它认识的
|
|
412
|
+
// 那部分,不认识的记一行日志等宿主升级后再生效。
|
|
413
|
+
const { known: filtered, ignored } = filterKnownKeys(patch, Object.keys(Config.dict || {}))
|
|
414
|
+
lastDrainIgnored = ignored
|
|
415
|
+
if (ignored.length) {
|
|
416
|
+
try { console.error('[memory-eternal] 待应用配置里有本宿主不认识的键(宿主升级后生效):' + ignored.join(',')) } catch { /* 日志失败无妨 */ }
|
|
417
|
+
}
|
|
418
|
+
if (!Object.keys(filtered).length) return { applied: true, repaired: false, ignored }
|
|
419
|
+
const out = await applyPatchVerified(filtered, {
|
|
379
420
|
apply: (p) => settings.update(p, undefined),
|
|
380
421
|
read: () => settings.get() ?? {},
|
|
381
422
|
onRepaired: (error) => {
|
|
@@ -384,8 +425,15 @@ export function apply(ctx, config) {
|
|
|
384
425
|
} catch { /* 日志失败无妨 */ }
|
|
385
426
|
},
|
|
386
427
|
})
|
|
428
|
+
return { ...out, ignored }
|
|
387
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* 应用一次 pending,并把结果**返回**给调用方(不再只是写日志)。
|
|
432
|
+
* 返回结构同时服务两条路:5 秒轮询 / fs.watch(忽略返回值)与 drain 路由(要如实回报给独立端)。
|
|
433
|
+
* @returns {Promise<{applied:string[], ignored:string[], error:string, dropped:boolean}>}
|
|
434
|
+
*/
|
|
388
435
|
const drainPending = async () => {
|
|
436
|
+
lastDrainIgnored = []
|
|
389
437
|
try {
|
|
390
438
|
const { drainPendingConfig } = await import('./lib/config-sync.js')
|
|
391
439
|
const applied = await drainPendingConfig(process.env, applyPendingPatch)
|
|
@@ -394,10 +442,13 @@ export function apply(ctx, config) {
|
|
|
394
442
|
syncConfigFile()
|
|
395
443
|
syncAudit()
|
|
396
444
|
try { console.error('[memory-eternal] 已应用独立 Web 端的配置改动: ' + Object.keys(applied).join(',')) } catch { /* 日志失败无妨 */ }
|
|
445
|
+
return { applied: Object.keys(applied), ignored: lastDrainIgnored, error: '', dropped: false }
|
|
397
446
|
}
|
|
447
|
+
return { applied: [], ignored: lastDrainIgnored, error: '', dropped: false }
|
|
398
448
|
} catch (error) {
|
|
399
449
|
// 失败时保留文件由 config-sync.js 负责;这里只保证「失败可见」
|
|
400
450
|
reportDrainError(error)
|
|
451
|
+
return { applied: [], ignored: lastDrainIgnored, error: String((error && error.message) || error), dropped: Boolean(error && error.dropped) }
|
|
401
452
|
}
|
|
402
453
|
}
|
|
403
454
|
drainPending()
|
|
@@ -422,7 +473,7 @@ export function apply(ctx, config) {
|
|
|
422
473
|
// 直接写共享配置(绕过会与宿主的 volatile 配置分叉)。心跳必须定期刷新:进程被硬杀时
|
|
423
474
|
// 没有机会删文件,独立端靠「新鲜度 + pid 存活」两个条件判定,所以宁可多刷几次。
|
|
424
475
|
ctx.effect(() => {
|
|
425
|
-
const beat = () =>
|
|
476
|
+
const beat = () => beatHostMarker()
|
|
426
477
|
beat()
|
|
427
478
|
// unref:心跳只是「我在跑」的旁证,不该成为阻止进程退出的最后一根钉子
|
|
428
479
|
// (测试里以假 ctx 跑 apply() 时会因此挂住不退,实测过一次)。
|
|
@@ -812,6 +863,11 @@ export function apply(ctx, config) {
|
|
|
812
863
|
...((cfg.auditMode ?? 'all') === 'none' ? [] : [
|
|
813
864
|
'6. **审核红线(强制)**:写卡一律停在 pending,由用户在「审核中心」审批。**禁止**调用 /memory-eternal/api/audit/approve 或 /audit/reject 代替用户审批,也不得用任何等价方式绕过审核;用户说「记录一下 / 增加记忆」不等于允许免审入库。需要立刻可用时,写卡后明确告知用户「已进审核中心,待批准」。',
|
|
814
865
|
]),
|
|
866
|
+
// 凭证取用提示(用户自己填的整句,见 Config.secretHint):只在填了非空值时注入。
|
|
867
|
+
// 为什么走 systemPrompt 而不是「往卡里写值」:密钥的**值**入库会被每次召回带进模型
|
|
868
|
+
// 上下文(card.summary = 正文前 200 字,召回默认取前 130 字),而 agent 真正需要的
|
|
869
|
+
// 只是「去哪取、怎么取」——这句负责**触发**,目录卡负责细节,值留在本机凭据库。
|
|
870
|
+
...((cfg.secretHint || '').trim() ? [String(cfg.secretHint).trim()] : []),
|
|
815
871
|
].join('\n')
|
|
816
872
|
// 异常提示:沉淀管线坏了,靠这一句把消息送到用户面前(不依赖用户去翻页面)。
|
|
817
873
|
const h = health.snapshot()
|
|
@@ -843,7 +899,9 @@ export function apply(ctx, config) {
|
|
|
843
899
|
name: 'memory_recall',
|
|
844
900
|
description:
|
|
845
901
|
'从本地记忆核心(Markdown 知识库)检索相关知识卡。需要项目背景、历史决策、之前讨论过的方案、' +
|
|
846
|
-
'或领域知识时调用;返回最相关的卡片摘要。用 query 描述要找的内容,支持中文整词与字符片段检索。'
|
|
902
|
+
'或领域知识时调用;返回最相关的卡片摘要。用 query 描述要找的内容,支持中文整词与字符片段检索。' +
|
|
903
|
+
'需要 API key / token / 令牌等**凭证**时也先查这里(查「密钥 目录」):记忆里存的是目录' +
|
|
904
|
+
'(名字、存放位置、取用命令),**值不在记忆库里**,请按卡片给的本机命令取用,不要直接读凭据文件。',
|
|
847
905
|
parameters: {
|
|
848
906
|
query: { type: 'string', required: true, description: '检索关键词或自然语言描述,如「数据库选型」「用户偏好」' },
|
|
849
907
|
limit: { type: 'number', description: '返回卡片数上限,默认 5' },
|
|
@@ -956,6 +1014,10 @@ export function apply(ctx, config) {
|
|
|
956
1014
|
// 没有该服务的 profile(如 TUI)里子 fiber 保持 pending,主插件照常激活。
|
|
957
1015
|
// (DSH 自身的 dsh-client-modules 也是这么写的:ctx.inject(['webServer'], …))
|
|
958
1016
|
const registerApiRoutes = (webServer) => {
|
|
1017
|
+
// webServer 是晚到服务:它一就绪就把「监听端口」记下来并立刻刷新心跳 —— 独立端靠这个端口
|
|
1018
|
+
// 把 drain 请求发回来(port=0 时独立端自动回落到旧的「等宿主自己 drain」行为)。
|
|
1019
|
+
hostDrainPort = Number(webServer.port) || 0
|
|
1020
|
+
beatHostMarker()
|
|
959
1021
|
const handleApi = createApi({
|
|
960
1022
|
vaultDir, vaultRoots, getSettings: settings.get,
|
|
961
1023
|
// 自动沉淀运行轨迹 + 健康状态:供「用量/今日」页排查「为什么没写卡」,异常时页面顶部亮红。
|
|
@@ -1016,16 +1078,33 @@ export function apply(ctx, config) {
|
|
|
1016
1078
|
}
|
|
1017
1079
|
return
|
|
1018
1080
|
}
|
|
1081
|
+
if (pathname === API_PREFIX + '/drain-pending') {
|
|
1082
|
+
// 独立端保存后叫醒宿主(#方案 A):**HTTP 请求上下文**是宿主允许写配置的地方 ——
|
|
1083
|
+
// 实测同一个 settings.update 从回调里调必被 activation 守卫拒、从请求里调则真提交。
|
|
1084
|
+
// 两道闸门写在 lib/config-sync.js(有单测):只接受本机来源 + 心跳里的一次性令牌。
|
|
1085
|
+
const { drainRequestError } = await import('./lib/config-sync.js')
|
|
1086
|
+
const deny = drainRequestError(
|
|
1087
|
+
{ method: req.method, headers: req.headers, remoteAddress: req.socket && req.socket.remoteAddress },
|
|
1088
|
+
hostDrainToken,
|
|
1089
|
+
)
|
|
1090
|
+
if (deny) return json(res, deny === '需 POST' ? 405 : 403, { ok: false, error: deny })
|
|
1091
|
+
// 端口可能刚刚才就绪(webServer 是晚到服务):顺手把心跳里的端口刷新到最新
|
|
1092
|
+
if (Number(webServer.port) !== hostDrainPort) { hostDrainPort = Number(webServer.port) || 0; beatHostMarker() }
|
|
1093
|
+
const drained = await drainPending()
|
|
1094
|
+
if (drained.applied.length) hub.broadcast('config', { at: Date.now(), source: 'drain-request', applied: drained.applied })
|
|
1095
|
+
// 失败也回 200:调用方要读到具体原因,而不是一个没有 body 的 500
|
|
1096
|
+
return json(res, 200, { ok: !drained.error, ...drained })
|
|
1097
|
+
}
|
|
1019
1098
|
if (pathname === API_PREFIX + '/config') {
|
|
1020
1099
|
const method = req.method || 'GET'
|
|
1021
1100
|
if (method === 'GET') {
|
|
1022
1101
|
const cfg = settings.get() ?? {}
|
|
1023
1102
|
// 只暴露可安全展示/回填的字段
|
|
1024
1103
|
const safe = {
|
|
1025
|
-
autoCapture: cfg.autoCapture, autoRecall: cfg.autoRecall, recallLimit: cfg.recallLimit, recallSummaryLen: cfg.recallSummaryLen, recallIncludeBody: cfg.recallIncludeBody,
|
|
1104
|
+
autoCapture: cfg.autoCapture, autoRecall: cfg.autoRecall, recallLimit: cfg.recallLimit, recallSummaryLen: cfg.recallSummaryLen, recallIncludeBody: cfg.recallIncludeBody, secretHint: cfg.secretHint || '',
|
|
1026
1105
|
captureMinChars: cfg.captureMinChars, captureCooldownMs: cfg.captureCooldownMs, dedupThreshold: cfg.dedupThreshold, maxCardsPerDay: cfg.maxCardsPerDay,
|
|
1027
1106
|
distillEnabled: cfg.distillEnabled, dedupByLLM: cfg.dedupByLLM, captureMaxTokens: cfg.captureMaxTokens, recallMinScore: cfg.recallMinScore,
|
|
1028
|
-
autoWeb: cfg.autoWeb, autoWebMode: cfg.autoWebMode, webPort: cfg.webPort, webCheckIntervalMs: cfg.webCheckIntervalMs, webMaxRestart: cfg.webMaxRestart, watchdogAutoSpawn: cfg.watchdogAutoSpawn, autoMcpSetup: cfg.autoMcpSetup,
|
|
1107
|
+
autoWeb: cfg.autoWeb, autoWebMode: cfg.autoWebMode, webPort: cfg.webPort, webCheckIntervalMs: cfg.webCheckIntervalMs, webMaxRestart: cfg.webMaxRestart, watchdogAutoSpawn: cfg.watchdogAutoSpawn, autoRestartOnDrift: cfg.autoRestartOnDrift, autoMcpSetup: cfg.autoMcpSetup,
|
|
1029
1108
|
auditMode: cfg.auditMode ?? 'all', auditExemptAgents: cfg.auditExemptAgents || [], auditExemptKinds: cfg.auditExemptKinds || [], recycleRetentionDays: cfg.recycleRetentionDays ?? 30,
|
|
1030
1109
|
// 多库(#10):配置页可直接编辑
|
|
1031
1110
|
vaultProfiles: Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : [],
|
|
@@ -1088,7 +1167,7 @@ export function apply(ctx, config) {
|
|
|
1088
1167
|
retried: !!(writeResult && writeResult.retried),
|
|
1089
1168
|
note: pending.length
|
|
1090
1169
|
? '已写入;宿主尚未回流这些值(面板已本地生效,重启 DSH 后以配置文件为准)'
|
|
1091
|
-
: '已保存。autoWebMode/watchdogAutoSpawn 等需重启 DSH 生效;若已有常驻 watchdog,关闭/改参不会自动停掉它,需 dsh-memory stop / restart(#19
|
|
1170
|
+
: '已保存。autoWebMode/watchdogAutoSpawn/webPort 等需重启 DSH 生效;若已有常驻 watchdog,关闭/改参不会自动停掉它,需 dsh-memory stop / restart(#19)。版本漂移(升级后端口上仍是旧代码)由 autoRestartOnDrift 在下次激活时自动重启常驻实例,也可在「插件信息」点「重启常驻实例」立刻处理',
|
|
1092
1171
|
})
|
|
1093
1172
|
} catch (e) {
|
|
1094
1173
|
if (e && e.code === 'SETTINGS_CONFLICT') return json(res, 409, { ok: false, error: '配置已被外部修改,请刷新后重试(revision conflict)' })
|
|
@@ -1204,22 +1283,59 @@ export function apply(ctx, config) {
|
|
|
1204
1283
|
const wdPort = Number(cfg0.webPort) || 7999
|
|
1205
1284
|
if (cfg0.watchdogAutoSpawn === true && cfg0.autoWeb !== false) {
|
|
1206
1285
|
import('./lib/watchdog.js')
|
|
1207
|
-
.then((m) => {
|
|
1208
|
-
//
|
|
1209
|
-
//
|
|
1210
|
-
//
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1286
|
+
.then(async (m) => {
|
|
1287
|
+
// 先体检再决策(issue #19/#23):同端口已有**活着的** watchdog 时不再 spawn ——
|
|
1288
|
+
// 旧实现每次都 spawn 一个、每次都打印 "spawned",而新进程会在锁上让位退出,
|
|
1289
|
+
// 日志与实际状态完全相反。但「有实例在跑」不等于「跑的是新代码」:常驻 web 比
|
|
1290
|
+
// DSH 活得久,升级只换磁盘文件,所以这里问的是**端口上真正服务的版本**,
|
|
1291
|
+
// 漂移就用新代码重启它(autoRestartOnDrift),而不是继续委派给旧代码。
|
|
1292
|
+
const info = await m.inspectResident({ port: wdPort })
|
|
1293
|
+
const cur = m.currentPkgVersion()
|
|
1294
|
+
const decision = m.decideResidentAction({
|
|
1295
|
+
served: info.served,
|
|
1296
|
+
expect: cur,
|
|
1297
|
+
watchdogAlive: info.watchdogAlive,
|
|
1298
|
+
occupantIsOurs: info.occupantIsOurs,
|
|
1299
|
+
autoRestart: cfg0.autoRestartOnDrift !== false,
|
|
1300
|
+
residentStartedAt: info.residentStartedAt,
|
|
1301
|
+
})
|
|
1302
|
+
const servedLabel = info.served || '(旧版不自报版本)'
|
|
1303
|
+
|
|
1304
|
+
if (decision.action === 'delegate') {
|
|
1305
|
+
const who = info.watchdog ? `pid=${info.watchdog.pid}` : '外部实例'
|
|
1306
|
+
const why = `watchdog delegated to ${who} port=${wdPort}(端口上服务 v${servedLabel},本机 v${cur || '?'}${decision.reason === 'debounced' ? ';刚替换过,防抖窗口内不重复重启' : ''})`
|
|
1219
1307
|
console.error(`[memory-eternal] ${why}`)
|
|
1220
|
-
logCapture('system',
|
|
1308
|
+
logCapture('system', 'boot', why)
|
|
1221
1309
|
return
|
|
1222
1310
|
}
|
|
1311
|
+
|
|
1312
|
+
if (decision.action === 'restart') {
|
|
1313
|
+
// 「更新运行中的程序」的唯一安全形态 = 用新代码重启那个常驻进程(不是热替换内存里的模块)
|
|
1314
|
+
const why = `版本漂移:端口 ${wdPort} 上服务 v${servedLabel} ≠ 本机 v${cur}(${decision.reason})→ 用新代码重启常驻实例`
|
|
1315
|
+
console.error(`[memory-eternal] ${why}`)
|
|
1316
|
+
logCapture('system', 'warn', why)
|
|
1317
|
+
const out = await m.restartResident({
|
|
1318
|
+
port: wdPort,
|
|
1319
|
+
vaultRoot: vaultDir(),
|
|
1320
|
+
interval: Number(cfg0.webCheckIntervalMs) || 5000,
|
|
1321
|
+
maxRestart: Number(cfg0.webMaxRestart) || 10,
|
|
1322
|
+
})
|
|
1323
|
+
const done = out.ok
|
|
1324
|
+
? `常驻实例已重启:端口 ${wdPort} 正在服务 v${out.served}(等待 ${Math.round(out.waitedMs / 1000)}s)`
|
|
1325
|
+
: `常驻实例重启未确认生效(${out.stage}):${out.error || '未知原因'} —— 可再试「设置 → 记忆 → 插件信息 → 重启常驻实例」,或执行 dsh-memory restart --port ${wdPort}`
|
|
1326
|
+
console.error(`[memory-eternal] ${done}`)
|
|
1327
|
+
logCapture('system', out.ok ? 'boot' : 'fail', done)
|
|
1328
|
+
return
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
if (decision.action === 'warn') {
|
|
1332
|
+
const why = `常驻实例版本漂移(端口上服务 v${servedLabel} ≠ 本机 v${cur}),但 autoRestartOnDrift 已关闭 → 不自动重启;可点面板「重启常驻实例」或执行 dsh-memory restart --port ${wdPort}`
|
|
1333
|
+
console.error(`[memory-eternal] ${why}`)
|
|
1334
|
+
logCapture('system', 'warn', why)
|
|
1335
|
+
return
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// 端口上没有我们的服务(也没人占着)→ 照常拉起一个常驻 watchdog
|
|
1223
1339
|
const port = wdPort
|
|
1224
1340
|
// 用 nodeBinary() 而非 process.execPath:Electron 宿主下后者是 Electron
|
|
1225
1341
|
// 主程序,spawn 出来不会执行 watchdog.js(见 lib/node-bin.js)。
|
package/lib/api.js
CHANGED
|
@@ -20,6 +20,7 @@ import { buildDiagnostics } from './feedback.js'
|
|
|
20
20
|
import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
|
|
21
21
|
import { backupDb } from './db.js'
|
|
22
22
|
import { createHub } from './sse.js'
|
|
23
|
+
import { probeServedVersion, restartResident, currentPkgVersion } from './watchdog.js'
|
|
23
24
|
|
|
24
25
|
// 独立 web 的 SSE 中心:配置/数据变更即时推给已打开的页面(无需手动刷新)
|
|
25
26
|
const hub = createHub()
|
|
@@ -31,6 +32,48 @@ export const API_PREFIX = '/memory-eternal/api'
|
|
|
31
32
|
// npm 最新版本缓存(避免每次点「检查更新」都打 registry)
|
|
32
33
|
const versionCache = { at: 0, latest: '' }
|
|
33
34
|
|
|
35
|
+
// 「重启常驻实例」的并发闸门:连点两次不能起两个替换助手(后一个会让位退出,但白折腾一次)
|
|
36
|
+
let residentRestarting = false
|
|
37
|
+
|
|
38
|
+
/** 解析 `x.y.z[-pre]`;解析不出来返回 null。 */
|
|
39
|
+
export function parseVersion(v) {
|
|
40
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(v || '').trim())
|
|
41
|
+
if (!m) return null
|
|
42
|
+
return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || '' }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 比较两个版本号(a>b → 1)。
|
|
47
|
+
*
|
|
48
|
+
* 为什么需要:`latest !== onDisk` 这种判断在**本地跑着未发布版本**时会给出误导性结论
|
|
49
|
+
* (磁盘 v0.10.7、npm 还是 v0.10.6 → 面板提示「有新版本」并让你装回旧的)。
|
|
50
|
+
* 预发布标识按 semver 口径比(点分标识、数字段按数值比)—— `rc.2 < rc.10` 不是字符串比较。
|
|
51
|
+
*/
|
|
52
|
+
export function compareVersions(a, b) {
|
|
53
|
+
const A = parseVersion(a)
|
|
54
|
+
const B = parseVersion(b)
|
|
55
|
+
if (!A || !B) return 0
|
|
56
|
+
for (let i = 0; i < 3; i++) if (A.nums[i] !== B.nums[i]) return A.nums[i] < B.nums[i] ? -1 : 1
|
|
57
|
+
if (A.pre === B.pre) return 0
|
|
58
|
+
if (!A.pre) return 1 // 正式版 > 预发布版
|
|
59
|
+
if (!B.pre) return -1
|
|
60
|
+
const as = A.pre.split('.')
|
|
61
|
+
const bs = B.pre.split('.')
|
|
62
|
+
for (let i = 0; i < Math.max(as.length, bs.length); i++) {
|
|
63
|
+
const x = as[i]
|
|
64
|
+
const y = bs[i]
|
|
65
|
+
if (x === undefined) return -1 // 前缀相同、标识更少的那个更小
|
|
66
|
+
if (y === undefined) return 1
|
|
67
|
+
if (x === y) continue
|
|
68
|
+
const nx = /^\d+$/.test(x)
|
|
69
|
+
const ny = /^\d+$/.test(y)
|
|
70
|
+
if (nx && ny) return Number(x) < Number(y) ? -1 : 1
|
|
71
|
+
if (nx !== ny) return nx ? -1 : 1 // 数字标识 < 字母标识
|
|
72
|
+
return x < y ? -1 : 1
|
|
73
|
+
}
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
|
|
34
77
|
export function createApi(deps = {}) {
|
|
35
78
|
const vaultDir = deps.vaultDir ?? (() => '')
|
|
36
79
|
const vaultRoots = deps.vaultRoots ?? (() => [{ name: '', root: vaultDir() }])
|
|
@@ -40,6 +83,14 @@ export function createApi(deps = {}) {
|
|
|
40
83
|
// 自动沉淀运行轨迹(最新在前)+ 健康状态(异常时 UI 顶部亮红)。
|
|
41
84
|
const getCaptureLog = deps.getCaptureLog ?? (() => null)
|
|
42
85
|
const getCaptureHealth = deps.getCaptureHealth ?? (() => null)
|
|
86
|
+
// 「重启常驻实例」的实现(宿主与独立 web 共用 lib/watchdog.js 的那一份;注入仅为单测)
|
|
87
|
+
const doRestartResident = deps.restartResident ?? restartResident
|
|
88
|
+
const probeResidentVersion = deps.probeServedVersion ?? probeServedVersion
|
|
89
|
+
const pkgVersion = deps.currentPkgVersion ?? currentPkgVersion
|
|
90
|
+
// 本进程自己监听的端口(独立 web 传入;宿主为 0 = 不知道)。用来把「要重启/要看的是哪个
|
|
91
|
+
// 常驻实例」钉死在**本页所连的那个实例**上,而不是配置里的理想端口(web 退让到 port+1 时
|
|
92
|
+
// 两者不同,探错端口会给出错误结论)。
|
|
93
|
+
const selfPort = () => (typeof deps.selfPort === 'function' ? Number(deps.selfPort()) || 0 : Number(deps.selfPort) || 0)
|
|
43
94
|
|
|
44
95
|
return async function handleApi(req, res) {
|
|
45
96
|
const vaultRoot = vaultDir()
|
|
@@ -137,9 +188,9 @@ export function createApi(deps = {}) {
|
|
|
137
188
|
return
|
|
138
189
|
}
|
|
139
190
|
case '/version-check': {
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
const loaded = (getDshInfo() || {}).version || ''
|
|
191
|
+
// 版本跟踪:运行中(服务本页的进程) vs 磁盘安装 vs npm 最新。
|
|
192
|
+
// 典型场景:磁盘已更新,但端口上的常驻进程仍加载旧版 → UI 显示旧版本号、新接口 404。
|
|
193
|
+
const loaded = (getDshInfo() || {}).version || process.env.MEMORY_ETERNAL_VERSION || ''
|
|
143
194
|
let onDisk = ''
|
|
144
195
|
try {
|
|
145
196
|
const pj = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
@@ -162,16 +213,76 @@ export function createApi(deps = {}) {
|
|
|
162
213
|
latest = versionCache.latest
|
|
163
214
|
}
|
|
164
215
|
} catch (e) { checkError = String((e && e.message) || e).slice(0, 120) }
|
|
216
|
+
// 常驻实例(端口上那个进程)自报的版本:宿主面板靠它区分「是本页这个进程旧」还是
|
|
217
|
+
// 「常驻 web 旧」——两者要做的操作不同(重启 DSH vs 重启常驻实例)。独立 web 页里
|
|
218
|
+
// 探到的就是它自己。
|
|
219
|
+
const cfgVc = getSettings() ?? {}
|
|
220
|
+
const residentPort = selfPort() || Number(cfgVc.webPort) || Number(process.env.MEMORY_WEB_PORT) || 7999
|
|
221
|
+
let residentServed = ''
|
|
222
|
+
try { residentServed = String((await probeResidentVersion(residentPort)) || '') } catch { residentServed = '' }
|
|
165
223
|
json(res, 200, {
|
|
166
224
|
ok: true,
|
|
167
225
|
loaded,
|
|
168
226
|
onDisk,
|
|
169
227
|
latest,
|
|
170
228
|
checkError,
|
|
229
|
+
resident: { port: residentPort, served: residentServed },
|
|
171
230
|
stale: !!(loaded && onDisk && loaded !== onDisk),
|
|
172
|
-
|
|
231
|
+
// 只在 **npm 上确实更新** 时提示升级:本地跑着未发布版本(磁盘 0.10.7 / npm 0.10.6)时
|
|
232
|
+
// 旧的 `latest !== onDisk` 会反过来劝用户装回旧版。
|
|
233
|
+
updateAvailable: !!(latest && onDisk && compareVersions(latest, onDisk) > 0),
|
|
173
234
|
updateCommand: 'dsh plugin --profile <你的 profile> add memory-eternal@latest',
|
|
235
|
+
// 「重启常驻实例」的能力与命令(面板据此给一键按钮 / 手动指引)
|
|
236
|
+
canRestartResident: true,
|
|
237
|
+
restartCommand: `dsh-memory restart --port ${residentPort}`,
|
|
238
|
+
})
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
case '/restart-self': {
|
|
242
|
+
// 「运行中还是旧版本」的一键修复:用新代码重启**常驻实例**(端口上那个进程)。
|
|
243
|
+
// 三条硬约束:
|
|
244
|
+
// ① 本进程很可能就是要被替换的旧 web → 先回响应,再调度替换(助手负责收旧实例);
|
|
245
|
+
// ② 常驻实例已经是最新版就别白折腾(宿主面板点这个按钮时最常见)——如实回 alreadyCurrent;
|
|
246
|
+
// ③ 同一时刻只允许一个替换在跑。
|
|
247
|
+
if (req.method && req.method !== 'POST') return json(res, 405, { ok: false, error: 'method not allowed' })
|
|
248
|
+
const cfgR = getSettings() ?? {}
|
|
249
|
+
const port = Number(query.get('port')) || selfPort() || Number(cfgR.webPort) || Number(process.env.MEMORY_WEB_PORT) || 7999
|
|
250
|
+
const expect = pkgVersion()
|
|
251
|
+
const served = await probeResidentVersion(port).catch(() => '')
|
|
252
|
+
if (served && expect && String(served) === String(expect)) {
|
|
253
|
+
return json(res, 200, {
|
|
254
|
+
ok: true, alreadyCurrent: true, port, served: String(served), expect,
|
|
255
|
+
note: `常驻实例(端口 ${port})已经在服务 v${served},无需重启。若「运行中」仍显示旧版本号,说明那一行来自另一个进程(例如 DSH 宿主),需重启桌面版 / DSH。`,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
if (residentRestarting) {
|
|
259
|
+
return json(res, 200, { ok: true, alreadyRunning: true, port, expect, note: `端口 ${port} 的替换已在进行中,请稍候刷新页面。` })
|
|
260
|
+
}
|
|
261
|
+
residentRestarting = true
|
|
262
|
+
json(res, 200, {
|
|
263
|
+
ok: true, scheduled: true, port, served: String(served || ''), expect,
|
|
264
|
+
note: `已调度重启常驻实例(端口 ${port} → v${expect || '?'}):约 3-8 秒后新进程接管,请稍等再刷新页面(Ctrl+F5)。`,
|
|
174
265
|
})
|
|
266
|
+
setTimeout(() => {
|
|
267
|
+
Promise.resolve()
|
|
268
|
+
.then(() => doRestartResident({
|
|
269
|
+
port, vaultRoot, interval: Number(cfgR.webCheckIntervalMs) || 5000, maxRestart: Number(cfgR.webMaxRestart) || 10,
|
|
270
|
+
}))
|
|
271
|
+
.then(async (out) => {
|
|
272
|
+
// 本进程若正是被替换的旧 web,这里根本不会执行 —— 助手会把结论写进 capture 日志
|
|
273
|
+
try {
|
|
274
|
+
const { appendCaptureLog } = await import('./capture-log.js')
|
|
275
|
+
await appendCaptureLog({
|
|
276
|
+
time: Date.now(), sessionId: 'system', action: out && out.ok ? 'boot' : 'fail',
|
|
277
|
+
reason: out && out.ok
|
|
278
|
+
? `面板一键重启常驻实例成功:端口 ${port} 正在服务 v${out.served}`
|
|
279
|
+
: `面板一键重启常驻实例未确认生效:${(out && out.error) || '未知原因'}`,
|
|
280
|
+
}, process.env)
|
|
281
|
+
} catch { /* 日志失败不影响替换 */ }
|
|
282
|
+
})
|
|
283
|
+
.catch(() => {})
|
|
284
|
+
.finally(() => { residentRestarting = false })
|
|
285
|
+
}, 300)
|
|
175
286
|
return
|
|
176
287
|
}
|
|
177
288
|
case '/diagnostics': {
|
|
@@ -426,11 +537,12 @@ export function createApi(deps = {}) {
|
|
|
426
537
|
webCheckIntervalMs: Number(cfg.webCheckIntervalMs) || 5000,
|
|
427
538
|
webMaxRestart: Number(cfg.webMaxRestart) || 10,
|
|
428
539
|
watchdogAutoSpawn: cfg.watchdogAutoSpawn ?? env.MEMORY_WATCHDOG === '1',
|
|
540
|
+
autoRestartOnDrift: cfg.autoRestartOnDrift ?? true,
|
|
429
541
|
autoMcpSetup: cfg.autoMcpSetup ?? false,
|
|
430
542
|
})
|
|
431
543
|
} catch (e) {
|
|
432
544
|
// 配置读取异常时降级返回默认值,避免面板整块红屏
|
|
433
|
-
json(res, 200, { ok: true, budgetChars: 80000, recallLimit: 5, embedding: '', autoWeb: true, autoWebMode: 'init', webPort: 7999, webCheckIntervalMs: 5000, webMaxRestart: 10, watchdogAutoSpawn: true, autoMcpSetup: false })
|
|
545
|
+
json(res, 200, { ok: true, budgetChars: 80000, recallLimit: 5, embedding: '', autoWeb: true, autoWebMode: 'init', webPort: 7999, webCheckIntervalMs: 5000, webMaxRestart: 10, watchdogAutoSpawn: true, autoRestartOnDrift: true, autoMcpSetup: false })
|
|
434
546
|
}
|
|
435
547
|
return
|
|
436
548
|
}
|
|
@@ -444,10 +556,10 @@ export function createApi(deps = {}) {
|
|
|
444
556
|
const cfg = getSettings() ?? {}
|
|
445
557
|
const env = typeof process !== 'undefined' ? process.env : {}
|
|
446
558
|
const safe = {
|
|
447
|
-
autoCapture: cfg.autoCapture ?? true, autoRecall: cfg.autoRecall ?? true, recallLimit: cfg.recallLimit ?? 5, recallSummaryLen: cfg.recallSummaryLen ?? 130, recallIncludeBody: cfg.recallIncludeBody ?? false,
|
|
559
|
+
autoCapture: cfg.autoCapture ?? true, autoRecall: cfg.autoRecall ?? true, recallLimit: cfg.recallLimit ?? 5, recallSummaryLen: cfg.recallSummaryLen ?? 130, recallIncludeBody: cfg.recallIncludeBody ?? false, secretHint: cfg.secretHint || '',
|
|
448
560
|
captureMinChars: cfg.captureMinChars ?? 200, captureCooldownMs: cfg.captureCooldownMs ?? 300000, dedupThreshold: cfg.dedupThreshold ?? 0.62, maxCardsPerDay: cfg.maxCardsPerDay ?? 60,
|
|
449
561
|
distillEnabled: cfg.distillEnabled ?? true, dedupByLLM: cfg.dedupByLLM ?? true, captureMaxTokens: cfg.captureMaxTokens ?? DEFAULT_CAPTURE_MAX_TOKENS, recallMinScore: cfg.recallMinScore ?? 2,
|
|
450
|
-
autoWeb: cfg.autoWeb ?? env.MEMORY_AUTO_WEB !== '0', autoWebMode: cfg.autoWebMode || 'init', webPort: Number(cfg.webPort) || Number(env.MEMORY_WEB_PORT) || 7999, webCheckIntervalMs: Number(cfg.webCheckIntervalMs) || 5000, webMaxRestart: Number(cfg.webMaxRestart) || 10, watchdogAutoSpawn: cfg.watchdogAutoSpawn ?? env.MEMORY_WATCHDOG === '1', autoMcpSetup: cfg.autoMcpSetup ?? false,
|
|
562
|
+
autoWeb: cfg.autoWeb ?? env.MEMORY_AUTO_WEB !== '0', autoWebMode: cfg.autoWebMode || 'init', webPort: Number(cfg.webPort) || Number(env.MEMORY_WEB_PORT) || 7999, webCheckIntervalMs: Number(cfg.webCheckIntervalMs) || 5000, webMaxRestart: Number(cfg.webMaxRestart) || 10, watchdogAutoSpawn: cfg.watchdogAutoSpawn ?? env.MEMORY_WATCHDOG === '1', autoRestartOnDrift: cfg.autoRestartOnDrift ?? true, autoMcpSetup: cfg.autoMcpSetup ?? false,
|
|
451
563
|
auditMode: cfg.auditMode ?? 'all', auditExemptAgents: cfg.auditExemptAgents ?? [], auditExemptKinds: cfg.auditExemptKinds ?? [], recycleRetentionDays: cfg.recycleRetentionDays ?? 30,
|
|
452
564
|
vaultProfiles: Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : [], activeVault: cfg.activeVault || '',
|
|
453
565
|
enabled: cfg.enabled !== false, vaultDir: cfg.vaultDir || '', captureProvider: cfg.captureProvider || '',
|
|
@@ -488,16 +600,29 @@ export function createApi(deps = {}) {
|
|
|
488
600
|
}
|
|
489
601
|
if (bad.length) return json(res, 400, { ok: false, error: '字段值不合法:' + bad.join('、'), fields: bad })
|
|
490
602
|
if (!Object.keys(clean).length) return json(res, 400, { ok: false, error: '没有可写入的字段' })
|
|
491
|
-
const { writePendingConfig, readPendingConfig, waitPendingOutcome, clearPendingConfig, applySharedConfigDirect, describeApplyFailure } = await import('./config-sync.js')
|
|
603
|
+
const { writePendingConfig, readPendingConfig, waitPendingOutcome, clearPendingConfig, applySharedConfigDirect, describeApplyFailure, triggerHostDrain } = await import('./config-sync.js')
|
|
604
|
+
const { hostAlive, readHostMarker } = await import('./host-heartbeat.js')
|
|
492
605
|
// #16 建议 4:上一份改动是否已被判定失败?是的话这次不能再报「一切正常」——
|
|
493
606
|
// 否则用户第 N 次点击保存,看到的仍是成功,而问题一直在(其实是 settings.update 在报错)。
|
|
494
607
|
const before = readPendingConfig(process.env)
|
|
495
608
|
const out = writePendingConfig(process.env, clean)
|
|
496
609
|
hub.broadcast('config', { at: out.at, source: 'standalone', applied: Object.keys(clean) })
|
|
497
|
-
//
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
|
|
610
|
+
// 方案 A(首选):宿主活着、且心跳里带了端口 + 令牌时,**直接请宿主在 HTTP 请求上下文里
|
|
611
|
+
// 应用**。为什么不继续等宿主自己 drain:宿主那条 drain 挂在回调上下文(setInterval /
|
|
612
|
+
// fs.watch),在带 activation 守卫的宿主上必然被拒(实测与 patch 内容无关)—— 等不来结果。
|
|
613
|
+
// 叫醒失败(旧宿主没这条路由 / 端口对不上 / 超时)不是错误:退回 issue #21 的等待路径。
|
|
614
|
+
let outcome = 'queued'
|
|
615
|
+
let hostDrainError = ''
|
|
616
|
+
const aliveHost = hostAlive(process.env) ? readHostMarker(process.env) : null
|
|
617
|
+
if (aliveHost && aliveHost.port && aliveHost.token) {
|
|
618
|
+
const call = await triggerHostDrain({ port: aliveHost.port, token: aliveHost.token, timeoutMs: 2500 })
|
|
619
|
+
if (call.ok) outcome = 'applied'
|
|
620
|
+
else if (call.status === 404 || call.status === 405 || call.status === 0) hostDrainError = call.error || ''
|
|
621
|
+
else { outcome = 'failed'; hostDrainError = call.error || '' }
|
|
622
|
+
}
|
|
623
|
+
// 回落(issue #21 建议 3):别急着报「一切正常」。宿主对 pending 文件有 fs.watch(毫秒级),
|
|
624
|
+
// 稍等一下回读,就能分清「真的应用了」还是「只是排上队了」。
|
|
625
|
+
if (outcome === 'queued') outcome = await waitPendingOutcome(process.env, { timeoutMs: 800 })
|
|
501
626
|
// issue #21 的宿主无关路径:**本机没有活着的 DSH 宿主**时(Codex / Claude Code /
|
|
502
627
|
// 只跑 dsh-memory serve 的用户),没有任何进程会来 drain pending 文件 —— 那句
|
|
503
628
|
// 「DSH 未运行时下次启动生效」在那类环境下永远不会兑现。这时直接原子写共享配置
|
|
@@ -505,7 +630,6 @@ export function createApi(deps = {}) {
|
|
|
505
630
|
// 宿主活着时绝不走这条:共享文件是宿主的派生镜像,绕过它直写会被下一次 syncConfigFile 盖掉。
|
|
506
631
|
let appliedDirect = false
|
|
507
632
|
if (outcome === 'queued') {
|
|
508
|
-
const { hostAlive } = await import('./host-heartbeat.js')
|
|
509
633
|
if (!hostAlive(process.env)) {
|
|
510
634
|
try {
|
|
511
635
|
applySharedConfigDirect(process.env, clean)
|
|
@@ -518,17 +642,24 @@ export function createApi(deps = {}) {
|
|
|
518
642
|
const stale = before && (before.dropped || before.lastError)
|
|
519
643
|
const advice = describeApplyFailure(before && before.lastError)
|
|
520
644
|
const hostGuard = advice.kind === 'host-guard'
|
|
645
|
+
// 上一份已经被这个宿主的守卫判死(dropped + host-guard)→ 这次也不能再空头承诺
|
|
646
|
+
// 「DSH 端会自动同步」:直说去哪儿改,否则用户会一直看到一个永远不会生效的「已排队」。
|
|
647
|
+
const knownGuard = Boolean(before && before.dropped && hostGuard)
|
|
521
648
|
const note = outcome === 'applied'
|
|
522
|
-
? '
|
|
649
|
+
? '宿主已在请求上下文里应用该改动(点保存即生效)。'
|
|
523
650
|
: outcome === 'applied-direct'
|
|
524
651
|
? '本机没有 DSH 宿主在运行,已直接写入共享配置(hooks / MCP / 独立页立即生效)。'
|
|
525
652
|
: outcome === 'failed'
|
|
526
|
-
? '改动已写入,但宿主侧应用失败:' + (hostGuard ? advice.hint : '详见「反馈异常」里的诊断信息。')
|
|
527
|
-
:
|
|
653
|
+
? '改动已写入,但宿主侧应用失败:' + (hostDrainError || (hostGuard ? advice.hint : '详见「反馈异常」里的诊断信息。'))
|
|
654
|
+
: knownGuard
|
|
655
|
+
? '这个宿主不允许插件从回调上下文写配置(与配置内容无关):请到「DSH 设置 → 记忆」里改同一项。本次改动已在独立端重新排队,宿主升级/重启后仍会自动应用。'
|
|
656
|
+
: '已写入待应用:DSH 端会自动同步(DSH 未运行时下次启动生效)'
|
|
528
657
|
return json(res, 200, {
|
|
529
|
-
ok: true, applied: Object.keys(clean), pending: appliedDirect ? [] : Object.keys(clean), retried: false, at: out.at,
|
|
658
|
+
ok: true, applied: Object.keys(clean), pending: (appliedDirect || outcome === 'applied') ? [] : Object.keys(clean), retried: false, at: out.at,
|
|
530
659
|
pendingOutcome: outcome,
|
|
531
660
|
appliedDirect,
|
|
661
|
+
// 方案 A 的现场:宿主叫醒失败的原因(旧宿主没路由 / 令牌不匹配 / 应用报错)如实回给界面
|
|
662
|
+
hostDrainError: hostDrainError || undefined,
|
|
532
663
|
hostGuard: hostGuard || undefined,
|
|
533
664
|
hint: hostGuard ? advice.hint : undefined,
|
|
534
665
|
warning: stale
|