memory-eternal 0.9.23 → 0.10.2
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 +2 -16
- package/.codex-plugin/plugin.json +2 -16
- package/.cursor-plugin/plugin.json +2 -16
- package/.mcp.json +9 -9
- package/LICENSE +21 -21
- package/README.en.md +27 -7
- package/README.md +40 -7
- package/bin/dsh-memory.mjs +318 -188
- package/examples/mcp-memory/memory-eternal.cordis.yml +59 -0
- package/hooks/capture.mjs +77 -77
- package/hooks/claude-session.js +56 -56
- package/hooks/hooks.claude.json +12 -12
- package/hooks/hooks.codex.json +15 -15
- package/hooks/hooks.cursor.json +11 -11
- package/index.js +187 -32
- package/lib/api.js +592 -536
- package/lib/capture-log.js +65 -59
- package/lib/capture-run.js +134 -126
- package/lib/capture.js +278 -14
- package/lib/client.js +20 -17
- package/lib/config-sync.js +161 -89
- package/lib/db.js +359 -212
- package/lib/feedback.js +214 -205
- package/lib/llm-openai.js +84 -84
- package/lib/mcp.js +197 -166
- package/lib/node-bin.js +99 -99
- package/lib/setup.js +470 -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/vault.js +347 -106
- package/lib/watchdog.js +527 -392
- package/lib/web-assets.js +41 -0
- package/lib/web.js +316 -176
- package/package.json +12 -7
- package/scripts/release.mjs +204 -0
- package/skills/memory-recall/SKILL.md +14 -14
- package/web/app.js +20 -17
- package/web/index.html +46 -46
package/lib/sse.js
CHANGED
|
@@ -1,51 +1,51 @@
|
|
|
1
|
-
// SSE 推送中心:把「配置/卡片变更」即时推给所有已打开的页面(不必手动刷新)。
|
|
2
|
-
// 两个宿主(DSH 内嵌 + 独立 web)共用同一套实现;纯逻辑,便于单测(注入假 res)。
|
|
3
|
-
export function createHub({ heartbeatMs = 25000 } = {}) {
|
|
4
|
-
const clients = new Set()
|
|
5
|
-
let timer = null
|
|
6
|
-
const send = (res, chunk) => {
|
|
7
|
-
try { res.write(chunk); return true } catch { return false }
|
|
8
|
-
}
|
|
9
|
-
const drop = (res) => {
|
|
10
|
-
clients.delete(res)
|
|
11
|
-
try { res.end() } catch { /* 已断开 */ }
|
|
12
|
-
}
|
|
13
|
-
const ensureTimer = () => {
|
|
14
|
-
if (timer || !(heartbeatMs > 0)) return
|
|
15
|
-
timer = setInterval(() => {
|
|
16
|
-
for (const res of [...clients]) if (!send(res, ': ping\n\n')) drop(res)
|
|
17
|
-
}, heartbeatMs)
|
|
18
|
-
if (typeof timer.unref === 'function') timer.unref()
|
|
19
|
-
}
|
|
20
|
-
return {
|
|
21
|
-
/** 接入一个连接(保持长连接,不要 end)。 */
|
|
22
|
-
add(res) {
|
|
23
|
-
try {
|
|
24
|
-
res.writeHead(200, {
|
|
25
|
-
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
26
|
-
'Cache-Control': 'no-store',
|
|
27
|
-
Connection: 'keep-alive',
|
|
28
|
-
'X-Accel-Buffering': 'no',
|
|
29
|
-
})
|
|
30
|
-
} catch { return false }
|
|
31
|
-
if (!send(res, 'retry: 3000\n\n')) return false
|
|
32
|
-
clients.add(res)
|
|
33
|
-
ensureTimer()
|
|
34
|
-
const onClose = () => drop(res)
|
|
35
|
-
try { res.on('close', onClose); res.on('error', onClose) } catch { /* 某些响应对象没有 on */ }
|
|
36
|
-
return true
|
|
37
|
-
},
|
|
38
|
-
/** 广播事件(event: <name>,data: JSON)。返回当前连接数。 */
|
|
39
|
-
broadcast(event, data) {
|
|
40
|
-
const chunk = 'event: ' + event + '\ndata: ' + JSON.stringify(data === undefined ? {} : data) + '\n\n'
|
|
41
|
-
for (const res of [...clients]) if (!send(res, chunk)) drop(res)
|
|
42
|
-
return clients.size
|
|
43
|
-
},
|
|
44
|
-
get size() { return clients.size },
|
|
45
|
-
close() {
|
|
46
|
-
if (timer) clearInterval(timer)
|
|
47
|
-
timer = null
|
|
48
|
-
for (const res of [...clients]) drop(res)
|
|
49
|
-
},
|
|
50
|
-
}
|
|
51
|
-
}
|
|
1
|
+
// SSE 推送中心:把「配置/卡片变更」即时推给所有已打开的页面(不必手动刷新)。
|
|
2
|
+
// 两个宿主(DSH 内嵌 + 独立 web)共用同一套实现;纯逻辑,便于单测(注入假 res)。
|
|
3
|
+
export function createHub({ heartbeatMs = 25000 } = {}) {
|
|
4
|
+
const clients = new Set()
|
|
5
|
+
let timer = null
|
|
6
|
+
const send = (res, chunk) => {
|
|
7
|
+
try { res.write(chunk); return true } catch { return false }
|
|
8
|
+
}
|
|
9
|
+
const drop = (res) => {
|
|
10
|
+
clients.delete(res)
|
|
11
|
+
try { res.end() } catch { /* 已断开 */ }
|
|
12
|
+
}
|
|
13
|
+
const ensureTimer = () => {
|
|
14
|
+
if (timer || !(heartbeatMs > 0)) return
|
|
15
|
+
timer = setInterval(() => {
|
|
16
|
+
for (const res of [...clients]) if (!send(res, ': ping\n\n')) drop(res)
|
|
17
|
+
}, heartbeatMs)
|
|
18
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
/** 接入一个连接(保持长连接,不要 end)。 */
|
|
22
|
+
add(res) {
|
|
23
|
+
try {
|
|
24
|
+
res.writeHead(200, {
|
|
25
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
26
|
+
'Cache-Control': 'no-store',
|
|
27
|
+
Connection: 'keep-alive',
|
|
28
|
+
'X-Accel-Buffering': 'no',
|
|
29
|
+
})
|
|
30
|
+
} catch { return false }
|
|
31
|
+
if (!send(res, 'retry: 3000\n\n')) return false
|
|
32
|
+
clients.add(res)
|
|
33
|
+
ensureTimer()
|
|
34
|
+
const onClose = () => drop(res)
|
|
35
|
+
try { res.on('close', onClose); res.on('error', onClose) } catch { /* 某些响应对象没有 on */ }
|
|
36
|
+
return true
|
|
37
|
+
},
|
|
38
|
+
/** 广播事件(event: <name>,data: JSON)。返回当前连接数。 */
|
|
39
|
+
broadcast(event, data) {
|
|
40
|
+
const chunk = 'event: ' + event + '\ndata: ' + JSON.stringify(data === undefined ? {} : data) + '\n\n'
|
|
41
|
+
for (const res of [...clients]) if (!send(res, chunk)) drop(res)
|
|
42
|
+
return clients.size
|
|
43
|
+
},
|
|
44
|
+
get size() { return clients.size },
|
|
45
|
+
close() {
|
|
46
|
+
if (timer) clearInterval(timer)
|
|
47
|
+
timer = null
|
|
48
|
+
for (const res of [...clients]) drop(res)
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
package/lib/stall.js
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
// 记忆核心 · 「轮次收尾事件是否真的没来」判定
|
|
2
|
-
//
|
|
3
|
-
// 旧判据:turnsStarted > turnsStopped && claimIdleMs > 15min && idleMs > 15min。两个毛病:
|
|
4
|
-
// 1) 两个计数**累积且永不归零**,而被取消/被拒的轮次不发 turn-stopping(子代理轮次同样不计)
|
|
5
|
-
// → 差额一旦出现就永久为真,判据退化成「只要空闲 15 分钟就报一次」(issue #4 的
|
|
6
|
-
// 「已开始 16 / 已收尾 12,且 15 分钟无任何沉淀活动」就是这么来的);
|
|
7
|
-
// 2) 真故障的特征恰好相反:轮次**持续开始**、只是没有收尾 —— 此时 claimed 一直新鲜,
|
|
8
|
-
// claimIdleMs 条件不满足 → 真故障反而不报。
|
|
9
|
-
//
|
|
10
|
-
// 现在改成滑动窗口:只看「窗口内开始了几个、收尾了几个」。
|
|
11
|
-
// 持续开始 + 零收尾 → 判停滞;用户离开(窗口内 0 开始)→ 不报。
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* @param {object} input
|
|
15
|
-
* @param {number[]} input.claimedAt 轮次开始时间戳(ms)
|
|
16
|
-
* @param {number[]} input.stoppedAt 轮次收尾时间戳(ms)
|
|
17
|
-
* @param {number} [input.now] 当前时间
|
|
18
|
-
* @param {number} [input.windowMs] 观察窗口,默认 20 分钟
|
|
19
|
-
* @param {number} [input.minStarted] 窗口内至少开始几个轮次才判停滞,默认 3
|
|
20
|
-
* @returns {{alert:boolean, started:number, stopped:number, windowMs:number}}
|
|
21
|
-
*/
|
|
22
|
-
export function evaluateStall({ claimedAt = [], stoppedAt = [], now = Date.now(), windowMs = 20 * 60 * 1000, minStarted = 3 } = {}) {
|
|
23
|
-
const from = now - windowMs
|
|
24
|
-
const started = claimedAt.filter((t) => t >= from).length
|
|
25
|
-
const stopped = stoppedAt.filter((t) => t >= from).length
|
|
26
|
-
return { alert: started >= minStarted && stopped === 0, started, stopped, windowMs }
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** 时间戳数组的裁剪上限:只保留窗口相关的近邻,避免长跑无界增长。 */
|
|
30
|
-
export const MAX_STAMPS = 200
|
|
1
|
+
// 记忆核心 · 「轮次收尾事件是否真的没来」判定
|
|
2
|
+
//
|
|
3
|
+
// 旧判据:turnsStarted > turnsStopped && claimIdleMs > 15min && idleMs > 15min。两个毛病:
|
|
4
|
+
// 1) 两个计数**累积且永不归零**,而被取消/被拒的轮次不发 turn-stopping(子代理轮次同样不计)
|
|
5
|
+
// → 差额一旦出现就永久为真,判据退化成「只要空闲 15 分钟就报一次」(issue #4 的
|
|
6
|
+
// 「已开始 16 / 已收尾 12,且 15 分钟无任何沉淀活动」就是这么来的);
|
|
7
|
+
// 2) 真故障的特征恰好相反:轮次**持续开始**、只是没有收尾 —— 此时 claimed 一直新鲜,
|
|
8
|
+
// claimIdleMs 条件不满足 → 真故障反而不报。
|
|
9
|
+
//
|
|
10
|
+
// 现在改成滑动窗口:只看「窗口内开始了几个、收尾了几个」。
|
|
11
|
+
// 持续开始 + 零收尾 → 判停滞;用户离开(窗口内 0 开始)→ 不报。
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} input
|
|
15
|
+
* @param {number[]} input.claimedAt 轮次开始时间戳(ms)
|
|
16
|
+
* @param {number[]} input.stoppedAt 轮次收尾时间戳(ms)
|
|
17
|
+
* @param {number} [input.now] 当前时间
|
|
18
|
+
* @param {number} [input.windowMs] 观察窗口,默认 20 分钟
|
|
19
|
+
* @param {number} [input.minStarted] 窗口内至少开始几个轮次才判停滞,默认 3
|
|
20
|
+
* @returns {{alert:boolean, started:number, stopped:number, windowMs:number}}
|
|
21
|
+
*/
|
|
22
|
+
export function evaluateStall({ claimedAt = [], stoppedAt = [], now = Date.now(), windowMs = 20 * 60 * 1000, minStarted = 3 } = {}) {
|
|
23
|
+
const from = now - windowMs
|
|
24
|
+
const started = claimedAt.filter((t) => t >= from).length
|
|
25
|
+
const stopped = stoppedAt.filter((t) => t >= from).length
|
|
26
|
+
return { alert: started >= minStarted && stopped === 0, started, stopped, windowMs }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 时间戳数组的裁剪上限:只保留窗口相关的近邻,避免长跑无界增长。 */
|
|
30
|
+
export const MAX_STAMPS = 200
|
package/lib/sweep.js
CHANGED
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
// 记忆核心 · 会话记录挖掘(简版 sweep)。
|
|
2
|
-
//
|
|
3
|
-
// 扫描目录下的 Claude Code 会话 JSONL(*.jsonl),每文件提取对话文本,
|
|
4
|
-
// 按文件粒度蒸馏入库(source 带 sweep 标记;重复跑幂等靠 vault 词法去重)。
|
|
5
|
-
// 适用:Codex sessions 目录 / Claude Code projects 目录 / 任意兼容 JSONL。
|
|
6
|
-
|
|
7
|
-
import { promises as fs } from 'node:fs'
|
|
8
|
-
import path from 'node:path'
|
|
9
|
-
import { runStandaloneCapture, defaultVaultDir } from './capture-run.js'
|
|
10
|
-
|
|
11
|
-
async function* walk(dir) {
|
|
12
|
-
let entries
|
|
13
|
-
try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch { return }
|
|
14
|
-
for (const e of entries) {
|
|
15
|
-
const p = path.join(dir, e.name)
|
|
16
|
-
if (e.isDirectory()) yield* walk(p)
|
|
17
|
-
else if (e.name.endsWith('.jsonl')) yield p
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function extractConversation(jsonlText) {
|
|
22
|
-
const texts = []
|
|
23
|
-
for (const line of jsonlText.split('\n')) {
|
|
24
|
-
if (!line.trim()) continue
|
|
25
|
-
let rec
|
|
26
|
-
try { rec = JSON.parse(line) } catch { continue }
|
|
27
|
-
const msg = rec?.message
|
|
28
|
-
if (!msg || !msg.role) continue
|
|
29
|
-
let text = ''
|
|
30
|
-
if (typeof msg.content === 'string') text = msg.content
|
|
31
|
-
else if (Array.isArray(msg.content)) {
|
|
32
|
-
text = msg.content.filter((c) => c?.type === 'text').map((c) => c.text).join('\n')
|
|
33
|
-
}
|
|
34
|
-
if (text && text.trim().length > 4) texts.push(`${msg.role}: ${text.trim()}`)
|
|
35
|
-
}
|
|
36
|
-
return texts.slice(-60).join('\n\n')
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export async function sweepSessions(dir, { vaultRoot = defaultVaultDir(), limit = 50 } = {}) {
|
|
40
|
-
const root = path.resolve(dir)
|
|
41
|
-
let scanned = 0
|
|
42
|
-
let captured = 0
|
|
43
|
-
let skipped = 0
|
|
44
|
-
for await (const file of walk(root)) {
|
|
45
|
-
if (scanned >= limit) break
|
|
46
|
-
scanned++
|
|
47
|
-
try {
|
|
48
|
-
const text = extractConversation(await fs.readFile(file, 'utf8'))
|
|
49
|
-
if (text.trim().length < 200) { skipped++; continue }
|
|
50
|
-
const out = await runStandaloneCapture(vaultRoot, text, { source: `sweep:${path.basename(file)}` })
|
|
51
|
-
if (out.ok && out.action !== 'skipped') captured++
|
|
52
|
-
else skipped++
|
|
53
|
-
} catch { skipped++ }
|
|
54
|
-
}
|
|
55
|
-
return { ok: true, dir: root, scanned, captured, skipped }
|
|
56
|
-
}
|
|
1
|
+
// 记忆核心 · 会话记录挖掘(简版 sweep)。
|
|
2
|
+
//
|
|
3
|
+
// 扫描目录下的 Claude Code 会话 JSONL(*.jsonl),每文件提取对话文本,
|
|
4
|
+
// 按文件粒度蒸馏入库(source 带 sweep 标记;重复跑幂等靠 vault 词法去重)。
|
|
5
|
+
// 适用:Codex sessions 目录 / Claude Code projects 目录 / 任意兼容 JSONL。
|
|
6
|
+
|
|
7
|
+
import { promises as fs } from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { runStandaloneCapture, defaultVaultDir } from './capture-run.js'
|
|
10
|
+
|
|
11
|
+
async function* walk(dir) {
|
|
12
|
+
let entries
|
|
13
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch { return }
|
|
14
|
+
for (const e of entries) {
|
|
15
|
+
const p = path.join(dir, e.name)
|
|
16
|
+
if (e.isDirectory()) yield* walk(p)
|
|
17
|
+
else if (e.name.endsWith('.jsonl')) yield p
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function extractConversation(jsonlText) {
|
|
22
|
+
const texts = []
|
|
23
|
+
for (const line of jsonlText.split('\n')) {
|
|
24
|
+
if (!line.trim()) continue
|
|
25
|
+
let rec
|
|
26
|
+
try { rec = JSON.parse(line) } catch { continue }
|
|
27
|
+
const msg = rec?.message
|
|
28
|
+
if (!msg || !msg.role) continue
|
|
29
|
+
let text = ''
|
|
30
|
+
if (typeof msg.content === 'string') text = msg.content
|
|
31
|
+
else if (Array.isArray(msg.content)) {
|
|
32
|
+
text = msg.content.filter((c) => c?.type === 'text').map((c) => c.text).join('\n')
|
|
33
|
+
}
|
|
34
|
+
if (text && text.trim().length > 4) texts.push(`${msg.role}: ${text.trim()}`)
|
|
35
|
+
}
|
|
36
|
+
return texts.slice(-60).join('\n\n')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function sweepSessions(dir, { vaultRoot = defaultVaultDir(), limit = 50 } = {}) {
|
|
40
|
+
const root = path.resolve(dir)
|
|
41
|
+
let scanned = 0
|
|
42
|
+
let captured = 0
|
|
43
|
+
let skipped = 0
|
|
44
|
+
for await (const file of walk(root)) {
|
|
45
|
+
if (scanned >= limit) break
|
|
46
|
+
scanned++
|
|
47
|
+
try {
|
|
48
|
+
const text = extractConversation(await fs.readFile(file, 'utf8'))
|
|
49
|
+
if (text.trim().length < 200) { skipped++; continue }
|
|
50
|
+
const out = await runStandaloneCapture(vaultRoot, text, { source: `sweep:${path.basename(file)}` })
|
|
51
|
+
if (out.ok && out.action !== 'skipped') captured++
|
|
52
|
+
else skipped++
|
|
53
|
+
} catch { skipped++ }
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, dir: root, scanned, captured, skipped }
|
|
56
|
+
}
|
package/lib/vault-resolve.js
CHANGED
|
@@ -1,84 +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
|
-
}
|
|
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
|
+
}
|