memory-eternal 0.10.6 → 0.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hooks/capture.mjs CHANGED
@@ -1,77 +1,77 @@
1
- #!/usr/bin/env node
2
- // 记忆核心 · 通用「会话结束自动沉淀」hook(claude-code / codex / cursor 共用)。
3
- //
4
- // 用法:node capture.mjs <agent> agent ∈ claude-code | codex | cursor
5
- // stdin(平台 hook 注入):
6
- // { session_id, transcript_path, hook_event_name, cwd, last_assistant_message, prompt, ... }
7
- //
8
- // 行为:读 transcript(JSONL/JSON 兼容)或显式文本字段 → 调 runStandaloneCapture 蒸馏入库。
9
- // 设计约束:
10
- // - 静默失败(hook 崩溃不能影响宿主 agent)
11
- // - 幂等(去重/追加在 runStandaloneCapture 内处理)
12
- // - 署名 source = agent 名,便于智能体筛选;vault 由 MEMORY_VAULT_DIR 或 ~/.dsh/memory-vault 决定(与全库统一)
13
- //
14
- // Windows 友好:纯 node,无外部依赖(同 claude-session.js)。
15
-
16
- import { promises as fs } from 'node:fs'
17
-
18
- const agent = process.argv[2] || 'agent'
19
-
20
- const readStdin = () => new Promise((resolve) => {
21
- let buf = ''
22
- process.stdin.setEncoding('utf8')
23
- process.stdin.on('data', (c) => { buf += c })
24
- process.stdin.on('end', () => resolve(buf))
25
- setTimeout(() => resolve(buf), 2000)
26
- })
27
-
28
- /** 从 message 对象里抽可读文本(兼容 claude 的 {role,content} 与 codex/cursor 的 {text|content})。 */
29
- function textOf(msg) {
30
- if (!msg) return ''
31
- if (typeof msg === 'string') return msg
32
- const c = msg.content
33
- if (typeof c === 'string') return c
34
- if (Array.isArray(c)) return c.filter((x) => x && x.type === 'text').map((x) => x.text).join('\n')
35
- return msg.text || msg.message || ''
36
- }
37
-
38
- /** 读 transcript 文件并抽取最近对话文本。优先 JSONL 行解析,退回整体文本。 */
39
- async function extractTranscript(transcriptPath) {
40
- let text
41
- try { text = await fs.readFile(transcriptPath, 'utf8') } catch { return '' }
42
- if (!text.trim()) return ''
43
- text = text.replace(/^\uFEFF/, '') // 去掉可能存在的 UTF-8 BOM
44
- const lines = text.split('\n').filter(Boolean)
45
- const parts = []
46
- let structured = 0
47
- for (const line of lines.slice(-400)) {
48
- let rec
49
- try { rec = JSON.parse(line); structured++ } catch { continue }
50
- const t = textOf(rec?.message)
51
- if (t && t.trim().length > 4) {
52
- const role = rec?.message?.role || rec?.role || 'msg'
53
- parts.push(`${role}: ${t.trim()}`)
54
- }
55
- }
56
- if (structured >= 1 && parts.length) return parts.slice(-30).join('\n\n')
57
- // 非结构化:取尾部可读文本(去掉控制字符)
58
- return text.replace(/\r/g, '').replace(/\u0000/g, '').slice(-6000)
59
- }
60
-
61
- async function main() {
62
- let meta = {}
63
- try { meta = JSON.parse((await readStdin()) || '{}') } catch { /* 静默 */ }
64
-
65
- let conversation = ''
66
- if (meta.transcript_path) { try { conversation = await extractTranscript(meta.transcript_path) } catch { /* 静默 */ } }
67
- if (!conversation || conversation.trim().length < 120) {
68
- conversation = meta.last_assistant_message || meta.prompt || meta.context || ''
69
- }
70
- if (!conversation || conversation.trim().length < 120) return
71
-
72
- const { runStandaloneCapture, defaultVaultDir } = await import('../lib/capture-run.js')
73
- const src = agent // 署名 = agent 名(可筛选);若想保留会话粒度可用 `${agent}:${session_id}`
74
- await runStandaloneCapture(defaultVaultDir(), conversation, { source: src })
75
- }
76
-
77
- main().catch(() => { /* hook 静默失败 */ })
1
+ #!/usr/bin/env node
2
+ // 记忆核心 · 通用「会话结束自动沉淀」hook(claude-code / codex / cursor 共用)。
3
+ //
4
+ // 用法:node capture.mjs <agent> agent ∈ claude-code | codex | cursor
5
+ // stdin(平台 hook 注入):
6
+ // { session_id, transcript_path, hook_event_name, cwd, last_assistant_message, prompt, ... }
7
+ //
8
+ // 行为:读 transcript(JSONL/JSON 兼容)或显式文本字段 → 调 runStandaloneCapture 蒸馏入库。
9
+ // 设计约束:
10
+ // - 静默失败(hook 崩溃不能影响宿主 agent)
11
+ // - 幂等(去重/追加在 runStandaloneCapture 内处理)
12
+ // - 署名 source = agent 名,便于智能体筛选;vault 由 MEMORY_VAULT_DIR 或 ~/.dsh/memory-vault 决定(与全库统一)
13
+ //
14
+ // Windows 友好:纯 node,无外部依赖(同 claude-session.js)。
15
+
16
+ import { promises as fs } from 'node:fs'
17
+
18
+ const agent = process.argv[2] || 'agent'
19
+
20
+ const readStdin = () => new Promise((resolve) => {
21
+ let buf = ''
22
+ process.stdin.setEncoding('utf8')
23
+ process.stdin.on('data', (c) => { buf += c })
24
+ process.stdin.on('end', () => resolve(buf))
25
+ setTimeout(() => resolve(buf), 2000)
26
+ })
27
+
28
+ /** 从 message 对象里抽可读文本(兼容 claude 的 {role,content} 与 codex/cursor 的 {text|content})。 */
29
+ function textOf(msg) {
30
+ if (!msg) return ''
31
+ if (typeof msg === 'string') return msg
32
+ const c = msg.content
33
+ if (typeof c === 'string') return c
34
+ if (Array.isArray(c)) return c.filter((x) => x && x.type === 'text').map((x) => x.text).join('\n')
35
+ return msg.text || msg.message || ''
36
+ }
37
+
38
+ /** 读 transcript 文件并抽取最近对话文本。优先 JSONL 行解析,退回整体文本。 */
39
+ async function extractTranscript(transcriptPath) {
40
+ let text
41
+ try { text = await fs.readFile(transcriptPath, 'utf8') } catch { return '' }
42
+ if (!text.trim()) return ''
43
+ text = text.replace(/^\uFEFF/, '') // 去掉可能存在的 UTF-8 BOM
44
+ const lines = text.split('\n').filter(Boolean)
45
+ const parts = []
46
+ let structured = 0
47
+ for (const line of lines.slice(-400)) {
48
+ let rec
49
+ try { rec = JSON.parse(line); structured++ } catch { continue }
50
+ const t = textOf(rec?.message)
51
+ if (t && t.trim().length > 4) {
52
+ const role = rec?.message?.role || rec?.role || 'msg'
53
+ parts.push(`${role}: ${t.trim()}`)
54
+ }
55
+ }
56
+ if (structured >= 1 && parts.length) return parts.slice(-30).join('\n\n')
57
+ // 非结构化:取尾部可读文本(去掉控制字符)
58
+ return text.replace(/\r/g, '').replace(/\u0000/g, '').slice(-6000)
59
+ }
60
+
61
+ async function main() {
62
+ let meta = {}
63
+ try { meta = JSON.parse((await readStdin()) || '{}') } catch { /* 静默 */ }
64
+
65
+ let conversation = ''
66
+ if (meta.transcript_path) { try { conversation = await extractTranscript(meta.transcript_path) } catch { /* 静默 */ } }
67
+ if (!conversation || conversation.trim().length < 120) {
68
+ conversation = meta.last_assistant_message || meta.prompt || meta.context || ''
69
+ }
70
+ if (!conversation || conversation.trim().length < 120) return
71
+
72
+ const { runStandaloneCapture, defaultVaultDir } = await import('../lib/capture-run.js')
73
+ const src = agent // 署名 = agent 名(可筛选);若想保留会话粒度可用 `${agent}:${session_id}`
74
+ await runStandaloneCapture(defaultVaultDir(), conversation, { source: src })
75
+ }
76
+
77
+ main().catch(() => { /* hook 静默失败 */ })
@@ -1,56 +1,56 @@
1
- #!/usr/bin/env node
2
- // 记忆核心 · Claude Code SessionEnd/PreCompact hook(Windows 友好,纯 node)。
3
- //
4
- // Claude Code 在会话结束/压缩前触发,stdin 传入 JSON:
5
- // { session_id, transcript_path, hook_event_name, cwd, ... }
6
- // 本脚本解析 transcript JSONL,提取最近若干轮 user/assistant 文本,蒸馏入库。
7
- // 由 lib/setup.js 自动注册到 ~/.claude/settings.json 的 hooks.SessionEnd。
8
- //
9
- // 特性:静默失败(hook 崩溃不能影响 Claude Code);幂等(重复跑同一段只追加)。
10
-
11
- import { promises as fs } from 'node:fs'
12
- import path from 'node:path'
13
-
14
- async function main() {
15
- let input = {}
16
- try {
17
- const raw = await new Promise((resolve) => {
18
- let buf = ''
19
- process.stdin.setEncoding('utf8')
20
- process.stdin.on('data', (c) => { buf += c })
21
- process.stdin.on('end', () => resolve(buf))
22
- setTimeout(() => resolve(buf), 2000)
23
- })
24
- input = JSON.parse(raw || '{}')
25
- } catch { /* 静默 */ }
26
-
27
- const transcriptPath = input.transcript_path
28
- if (!transcriptPath) return
29
- let lines
30
- try { lines = (await fs.readFile(transcriptPath, 'utf8')).split('\n').filter(Boolean) } catch { return }
31
-
32
- // 提取尾部对话文本(user 与 assistant 的 message.content 文本部分)
33
- const texts = []
34
- for (const line of lines.slice(-400)) {
35
- let rec
36
- try { rec = JSON.parse(line) } catch { continue }
37
- const msg = rec?.message
38
- if (!msg || !msg.role) continue
39
- const content = msg.content
40
- let text = ''
41
- if (typeof content === 'string') text = content
42
- else if (Array.isArray(content)) {
43
- text = content.filter((c) => c?.type === 'text').map((c) => c.text).join('\n')
44
- }
45
- if (text && text.trim().length > 4) texts.push(`${msg.role}: ${text.trim()}`)
46
- }
47
- const conversation = texts.slice(-30).join('\n\n')
48
- if (conversation.trim().length < 120) return
49
-
50
- const { runStandaloneCapture, defaultVaultDir } = await import('../lib/capture-run.js')
51
- await runStandaloneCapture(defaultVaultDir(), conversation, {
52
- source: `claude-code:${input.session_id || path.basename(transcriptPath)}`,
53
- })
54
- }
55
-
56
- main().catch(() => { /* hook 静默失败 */ })
1
+ #!/usr/bin/env node
2
+ // 记忆核心 · Claude Code SessionEnd/PreCompact hook(Windows 友好,纯 node)。
3
+ //
4
+ // Claude Code 在会话结束/压缩前触发,stdin 传入 JSON:
5
+ // { session_id, transcript_path, hook_event_name, cwd, ... }
6
+ // 本脚本解析 transcript JSONL,提取最近若干轮 user/assistant 文本,蒸馏入库。
7
+ // 由 lib/setup.js 自动注册到 ~/.claude/settings.json 的 hooks.SessionEnd。
8
+ //
9
+ // 特性:静默失败(hook 崩溃不能影响 Claude Code);幂等(重复跑同一段只追加)。
10
+
11
+ import { promises as fs } from 'node:fs'
12
+ import path from 'node:path'
13
+
14
+ async function main() {
15
+ let input = {}
16
+ try {
17
+ const raw = await new Promise((resolve) => {
18
+ let buf = ''
19
+ process.stdin.setEncoding('utf8')
20
+ process.stdin.on('data', (c) => { buf += c })
21
+ process.stdin.on('end', () => resolve(buf))
22
+ setTimeout(() => resolve(buf), 2000)
23
+ })
24
+ input = JSON.parse(raw || '{}')
25
+ } catch { /* 静默 */ }
26
+
27
+ const transcriptPath = input.transcript_path
28
+ if (!transcriptPath) return
29
+ let lines
30
+ try { lines = (await fs.readFile(transcriptPath, 'utf8')).split('\n').filter(Boolean) } catch { return }
31
+
32
+ // 提取尾部对话文本(user 与 assistant 的 message.content 文本部分)
33
+ const texts = []
34
+ for (const line of lines.slice(-400)) {
35
+ let rec
36
+ try { rec = JSON.parse(line) } catch { continue }
37
+ const msg = rec?.message
38
+ if (!msg || !msg.role) continue
39
+ const content = msg.content
40
+ let text = ''
41
+ if (typeof content === 'string') text = content
42
+ else if (Array.isArray(content)) {
43
+ text = content.filter((c) => c?.type === 'text').map((c) => c.text).join('\n')
44
+ }
45
+ if (text && text.trim().length > 4) texts.push(`${msg.role}: ${text.trim()}`)
46
+ }
47
+ const conversation = texts.slice(-30).join('\n\n')
48
+ if (conversation.trim().length < 120) return
49
+
50
+ const { runStandaloneCapture, defaultVaultDir } = await import('../lib/capture-run.js')
51
+ await runStandaloneCapture(defaultVaultDir(), conversation, {
52
+ source: `claude-code:${input.session_id || path.basename(transcriptPath)}`,
53
+ })
54
+ }
55
+
56
+ main().catch(() => { /* hook 静默失败 */ })
@@ -1,12 +1,23 @@
1
- {
2
- "SessionEnd": [
3
- {
4
- "hooks": [
5
- {
6
- "type": "command",
7
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/capture.mjs\" claude-code"
8
- }
9
- ]
10
- }
11
- ]
12
- }
1
+ {
2
+ "SessionEnd": [
3
+ {
4
+ "hooks": [
5
+ {
6
+ "type": "command",
7
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/capture.mjs\" claude-code"
8
+ }
9
+ ]
10
+ }
11
+ ],
12
+ "PreToolUse": [
13
+ {
14
+ "matcher": "Read|Bash|Grep|Glob",
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard.mjs\""
19
+ }
20
+ ]
21
+ }
22
+ ]
23
+ }
@@ -1,15 +1,15 @@
1
- {
2
- "hooks": {
3
- "Stop": [
4
- {
5
- "hooks": [
6
- {
7
- "type": "command",
8
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/capture.mjs\" codex",
9
- "statusMessage": "memory-eternal: saving session"
10
- }
11
- ]
12
- }
13
- ]
14
- }
15
- }
1
+ {
2
+ "hooks": {
3
+ "Stop": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/capture.mjs\" codex",
9
+ "statusMessage": "memory-eternal: saving session"
10
+ }
11
+ ]
12
+ }
13
+ ]
14
+ }
15
+ }
@@ -1,11 +1,11 @@
1
- {
2
- "version": 1,
3
- "hooks": {
4
- "stop": [
5
- { "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/capture.mjs\" cursor" }
6
- ],
7
- "sessionEnd": [
8
- { "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/capture.mjs\" cursor" }
9
- ]
10
- }
11
- }
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "stop": [
5
+ { "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/capture.mjs\" cursor" }
6
+ ],
7
+ "sessionEnd": [
8
+ { "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/capture.mjs\" cursor" }
9
+ ]
10
+ }
11
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // 记忆核心 · 凭据读取守卫(Claude Code PreToolUse hook,纯 node、静默失败)
3
+ //
4
+ // 为什么需要它:一旦 agent 知道「本机有密钥」,它很可能直接 `cat ~/.dsh/.credentials.yaml`
5
+ // 或 Read 那个文件 —— 值就进了它的上下文(再经 provider / 日志 / 蒸馏扩散出去)。
6
+ // 「知道去哪取」与「不许直接读」必须成对存在,否则索引反而放大了泄漏面。
7
+ //
8
+ // 生效条件(**默认不打扰任何人**):本机存在密钥加载器
9
+ // ~/.config/memory-eternal/secrets.py
10
+ // 没有它(其它用户 / 其它机器)这个 hook 直接放行,什么也不做。
11
+ //
12
+ // 命中判定:工具参数里出现下列文件(Read 的 file_path / Bash 的命令行 / Grep 的 pattern 等)
13
+ // .credentials.y(a)ml、~/.dsh/.env、gitee-token
14
+ // 命中即以 PreToolUse permissionDecision=deny 拒绝,并指路到 secrets.py --names / --run。
15
+ //
16
+ // 由 hooks/hooks.claude.json 注册(PreToolUse)。也可以手工测试:
17
+ // echo '{"tool_name":"Read","tool_input":{"file_path":"/x/.credentials.yaml"}}' | node hooks/secret-guard.mjs
18
+
19
+ import fs from 'node:fs'
20
+ import os from 'node:os'
21
+ import path from 'node:path'
22
+
23
+ const LOADER = path.join(os.homedir(), '.config', 'memory-eternal', 'secrets.py')
24
+ const SENSITIVE = [
25
+ /\.credentials\.ya?ml/i,
26
+ /[\\/]\.dsh[\\/]\.env(?:$|[\s"'])/i,
27
+ /[\\/]\.env(?:$|["'\s])/i, // 通用 .env(含值)——只在本机装了加载器时才拦
28
+ /gitee-token/i,
29
+ ]
30
+ const REASON = [
31
+ '这道读取被「记忆核心」的凭据守卫拦下了:该文件里是密钥**明文**,读进上下文就等于泄漏',
32
+ '(值经 provider / 日志 / 自动蒸馏会扩散到多处,且删除也无法收回)。',
33
+ '',
34
+ '正确做法:',
35
+ ' 1) 先列名字(不输出值):python ~/.config/memory-eternal/secrets.py --names',
36
+ ' 2) 再带值执行命令(值只进子进程环境变量):python ~/.config/memory-eternal/secrets.py --require <KEY> --run -- <命令>',
37
+ ' 3) 需要「有哪些键、放在哪、干什么用」的说明:memory_recall("密钥 目录")',
38
+ '',
39
+ '确实需要人工查看时,请在终端里自己打开该文件 —— 不要经由 Agent 的工具读取。',
40
+ ].join('\n')
41
+
42
+ async function readStdin() {
43
+ try {
44
+ return await new Promise((resolve) => {
45
+ let buf = ''
46
+ process.stdin.setEncoding('utf8')
47
+ process.stdin.on('data', (c) => { buf += c })
48
+ process.stdin.on('end', () => resolve(buf))
49
+ setTimeout(() => resolve(buf), 1500) // hook 不能挂住宿主
50
+ })
51
+ } catch { return '' }
52
+ }
53
+
54
+ function targetsCredential(input) {
55
+ if (!input || typeof input !== 'object') return false
56
+ const fields = ['file_path', 'path', 'notebook_path', 'command', 'pattern', 'glob', 'query', 'url', 'content']
57
+ const haystack = fields
58
+ .map((f) => (typeof input[f] === 'string' ? input[f] : ''))
59
+ .filter(Boolean)
60
+ .join('\n')
61
+ if (!haystack) return false
62
+ return SENSITIVE.some((re) => re.test(haystack))
63
+ }
64
+
65
+ async function main() {
66
+ if (!fs.existsSync(LOADER)) return // 没装加载器 → 不打扰
67
+ const raw = await readStdin()
68
+ let payload = null
69
+ try { payload = JSON.parse(raw || '{}') } catch { return }
70
+ const tool = String(payload?.tool_name || '')
71
+ if (!tool) return
72
+ if (!targetsCredential(payload.tool_input)) return
73
+ process.stdout.write(JSON.stringify({
74
+ hookSpecificOutput: {
75
+ hookEventName: 'PreToolUse',
76
+ permissionDecision: 'deny',
77
+ permissionDecisionReason: REASON,
78
+ },
79
+ }))
80
+ }
81
+
82
+ main().catch(() => { /* hook 静默失败:绝不因为守卫崩了而挡住正常工具 */ })
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
- try { logCapture('system', dropped ? 'fail' : 'warn', why) } catch { /* 日志失败无妨 */ }
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
- return applyPatchVerified(patch, {
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 {
@@ -812,6 +839,11 @@ export function apply(ctx, config) {
812
839
  ...((cfg.auditMode ?? 'all') === 'none' ? [] : [
813
840
  '6. **审核红线(强制)**:写卡一律停在 pending,由用户在「审核中心」审批。**禁止**调用 /memory-eternal/api/audit/approve 或 /audit/reject 代替用户审批,也不得用任何等价方式绕过审核;用户说「记录一下 / 增加记忆」不等于允许免审入库。需要立刻可用时,写卡后明确告知用户「已进审核中心,待批准」。',
814
841
  ]),
842
+ // 凭证取用提示(用户自己填的整句,见 Config.secretHint):只在填了非空值时注入。
843
+ // 为什么走 systemPrompt 而不是「往卡里写值」:密钥的**值**入库会被每次召回带进模型
844
+ // 上下文(card.summary = 正文前 200 字,召回默认取前 130 字),而 agent 真正需要的
845
+ // 只是「去哪取、怎么取」——这句负责**触发**,目录卡负责细节,值留在本机凭据库。
846
+ ...((cfg.secretHint || '').trim() ? [String(cfg.secretHint).trim()] : []),
815
847
  ].join('\n')
816
848
  // 异常提示:沉淀管线坏了,靠这一句把消息送到用户面前(不依赖用户去翻页面)。
817
849
  const h = health.snapshot()
@@ -843,7 +875,9 @@ export function apply(ctx, config) {
843
875
  name: 'memory_recall',
844
876
  description:
845
877
  '从本地记忆核心(Markdown 知识库)检索相关知识卡。需要项目背景、历史决策、之前讨论过的方案、' +
846
- '或领域知识时调用;返回最相关的卡片摘要。用 query 描述要找的内容,支持中文整词与字符片段检索。',
878
+ '或领域知识时调用;返回最相关的卡片摘要。用 query 描述要找的内容,支持中文整词与字符片段检索。' +
879
+ '需要 API key / token / 令牌等**凭证**时也先查这里(查「密钥 目录」):记忆里存的是目录' +
880
+ '(名字、存放位置、取用命令),**值不在记忆库里**,请按卡片给的本机命令取用,不要直接读凭据文件。',
847
881
  parameters: {
848
882
  query: { type: 'string', required: true, description: '检索关键词或自然语言描述,如「数据库选型」「用户偏好」' },
849
883
  limit: { type: 'number', description: '返回卡片数上限,默认 5' },
@@ -1022,10 +1056,10 @@ export function apply(ctx, config) {
1022
1056
  const cfg = settings.get() ?? {}
1023
1057
  // 只暴露可安全展示/回填的字段
1024
1058
  const safe = {
1025
- 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 || '',
1026
1060
  captureMinChars: cfg.captureMinChars, captureCooldownMs: cfg.captureCooldownMs, dedupThreshold: cfg.dedupThreshold, maxCardsPerDay: cfg.maxCardsPerDay,
1027
1061
  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,
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,
1029
1063
  auditMode: cfg.auditMode ?? 'all', auditExemptAgents: cfg.auditExemptAgents || [], auditExemptKinds: cfg.auditExemptKinds || [], recycleRetentionDays: cfg.recycleRetentionDays ?? 30,
1030
1064
  // 多库(#10):配置页可直接编辑
1031
1065
  vaultProfiles: Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : [],
@@ -1088,7 +1122,7 @@ export function apply(ctx, config) {
1088
1122
  retried: !!(writeResult && writeResult.retried),
1089
1123
  note: pending.length
1090
1124
  ? '已写入;宿主尚未回流这些值(面板已本地生效,重启 DSH 后以配置文件为准)'
1091
- : '已保存。autoWebMode/watchdogAutoSpawn 等需重启 DSH 生效;若已有常驻 watchdog,关闭/改参不会自动停掉它,需 dsh-memory stop / restart(#19)',
1125
+ : '已保存。autoWebMode/watchdogAutoSpawn/webPort 等需重启 DSH 生效;若已有常驻 watchdog,关闭/改参不会自动停掉它,需 dsh-memory stop / restart(#19)。版本漂移(升级后端口上仍是旧代码)由 autoRestartOnDrift 在下次激活时自动重启常驻实例,也可在「插件信息」点「重启常驻实例」立刻处理',
1092
1126
  })
1093
1127
  } catch (e) {
1094
1128
  if (e && e.code === 'SETTINGS_CONFLICT') return json(res, 409, { ok: false, error: '配置已被外部修改,请刷新后重试(revision conflict)' })
@@ -1204,22 +1238,59 @@ export function apply(ctx, config) {
1204
1238
  const wdPort = Number(cfg0.webPort) || 7999
1205
1239
  if (cfg0.watchdogAutoSpawn === true && cfg0.autoWeb !== false) {
1206
1240
  import('./lib/watchdog.js')
1207
- .then((m) => {
1208
- // 同端口已有**活着的** watchdog 时不再 spawn(issue #19):旧实现每次都 spawn 一个、
1209
- // 每次都打印 "spawned",而新进程会在锁上让位退出 —— 日志与实际状态完全相反,
1210
- // 排查时必然误判。这里如实打印 delegated,并提示版本漂移与替换命令。
1211
- const lock = m.readWatchdogLock(process.env)
1212
- const existing = lock.watchdogs.find((w) => Number(w.port) === wdPort && m.isPidAlive(w.pid))
1213
- if (existing) {
1214
- const cur = m.currentPkgVersion()
1215
- const stale = !!(existing.pkgVersion && cur && existing.pkgVersion !== cur)
1216
- const why = stale
1217
- ? `watchdog delegated to existing pid=${existing.pid} port=${wdPort}(常驻实例是旧版 v${existing.pkgVersion},当前 v${cur};如需换新版请执行 dsh-memory restart --port ${wdPort})`
1218
- : `watchdog delegated to existing pid=${existing.pid} port=${wdPort}(同端口已有常驻实例,本次不再 spawn)`
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' ? ';刚替换过,防抖窗口内不重复重启' : ''})`
1219
1262
  console.error(`[memory-eternal] ${why}`)
1220
- logCapture('system', stale ? 'warn' : 'boot', why)
1263
+ logCapture('system', 'boot', why)
1221
1264
  return
1222
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
1223
1294
  const port = wdPort
1224
1295
  // 用 nodeBinary() 而非 process.execPath:Electron 宿主下后者是 Electron
1225
1296
  // 主程序,spawn 出来不会执行 watchdog.js(见 lib/node-bin.js)。