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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/bin/dsh-memory.mjs +6 -1
- package/index.js +54 -14
- package/lib/api.js +1 -0
- package/lib/capture-run.js +9 -7
- package/lib/client.js +11 -11
- package/lib/vault-resolve.js +84 -0
- package/lib/watchdog.js +113 -16
- package/package.json +2 -2
- package/web/app.js +14 -14
|
@@ -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
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* 读取锁文件,归一化成 { watchdogs: [{pid, port, vault, startedAt}] }。
|
|
50
|
+
* 兼容早期单槽格式(顶层就是 {pid, port}):多端口/多 vault 并存时需要多槽。
|
|
51
|
+
*/
|
|
49
52
|
export function readWatchdogLock(env = process.env) {
|
|
50
|
-
|
|
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({
|
|
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
|
-
|
|
74
|
-
|
|
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.
|
|
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"
|