memory-eternal 0.9.9 → 0.9.10

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.
@@ -0,0 +1,84 @@
1
+ // 记忆核心 · vault 目录解析(宿主与所有独立进程共用同一套优先级)
2
+ //
3
+ // 为什么单独成模块:以前宿主 index.js 认 activeVault,而 CLI / MCP / hooks /
4
+ // 独立 web / sweep 只认 MEMORY_VAULT_DIR 与默认库 —— 用户切库后终端与 hooks 仍写
5
+ // 默认库,同一台机器上的记忆被劈成两份(dsh-memory-eternal#5)。解析逻辑必须只有一份。
6
+ //
7
+ // 优先级:
8
+ // 1. MEMORY_VAULT_DIR (临时切库 / 宿主 spawn 子进程时显式传入)
9
+ // 2. activeVault 命中 vaultProfiles[].name
10
+ // 3. vaultProfiles[].match.workspace 命中当前 workspace(最长前缀优先,opt-in)
11
+ // 4. vaultDir(单库配置)
12
+ // 5. 默认 ~/.dsh/memory-vault
13
+
14
+ import path from 'node:path'
15
+ import os from 'node:os'
16
+
17
+ /** 取当前 workspace(用于 match.workspace 的按项目选库):MEMORY_WORKSPACE 优先,其次进程 cwd。 */
18
+ export function currentWorkspace(env = process.env) {
19
+ const explicit = String((env && env.MEMORY_WORKSPACE) || '').trim()
20
+ if (explicit) return explicit
21
+ try { return process.cwd() } catch { return '' }
22
+ }
23
+
24
+ /** 默认库目录。 */
25
+ export function defaultVaultRoot(env = process.env) {
26
+ const home = (env && env.DSH_HOME) || path.join(os.homedir(), '.dsh')
27
+ return path.join(home, 'memory-vault')
28
+ }
29
+
30
+ /** 归一化:反斜杠转正斜杠、去尾斜杠、统一小写(Windows 路径大小写不敏感)。 */
31
+ const norm = (p) => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
32
+
33
+ /**
34
+ * 找出 match.workspace 命中当前 workspace 的 profile。
35
+ * 双向前缀匹配(workspace 落在规则目录下,或规则目录落在 workspace 下),最长规则优先。
36
+ * @returns {object|null}
37
+ */
38
+ export function matchWorkspaceProfile(profiles, workspace) {
39
+ const ws = norm(workspace)
40
+ if (!ws) return null
41
+ let best = null
42
+ let bestLen = -1
43
+ for (const p of Array.isArray(profiles) ? profiles : []) {
44
+ const rule = p && p.match && p.match.workspace
45
+ const target = norm(rule)
46
+ if (!target) continue
47
+ // 规则没配可用目录 → 不能作为记忆库,直接跳过(与 resolveVaultDir 的判定保持一致)
48
+ if (!p.path || !String(p.path).trim()) continue
49
+ if (!(ws === target || ws.startsWith(target + '/') || target.startsWith(ws + '/'))) continue
50
+ if (target.length > bestLen) { best = p; bestLen = target.length }
51
+ }
52
+ return best
53
+ }
54
+
55
+ /**
56
+ * 解析当前 vault 目录。
57
+ * @param {object} input
58
+ * @param {NodeJS.ProcessEnv} [input.env]
59
+ * @param {Array<{name?:string,path?:string,match?:{workspace?:string}}>} [input.profiles]
60
+ * @param {string} [input.activeVault]
61
+ * @param {string} [input.configured] vaultDir 单库配置
62
+ * @param {string} [input.workspace] 显式指定 workspace(默认 currentWorkspace)
63
+ * @returns {{root:string, source:'env'|'profile'|'workspace'|'vaultDir'|'default', name:string, workspace:string}}
64
+ */
65
+ export function resolveVaultDir({ env = process.env, profiles = [], activeVault = '', configured = '', workspace } = {}) {
66
+ const ws = workspace === undefined ? currentWorkspace(env) : String(workspace || '')
67
+ const envDir = String((env && env.MEMORY_VAULT_DIR) || '').trim()
68
+ if (envDir) return { root: path.resolve(envDir), source: 'env', name: '', workspace: ws }
69
+
70
+ const list = Array.isArray(profiles) ? profiles : []
71
+ const active = String(activeVault || '').trim()
72
+ if (active) {
73
+ const hit = list.find((p) => p && String(p.name || '') === active && p.path && String(p.path).trim())
74
+ if (hit) return { root: path.resolve(String(hit.path).trim()), source: 'profile', name: active, workspace: ws }
75
+ }
76
+ const byWs = matchWorkspaceProfile(list, ws)
77
+ if (byWs && byWs.path && String(byWs.path).trim()) {
78
+ return { root: path.resolve(String(byWs.path).trim()), source: 'workspace', name: String(byWs.name || ''), workspace: ws }
79
+ }
80
+ if (configured && String(configured).trim()) {
81
+ return { root: path.resolve(String(configured).trim()), source: 'vaultDir', name: '', workspace: ws }
82
+ }
83
+ return { root: defaultVaultRoot(env), source: 'default', name: '', workspace: ws }
84
+ }
package/lib/watchdog.js CHANGED
@@ -45,33 +45,125 @@ export function isPidAlive(pid) {
45
45
  try { process.kill(n, 0); return true } catch (error) { return error?.code === 'EPERM' }
46
46
  }
47
47
 
48
- /** 读取锁文件;损坏或不存在返回 null。 */
48
+ /**
49
+ * 读取锁文件,归一化成 { watchdogs: [{pid, port, vault, startedAt}] }。
50
+ * 兼容早期单槽格式(顶层就是 {pid, port}):多端口/多 vault 并存时需要多槽。
51
+ */
49
52
  export function readWatchdogLock(env = process.env) {
50
- try { return JSON.parse(fs.readFileSync(watchdogLockPath(env), 'utf8')) } catch { return null }
53
+ let raw = null
54
+ try { raw = JSON.parse(fs.readFileSync(watchdogLockPath(env), 'utf8')) } catch { return { watchdogs: [] } }
55
+ if (!raw || typeof raw !== 'object') return { watchdogs: [] }
56
+ if (Array.isArray(raw.watchdogs)) return { watchdogs: raw.watchdogs.filter((w) => w && w.pid) }
57
+ if (raw.pid) return { watchdogs: [raw] } // 旧格式
58
+ return { watchdogs: [] }
51
59
  }
52
60
 
53
- /**
54
- * 尝试取得看门狗锁。
55
- * @returns {{acquired:boolean, file:string, previous:object|null}} acquired=false 表示「同端口已有活着的看门狗」,调用方应直接退出。
56
- */
57
- export function acquireWatchdogLock({ env = process.env, port, vault = '', pid = process.pid } = {}) {
61
+ function writeWatchdogLock(env, list) {
58
62
  const file = watchdogLockPath(env)
59
- const prev = readWatchdogLock(env)
60
- const samePortOwnerAlive = prev && prev.pid !== pid && Number(prev.port) === Number(port) && isPidAlive(prev.pid)
61
- if (samePortOwnerAlive) return { acquired: false, file, previous: prev }
62
- const stale = prev && !isPidAlive(prev.pid) ? prev : null
63
63
  try {
64
64
  fs.mkdirSync(path.dirname(file), { recursive: true })
65
- fs.writeFileSync(file, JSON.stringify({ pid, port: Number(port), vault, startedAt: new Date().toISOString() }))
65
+ fs.writeFileSync(file, JSON.stringify({ watchdogs: list }))
66
66
  } catch { /* 写不了锁不阻塞保活 */ }
67
- return { acquired: true, file, previous: stale }
68
67
  }
69
68
 
70
- /** 释放锁(只清自己的,避免误删别人刚写的)。 */
69
+ /**
70
+ * 尝试取得看门狗锁(按端口分槽)。
71
+ * @returns {{acquired:boolean, file:string, previous:object|null, reaped:number[]}} acquired=false 表示同端口已有活着的看门狗,调用方应直接退出。
72
+ */
73
+ export function acquireWatchdogLock({ env = process.env, port, vault = '', pid = process.pid } = {}) {
74
+ const file = watchdogLockPath(env)
75
+ const cur = readWatchdogLock(env)
76
+ const samePort = cur.watchdogs.filter((w) => Number(w.port) === Number(port) && w.pid !== pid)
77
+ const alive = samePort.find((w) => isPidAlive(w.pid))
78
+ if (alive) return { acquired: false, file, previous: alive, reaped: [] }
79
+ // 清掉死掉的槽(含其它端口的陈旧记录),保留仍然存活的其它端口
80
+ const reaped = []
81
+ const kept = []
82
+ for (const w of cur.watchdogs) {
83
+ if (Number(w.port) === Number(port)) { reaped.push(w.pid); continue }
84
+ if (isPidAlive(w.pid)) kept.push(w)
85
+ else reaped.push(w.pid)
86
+ }
87
+ const previous = samePort[0] || (reaped.length ? { pid: reaped[0] } : null)
88
+ kept.push({ pid, port: Number(port), vault, startedAt: new Date().toISOString() })
89
+ writeWatchdogLock(env, kept)
90
+ return { acquired: true, file, previous, reaped }
91
+ }
92
+
93
+ /** 释放锁(只清自己那一槽;没有其它槽就删文件)。 */
71
94
  export function releaseWatchdogLock({ env = process.env, pid = process.pid } = {}) {
72
95
  const cur = readWatchdogLock(env)
73
- if (!cur || cur.pid !== pid) return false
74
- try { fs.unlinkSync(watchdogLockPath(env)); return true } catch { return false }
96
+ const kept = cur.watchdogs.filter((w) => w.pid !== pid)
97
+ if (kept.length === cur.watchdogs.length) return false
98
+ if (!kept.length) { try { fs.unlinkSync(watchdogLockPath(env)) } catch { /* 已不存在 */ } return true }
99
+ writeWatchdogLock(env, kept)
100
+ return true
101
+ }
102
+
103
+ /**
104
+ * 解析进程列表(纯函数,便于单测)。
105
+ * 支持 `ps -eo pid,args` 与 Windows 的 `ConvertTo-Csv` 两种输入。
106
+ * @returns {Array<{pid:number, port:number, command:string}>} 只保留命令行里带 watchdog.js 的 node 进程
107
+ */
108
+ export function parseWatchdogProcesses(text, platform = process.platform) {
109
+ const out = []
110
+ for (const raw of String(text || '').split(/\r?\n/)) {
111
+ const line = raw.trim()
112
+ if (!line || !line.includes('watchdog.js')) continue
113
+ let pid = 0
114
+ let command = line
115
+ const csv = /^"?(\d+)"?,(.*)$/.exec(line) // Windows: "1234","node ... watchdog.js --port 7999"
116
+ const ps = /^(\d+)\s+(.*)$/.exec(line) // POSIX: 1234 node ... watchdog.js --port 7999
117
+ if (csv) { pid = Number(csv[1]); command = csv[2].replace(/^"|"$/g, '') }
118
+ else if (ps) { pid = Number(ps[1]); command = ps[2] }
119
+ if (!pid) continue
120
+ const m = /--port\s+(\d+)/.exec(command)
121
+ out.push({ pid, port: m ? Number(m[1]) : 7999, command })
122
+ }
123
+ return out
124
+ }
125
+
126
+ /** 列出机器上所有 watchdog 进程(平台相关,失败返回空数组)。 */
127
+ async function listWatchdogProcesses(exec, platform = process.platform) {
128
+ if (platform === 'win32') {
129
+ const ps = "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"
130
+ const out = await exec('powershell', ['-NoProfile', '-Command', ps])
131
+ return parseWatchdogProcesses(out, 'win32')
132
+ }
133
+ const out = await exec('ps', ['-eo', 'pid,args'])
134
+ return parseWatchdogProcesses(out, 'linux')
135
+ }
136
+
137
+ /**
138
+ * 回收历史遗留的孤儿看门狗(新锁只能防新增,管不了升级前已经在跑的)。
139
+ * 只杀「命令行是 watchdog.js + 端口相同 + pid 不是自己 + 不在锁里存活」的进程。
140
+ * @returns {Promise<{scanned:number, killed:number[], skipped:number[]}>}
141
+ */
142
+ export async function reapStaleWatchdogs({ port = 7999, keepPid = process.pid, env = process.env, exec, list, kill } = {}) {
143
+ const runExec = exec || (async (cmd, args) => {
144
+ const { spawn } = await import('node:child_process')
145
+ return await new Promise((resolve) => {
146
+ const c = spawn(cmd, args, { windowsHide: true })
147
+ let buf = ''
148
+ c.stdout.on('data', (d) => { buf += d })
149
+ c.on('error', () => resolve(''))
150
+ c.on('close', () => resolve(buf))
151
+ })
152
+ })
153
+ const procs = list ? await list() : await listWatchdogProcesses(runExec)
154
+ const lock = readWatchdogLock(env)
155
+ const lockedAlive = new Set(lock.watchdogs.filter((w) => isPidAlive(w.pid)).map((w) => Number(w.pid)))
156
+ const killFn = kill || ((pid) => { try { process.kill(pid, 'SIGTERM'); return true } catch { return false } })
157
+ const killed = []
158
+ const skipped = []
159
+ for (const p of procs) {
160
+ if (Number(p.port) !== Number(port)) { skipped.push(p.pid); continue }
161
+ if (Number(p.pid) === Number(keepPid)) { skipped.push(p.pid); continue }
162
+ if (lockedAlive.has(Number(p.pid))) { skipped.push(p.pid); continue } // 锁里活着的是当前合法实例
163
+ if (!isPidAlive(p.pid)) { skipped.push(p.pid); continue }
164
+ if (killFn(p.pid)) killed.push(p.pid); else skipped.push(p.pid)
165
+ }
166
+ return { scanned: procs.length, killed, skipped }
75
167
  }
76
168
 
77
169
  const ts = () => new Date().toISOString().slice(11, 19) // HH:MM:SS
@@ -275,6 +367,11 @@ if (process.argv[1] && /[\\/]watchdog\.js$/.test(process.argv[1])) {
275
367
  const maxRestart = Number(argOf('--max-restart')) || 10
276
368
  const vaultRoot = argOf('--vault') || undefined
277
369
  const autoStart = !argv.includes('--no-restart')
370
+ if (argv.includes('--reap')) {
371
+ const out = await reapStaleWatchdogs({ port, keepPid: 0 })
372
+ log(`回收孤儿看门狗:扫描 ${out.scanned} 个,清理 ${out.killed.length} 个${out.killed.length ? '(pid ' + out.killed.join(', ') + ')' : ''},保留 ${out.skipped.length} 个`)
373
+ process.exit(0)
374
+ }
278
375
  const lock = acquireWatchdogLock({ port, vault: vaultRoot || '' })
279
376
  if (!lock.acquired) {
280
377
  log('同端口已有看门狗在跑(pid ' + (lock.previous && lock.previous.pid) + ',启动于 ' + (lock.previous && lock.previous.startedAt) + ')→ 本次启动退出,避免进程堆积')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.9.9",
3
+ "version": "0.9.10",
4
4
  "description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -60,7 +60,7 @@
60
60
  "build": "node build.mjs",
61
61
  "build:client": "node build.mjs",
62
62
  "prepublishOnly": "node build.mjs",
63
- "test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/api.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/ssr.test.mjs"
63
+ "test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.test.mjs && node tests/api.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/ssr.test.mjs"
64
64
  },
65
65
  "dependencies": {
66
66
  "@deepseek-ai/schemastery": "^3.18.1"