memory-eternal 0.10.5 → 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.
- 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 +113 -21
- package/lib/api.js +125 -8
- package/lib/client.js +17 -17
- package/lib/config-sync.js +24 -0
- package/lib/db.js +359 -359
- package/lib/host-heartbeat.js +72 -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 +330 -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
|
@@ -76,6 +76,13 @@ export const Config = z.object({
|
|
|
76
76
|
recallLimit: z.number().min(1).max(20).default(5),
|
|
77
77
|
recallSummaryLen: z.number().min(40).max(400).default(130),
|
|
78
78
|
recallIncludeBody: z.boolean().default(false),
|
|
79
|
+
// 凭证取用提示(默认空 = 不注入):写进来的一整句会被追加到每个会话的 systemPrompt 段,
|
|
80
|
+
// 用来让 agent「需要 API key / token 时先想到去查记忆里的目录卡」。
|
|
81
|
+
// 为什么做成配置而不是写死:默认值必须对**所有**用户安全 —— 插件本身不假定任何本机
|
|
82
|
+
// 密钥布局(也不该在公开包里硬编码某台机器的路径);有需要的用户填一句自己的约定即可。
|
|
83
|
+
// 参考句式:需要 API key/token 时先查记忆目录卡(memory_recall「密钥 目录」),
|
|
84
|
+
// 再按卡片给的本机命令取用;禁止直接读凭据文件、禁止把值打印或写进卡片/代码。
|
|
85
|
+
secretHint: z.string().default(''),
|
|
79
86
|
// 多 Vault / 多 Profile:命名分库,当前激活一个
|
|
80
87
|
vaultProfiles: z.array(z.object({ name: z.string(), path: z.string() })).default([]),
|
|
81
88
|
activeVault: z.string().default(''),
|
|
@@ -99,6 +106,12 @@ export const Config = z.object({
|
|
|
99
106
|
// **v0.6.0 起默认 true**——DSH 进程内 setInterval 在 DSH 退出后失效;
|
|
100
107
|
// 常驻 web 场景需要独立 watchdog;代价是 ~47 MB 额外常驻内存。
|
|
101
108
|
watchdogAutoSpawn: z.boolean().default(true),
|
|
109
|
+
// 版本漂移自愈(默认开):常驻 web(7999)比 DSH 活得久 —— 升级 npm 包只换磁盘文件,
|
|
110
|
+
// 端口上那个进程仍跑着启动时加载的旧代码,而「同端口已有活着的 watchdog 就让位」的策略
|
|
111
|
+
// 让重启 DSH 永远换不掉它(issue #19/#23)。开启后,激活时若发现**端口上真正服务的版本
|
|
112
|
+
// ≠ 本机磁盘版本**,就用新代码重启常驻实例(delegate → restart),而不是继续委派给旧实例。
|
|
113
|
+
// 关掉只是不自动做,面板上的「重启常驻实例」按钮与 dsh-memory restart 始终可用。
|
|
114
|
+
autoRestartOnDrift: z.boolean().default(true),
|
|
102
115
|
// 回收站保留天数:软删卡超过此天数自动永久删除(默认 30)
|
|
103
116
|
recycleRetentionDays: z.number().min(1).max(3650).default(30),
|
|
104
117
|
// 自动审核配置
|
|
@@ -361,7 +374,10 @@ export function apply(ctx, config) {
|
|
|
361
374
|
? '独立 Web 端的配置改动连续失败已放弃(文件保留以便排查):' + msg
|
|
362
375
|
: '独立 Web 端的配置改动应用失败,将重试:' + msg
|
|
363
376
|
try { console.error('[memory-eternal] ' + why) } catch { /* 日志失败无妨 */ }
|
|
364
|
-
|
|
377
|
+
// 用专用动作而不是 'fail':logCapture 里 `action==='fail'` 会把**自动沉淀健康状态**翻红,
|
|
378
|
+
// 于是「配置同步失败」会被念成「自动沉淀异常」(本会话实测:宿主重启前一直挂着这条假警报)。
|
|
379
|
+
// 配置同步是另一个子系统:照样写进日志/诊断信息,但不冒充沉淀管线坏了。
|
|
380
|
+
try { logCapture('system', dropped ? 'config-fail' : 'config-warn', why) } catch { /* 日志失败无妨 */ }
|
|
365
381
|
}
|
|
366
382
|
// issue #21:dsh-tui 一类宿主的能力守卫会在 settings.update **内部**的 describe() 上抛
|
|
367
383
|
// 「root.events.emit is unavailable from a plugin activation」(守卫只区分「第三方插件 vs
|
|
@@ -374,8 +390,18 @@ export function apply(ctx, config) {
|
|
|
374
390
|
// AsyncLocalStorage.run(token) 包住每次插件回调,并 patch 了 Fiber._execute,定时器 /
|
|
375
391
|
// fs.watch 的每一次回调都带着 activation token(见 ccch1mneyyy/dsh-TUI#1348 的定位)。
|
|
376
392
|
const applyPendingPatch = async (patch) => {
|
|
377
|
-
const { applyPatchVerified } = await import('./lib/config-sync.js')
|
|
378
|
-
|
|
393
|
+
const { applyPatchVerified, filterKnownKeys } = await import('./lib/config-sync.js')
|
|
394
|
+
// 只把**本宿主 schema 里存在的键**交给 settings.update。独立 Web 端(新版)提交的是整张表单,
|
|
395
|
+
// 升级期它可能带上旧宿主还不认识的键(本会话实测:新独立页提交含 secretHint 的整包 → 旧宿主
|
|
396
|
+
// 按白名单丢掉 secretHint → 回读校验必然对不上 → 连续失败 5 次标 dropped,用户还会看到
|
|
397
|
+
// 「HMR transactions cannot be nested」这种与配置内容无关的报错)。过滤后:旧宿主只应用它认识的
|
|
398
|
+
// 那部分,不认识的记一行日志等宿主升级后再生效。
|
|
399
|
+
const { known: filtered, ignored } = filterKnownKeys(patch, Object.keys(Config.dict || {}))
|
|
400
|
+
if (ignored.length) {
|
|
401
|
+
try { console.error('[memory-eternal] 待应用配置里有本宿主不认识的键(宿主升级后生效):' + ignored.join(',')) } catch { /* 日志失败无妨 */ }
|
|
402
|
+
}
|
|
403
|
+
if (!Object.keys(filtered).length) return { applied: true, repaired: false, ignored }
|
|
404
|
+
const out = await applyPatchVerified(filtered, {
|
|
379
405
|
apply: (p) => settings.update(p, undefined),
|
|
380
406
|
read: () => settings.get() ?? {},
|
|
381
407
|
onRepaired: (error) => {
|
|
@@ -384,6 +410,7 @@ export function apply(ctx, config) {
|
|
|
384
410
|
} catch { /* 日志失败无妨 */ }
|
|
385
411
|
},
|
|
386
412
|
})
|
|
413
|
+
return { ...out, ignored }
|
|
387
414
|
}
|
|
388
415
|
const drainPending = async () => {
|
|
389
416
|
try {
|
|
@@ -448,6 +475,24 @@ export function apply(ctx, config) {
|
|
|
448
475
|
return roots
|
|
449
476
|
}
|
|
450
477
|
// 把完整配置写入共享文件,使独立 web / MCP hook 捕获与 DSH 设置同步(不同步修复)。
|
|
478
|
+
//
|
|
479
|
+
// 2026-10-07:这里原来是 `catch { /* 静默 */ }` —— 正是本项目一直在消灭的静默吞错。它写不进去
|
|
480
|
+
// 的后果很隐蔽:独立页 / hooks / MCP 读的都是这个文件,于是表现为「配置改了但他们看不到」
|
|
481
|
+
// (实测到过一次:共享文件的 mtime 停在激活前,而宿主里的值已经变了)。现在失败一律可见。
|
|
482
|
+
let lastSyncError = ''
|
|
483
|
+
const reportSyncFailure = (reason) => {
|
|
484
|
+
const why = String(reason || '')
|
|
485
|
+
if (!why || why === lastSyncError) return // 同一原因只报一次,避免 5 秒轮询刷屏
|
|
486
|
+
lastSyncError = why
|
|
487
|
+
// 必须延迟一拍:本函数可能在 apply() 的**同步**阶段被调用,而 logCapture 定义在更后面
|
|
488
|
+
// (const 的 TDZ 会直接抛 ReferenceError)。setImmediate 之后整个 apply() 已执行完毕。
|
|
489
|
+
const emit = () => {
|
|
490
|
+
try { console.error('[memory-eternal] 共享配置文件同步失败(独立页 / hooks / MCP 读到的是旧值):' + why) } catch { /* 无妨 */ }
|
|
491
|
+
try { logCapture('system', 'warn', '共享配置文件同步失败(独立页 / hooks / MCP 会读到旧值):' + why) } catch { /* 无妨 */ }
|
|
492
|
+
}
|
|
493
|
+
if (typeof setImmediate === 'function') setImmediate(emit)
|
|
494
|
+
else setTimeout(emit, 0)
|
|
495
|
+
}
|
|
451
496
|
const syncConfigFile = async () => {
|
|
452
497
|
try {
|
|
453
498
|
const cfg = settings.get() ?? {}
|
|
@@ -456,7 +501,10 @@ export function apply(ctx, config) {
|
|
|
456
501
|
// 原子写(tmp + rename,见 lib/config-sync.js 里 writeFileAtomicSync 的说明):
|
|
457
502
|
// 这个文件是跨进程共享的读源,非原子写会让独立 web / MCP 读到半截内容并静默回落成默认值。
|
|
458
503
|
writeFileAtomicSync(configFilePath(process.env), JSON.stringify(cfg, null, 2))
|
|
459
|
-
|
|
504
|
+
lastSyncError = ''
|
|
505
|
+
} catch (error) {
|
|
506
|
+
reportSyncFailure(error && error.message ? error.message : error)
|
|
507
|
+
}
|
|
460
508
|
}
|
|
461
509
|
syncConfigFile()
|
|
462
510
|
// agent/turn-stopping 是 serial 事件:不在监听器里 await LLM(会拖慢收尾),
|
|
@@ -791,6 +839,11 @@ export function apply(ctx, config) {
|
|
|
791
839
|
...((cfg.auditMode ?? 'all') === 'none' ? [] : [
|
|
792
840
|
'6. **审核红线(强制)**:写卡一律停在 pending,由用户在「审核中心」审批。**禁止**调用 /memory-eternal/api/audit/approve 或 /audit/reject 代替用户审批,也不得用任何等价方式绕过审核;用户说「记录一下 / 增加记忆」不等于允许免审入库。需要立刻可用时,写卡后明确告知用户「已进审核中心,待批准」。',
|
|
793
841
|
]),
|
|
842
|
+
// 凭证取用提示(用户自己填的整句,见 Config.secretHint):只在填了非空值时注入。
|
|
843
|
+
// 为什么走 systemPrompt 而不是「往卡里写值」:密钥的**值**入库会被每次召回带进模型
|
|
844
|
+
// 上下文(card.summary = 正文前 200 字,召回默认取前 130 字),而 agent 真正需要的
|
|
845
|
+
// 只是「去哪取、怎么取」——这句负责**触发**,目录卡负责细节,值留在本机凭据库。
|
|
846
|
+
...((cfg.secretHint || '').trim() ? [String(cfg.secretHint).trim()] : []),
|
|
794
847
|
].join('\n')
|
|
795
848
|
// 异常提示:沉淀管线坏了,靠这一句把消息送到用户面前(不依赖用户去翻页面)。
|
|
796
849
|
const h = health.snapshot()
|
|
@@ -822,7 +875,9 @@ export function apply(ctx, config) {
|
|
|
822
875
|
name: 'memory_recall',
|
|
823
876
|
description:
|
|
824
877
|
'从本地记忆核心(Markdown 知识库)检索相关知识卡。需要项目背景、历史决策、之前讨论过的方案、' +
|
|
825
|
-
'或领域知识时调用;返回最相关的卡片摘要。用 query 描述要找的内容,支持中文整词与字符片段检索。'
|
|
878
|
+
'或领域知识时调用;返回最相关的卡片摘要。用 query 描述要找的内容,支持中文整词与字符片段检索。' +
|
|
879
|
+
'需要 API key / token / 令牌等**凭证**时也先查这里(查「密钥 目录」):记忆里存的是目录' +
|
|
880
|
+
'(名字、存放位置、取用命令),**值不在记忆库里**,请按卡片给的本机命令取用,不要直接读凭据文件。',
|
|
826
881
|
parameters: {
|
|
827
882
|
query: { type: 'string', required: true, description: '检索关键词或自然语言描述,如「数据库选型」「用户偏好」' },
|
|
828
883
|
limit: { type: 'number', description: '返回卡片数上限,默认 5' },
|
|
@@ -1001,10 +1056,10 @@ export function apply(ctx, config) {
|
|
|
1001
1056
|
const cfg = settings.get() ?? {}
|
|
1002
1057
|
// 只暴露可安全展示/回填的字段
|
|
1003
1058
|
const safe = {
|
|
1004
|
-
autoCapture: cfg.autoCapture, autoRecall: cfg.autoRecall, recallLimit: cfg.recallLimit, recallSummaryLen: cfg.recallSummaryLen, recallIncludeBody: cfg.recallIncludeBody,
|
|
1059
|
+
autoCapture: cfg.autoCapture, autoRecall: cfg.autoRecall, recallLimit: cfg.recallLimit, recallSummaryLen: cfg.recallSummaryLen, recallIncludeBody: cfg.recallIncludeBody, secretHint: cfg.secretHint || '',
|
|
1005
1060
|
captureMinChars: cfg.captureMinChars, captureCooldownMs: cfg.captureCooldownMs, dedupThreshold: cfg.dedupThreshold, maxCardsPerDay: cfg.maxCardsPerDay,
|
|
1006
1061
|
distillEnabled: cfg.distillEnabled, dedupByLLM: cfg.dedupByLLM, captureMaxTokens: cfg.captureMaxTokens, recallMinScore: cfg.recallMinScore,
|
|
1007
|
-
autoWeb: cfg.autoWeb, autoWebMode: cfg.autoWebMode, webPort: cfg.webPort, webCheckIntervalMs: cfg.webCheckIntervalMs, webMaxRestart: cfg.webMaxRestart, watchdogAutoSpawn: cfg.watchdogAutoSpawn, autoMcpSetup: cfg.autoMcpSetup,
|
|
1062
|
+
autoWeb: cfg.autoWeb, autoWebMode: cfg.autoWebMode, webPort: cfg.webPort, webCheckIntervalMs: cfg.webCheckIntervalMs, webMaxRestart: cfg.webMaxRestart, watchdogAutoSpawn: cfg.watchdogAutoSpawn, autoRestartOnDrift: cfg.autoRestartOnDrift, autoMcpSetup: cfg.autoMcpSetup,
|
|
1008
1063
|
auditMode: cfg.auditMode ?? 'all', auditExemptAgents: cfg.auditExemptAgents || [], auditExemptKinds: cfg.auditExemptKinds || [], recycleRetentionDays: cfg.recycleRetentionDays ?? 30,
|
|
1009
1064
|
// 多库(#10):配置页可直接编辑
|
|
1010
1065
|
vaultProfiles: Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : [],
|
|
@@ -1067,7 +1122,7 @@ export function apply(ctx, config) {
|
|
|
1067
1122
|
retried: !!(writeResult && writeResult.retried),
|
|
1068
1123
|
note: pending.length
|
|
1069
1124
|
? '已写入;宿主尚未回流这些值(面板已本地生效,重启 DSH 后以配置文件为准)'
|
|
1070
|
-
: '已保存。autoWebMode/watchdogAutoSpawn 等需重启 DSH 生效;若已有常驻 watchdog,关闭/改参不会自动停掉它,需 dsh-memory stop / restart(#19
|
|
1125
|
+
: '已保存。autoWebMode/watchdogAutoSpawn/webPort 等需重启 DSH 生效;若已有常驻 watchdog,关闭/改参不会自动停掉它,需 dsh-memory stop / restart(#19)。版本漂移(升级后端口上仍是旧代码)由 autoRestartOnDrift 在下次激活时自动重启常驻实例,也可在「插件信息」点「重启常驻实例」立刻处理',
|
|
1071
1126
|
})
|
|
1072
1127
|
} catch (e) {
|
|
1073
1128
|
if (e && e.code === 'SETTINGS_CONFLICT') return json(res, 409, { ok: false, error: '配置已被外部修改,请刷新后重试(revision conflict)' })
|
|
@@ -1183,22 +1238,59 @@ export function apply(ctx, config) {
|
|
|
1183
1238
|
const wdPort = Number(cfg0.webPort) || 7999
|
|
1184
1239
|
if (cfg0.watchdogAutoSpawn === true && cfg0.autoWeb !== false) {
|
|
1185
1240
|
import('./lib/watchdog.js')
|
|
1186
|
-
.then((m) => {
|
|
1187
|
-
//
|
|
1188
|
-
//
|
|
1189
|
-
//
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1241
|
+
.then(async (m) => {
|
|
1242
|
+
// 先体检再决策(issue #19/#23):同端口已有**活着的** watchdog 时不再 spawn ——
|
|
1243
|
+
// 旧实现每次都 spawn 一个、每次都打印 "spawned",而新进程会在锁上让位退出,
|
|
1244
|
+
// 日志与实际状态完全相反。但「有实例在跑」不等于「跑的是新代码」:常驻 web 比
|
|
1245
|
+
// DSH 活得久,升级只换磁盘文件,所以这里问的是**端口上真正服务的版本**,
|
|
1246
|
+
// 漂移就用新代码重启它(autoRestartOnDrift),而不是继续委派给旧代码。
|
|
1247
|
+
const info = await m.inspectResident({ port: wdPort })
|
|
1248
|
+
const cur = m.currentPkgVersion()
|
|
1249
|
+
const decision = m.decideResidentAction({
|
|
1250
|
+
served: info.served,
|
|
1251
|
+
expect: cur,
|
|
1252
|
+
watchdogAlive: info.watchdogAlive,
|
|
1253
|
+
occupantIsOurs: info.occupantIsOurs,
|
|
1254
|
+
autoRestart: cfg0.autoRestartOnDrift !== false,
|
|
1255
|
+
residentStartedAt: info.residentStartedAt,
|
|
1256
|
+
})
|
|
1257
|
+
const servedLabel = info.served || '(旧版不自报版本)'
|
|
1258
|
+
|
|
1259
|
+
if (decision.action === 'delegate') {
|
|
1260
|
+
const who = info.watchdog ? `pid=${info.watchdog.pid}` : '外部实例'
|
|
1261
|
+
const why = `watchdog delegated to ${who} port=${wdPort}(端口上服务 v${servedLabel},本机 v${cur || '?'}${decision.reason === 'debounced' ? ';刚替换过,防抖窗口内不重复重启' : ''})`
|
|
1198
1262
|
console.error(`[memory-eternal] ${why}`)
|
|
1199
|
-
logCapture('system',
|
|
1263
|
+
logCapture('system', 'boot', why)
|
|
1200
1264
|
return
|
|
1201
1265
|
}
|
|
1266
|
+
|
|
1267
|
+
if (decision.action === 'restart') {
|
|
1268
|
+
// 「更新运行中的程序」的唯一安全形态 = 用新代码重启那个常驻进程(不是热替换内存里的模块)
|
|
1269
|
+
const why = `版本漂移:端口 ${wdPort} 上服务 v${servedLabel} ≠ 本机 v${cur}(${decision.reason})→ 用新代码重启常驻实例`
|
|
1270
|
+
console.error(`[memory-eternal] ${why}`)
|
|
1271
|
+
logCapture('system', 'warn', why)
|
|
1272
|
+
const out = await m.restartResident({
|
|
1273
|
+
port: wdPort,
|
|
1274
|
+
vaultRoot: vaultDir(),
|
|
1275
|
+
interval: Number(cfg0.webCheckIntervalMs) || 5000,
|
|
1276
|
+
maxRestart: Number(cfg0.webMaxRestart) || 10,
|
|
1277
|
+
})
|
|
1278
|
+
const done = out.ok
|
|
1279
|
+
? `常驻实例已重启:端口 ${wdPort} 正在服务 v${out.served}(等待 ${Math.round(out.waitedMs / 1000)}s)`
|
|
1280
|
+
: `常驻实例重启未确认生效(${out.stage}):${out.error || '未知原因'} —— 可再试「设置 → 记忆 → 插件信息 → 重启常驻实例」,或执行 dsh-memory restart --port ${wdPort}`
|
|
1281
|
+
console.error(`[memory-eternal] ${done}`)
|
|
1282
|
+
logCapture('system', out.ok ? 'boot' : 'fail', done)
|
|
1283
|
+
return
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
if (decision.action === 'warn') {
|
|
1287
|
+
const why = `常驻实例版本漂移(端口上服务 v${servedLabel} ≠ 本机 v${cur}),但 autoRestartOnDrift 已关闭 → 不自动重启;可点面板「重启常驻实例」或执行 dsh-memory restart --port ${wdPort}`
|
|
1288
|
+
console.error(`[memory-eternal] ${why}`)
|
|
1289
|
+
logCapture('system', 'warn', why)
|
|
1290
|
+
return
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// 端口上没有我们的服务(也没人占着)→ 照常拉起一个常驻 watchdog
|
|
1202
1294
|
const port = wdPort
|
|
1203
1295
|
// 用 nodeBinary() 而非 process.execPath:Electron 宿主下后者是 Electron
|
|
1204
1296
|
// 主程序,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,18 +213,78 @@ 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}`,
|
|
174
238
|
})
|
|
175
239
|
return
|
|
176
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)。`,
|
|
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)
|
|
286
|
+
return
|
|
287
|
+
}
|
|
177
288
|
case '/diagnostics': {
|
|
178
289
|
// 「反馈异常」用:返回一段可直接贴进 issue 的**已脱敏**诊断文本。
|
|
179
290
|
// 宿主与独立 web 共用这个实现;home 目录、各类 key/token 全都会被替换掉。
|
|
@@ -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 || '',
|
|
@@ -518,13 +630,18 @@ export function createApi(deps = {}) {
|
|
|
518
630
|
const stale = before && (before.dropped || before.lastError)
|
|
519
631
|
const advice = describeApplyFailure(before && before.lastError)
|
|
520
632
|
const hostGuard = advice.kind === 'host-guard'
|
|
633
|
+
// 上一份已经被这个宿主的守卫判死(dropped + host-guard)→ 这次也不能再空头承诺
|
|
634
|
+
// 「DSH 端会自动同步」:直说去哪儿改,否则用户会一直看到一个永远不会生效的「已排队」。
|
|
635
|
+
const knownGuard = Boolean(before && before.dropped && hostGuard)
|
|
521
636
|
const note = outcome === 'applied'
|
|
522
637
|
? '宿主已应用该改动。'
|
|
523
638
|
: outcome === 'applied-direct'
|
|
524
639
|
? '本机没有 DSH 宿主在运行,已直接写入共享配置(hooks / MCP / 独立页立即生效)。'
|
|
525
640
|
: outcome === 'failed'
|
|
526
641
|
? '改动已写入,但宿主侧应用失败:' + (hostGuard ? advice.hint : '详见「反馈异常」里的诊断信息。')
|
|
527
|
-
:
|
|
642
|
+
: knownGuard
|
|
643
|
+
? '这个宿主不允许插件从回调上下文写配置(与配置内容无关):请到「DSH 设置 → 记忆」里改同一项。本次改动已在独立端重新排队,宿主升级/重启后仍会自动应用。'
|
|
644
|
+
: '已写入待应用:DSH 端会自动同步(DSH 未运行时下次启动生效)'
|
|
528
645
|
return json(res, 200, {
|
|
529
646
|
ok: true, applied: Object.keys(clean), pending: appliedDirect ? [] : Object.keys(clean), retried: false, at: out.at,
|
|
530
647
|
pendingOutcome: outcome,
|