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.
@@ -1,59 +1,65 @@
1
- // 记忆核心 · 自动沉淀日志的持久层(JSONL 追加)。
2
- //
3
- // 为什么要有它:
4
- // 1) 独立 Web 页(lib/web.js 起的进程)拿不到宿主内存里的日志,面板只能显示
5
- // 「日志仅在 DSH 宿主内可用」——用户看到的就是"记录里没内容";
6
- // 2) 宿主重启后内存环形缓冲清空,历史诊断信息全丢。
7
- // 落一个 JSONL 到 DSH_HOME 里,两边都能读:宿主写、两边读,最多保留最近 500 条。
8
-
9
- import { promises as fs } from 'node:fs'
10
- import path from 'node:path'
11
- import os from 'node:os'
12
-
13
- const FILE = 'memory-eternal-capture.jsonl'
14
- export const CAPTURE_LOG_MAX_LINES = 500
15
-
16
- /** 日志文件路径(与 vault 同级的 DSH_HOME 下)。 */
17
- export function captureLogPath(env = process.env) {
18
- const home = env.DSH_HOME || path.join(os.homedir(), '.dsh')
19
- return path.join(home, FILE)
20
- }
21
-
22
- /** 追加一条(失败静默:日志不能反过来搞坏沉淀管线)。 */
23
- export async function appendCaptureLog(entry, env = process.env) {
24
- try { await fs.appendFile(captureLogPath(env), JSON.stringify(entry) + '\n', 'utf8') } catch { /* 忽略 */ }
25
- }
26
-
27
- /** 读最近 limit 条,**最新在前**(与 /capture-log 接口语义一致)。 */
28
- export async function readCaptureLog(limit = 100, env = process.env) {
29
- try {
30
- const txt = await fs.readFile(captureLogPath(env), 'utf8')
31
- const lines = txt.split('\n').filter(Boolean)
32
- const out = []
33
- for (const line of lines.slice(-limit)) {
34
- try { out.push(JSON.parse(line)) } catch { /* 跳过坏行 */ }
35
- }
36
- return out.reverse()
37
- } catch { return [] }
38
- }
39
-
40
- /** 从日志推健康状态(独立 Web 页没有宿主内存状态,只能看最近一条结论)。 */
41
- export function deriveHealth(entries) {
42
- for (const e of entries || []) {
43
- if (e.action === 'fail') return { ok: false, reason: e.reason || '', since: e.time || 0, lastOkAt: 0, lastFailAt: e.time || 0 }
44
- if (e.action === 'created' || e.action === 'appended') return { ok: true, reason: '', since: 0, lastOkAt: e.time || 0, lastFailAt: 0 }
45
- }
46
- return null
47
- }
48
-
49
- /** 行数超上限时重写为最近 CAPTURE_LOG_MAX_LINES 行(由宿主定期调用)。 */
50
- export async function rotateCaptureLog(env = process.env) {
51
- try {
52
- const p = captureLogPath(env)
53
- const txt = await fs.readFile(p, 'utf8')
54
- const lines = txt.split('\n').filter(Boolean)
55
- if (lines.length <= CAPTURE_LOG_MAX_LINES) return false
56
- await fs.writeFile(p, lines.slice(-CAPTURE_LOG_MAX_LINES).join('\n') + '\n', 'utf8')
57
- return true
58
- } catch { return false }
59
- }
1
+ // 记忆核心 · 自动沉淀日志的持久层(JSONL 追加)。
2
+ //
3
+ // 为什么要有它:
4
+ // 1) 独立 Web 页(lib/web.js 起的进程)拿不到宿主内存里的日志,面板只能显示
5
+ // 「日志仅在 DSH 宿主内可用」——用户看到的就是"记录里没内容";
6
+ // 2) 宿主重启后内存环形缓冲清空,历史诊断信息全丢。
7
+ // 落一个 JSONL 到 DSH_HOME 里,两边都能读:宿主写、两边读,最多保留最近 500 条。
8
+
9
+ import { promises as fs } from 'node:fs'
10
+ import path from 'node:path'
11
+ import os from 'node:os'
12
+
13
+ const FILE = 'memory-eternal-capture.jsonl'
14
+ export const CAPTURE_LOG_MAX_LINES = 500
15
+
16
+ /** 日志文件路径(与 vault 同级的 DSH_HOME 下)。 */
17
+ export function captureLogPath(env = process.env) {
18
+ const home = env.DSH_HOME || path.join(os.homedir(), '.dsh')
19
+ return path.join(home, FILE)
20
+ }
21
+
22
+ /** 追加一条(失败静默:日志不能反过来搞坏沉淀管线)。 */
23
+ export async function appendCaptureLog(entry, env = process.env) {
24
+ try {
25
+ const file = captureLogPath(env)
26
+ // DSH_HOME 还不存在时先建出来:否则 appendFile 直接 ENOENT,而这里又是静默失败 ——
27
+ // 诊断信息(如「插件安装不完整」)会连一条都留不下。
28
+ await fs.mkdir(path.dirname(file), { recursive: true })
29
+ await fs.appendFile(file, JSON.stringify(entry) + '\n', 'utf8')
30
+ } catch { /* 忽略 */ }
31
+ }
32
+
33
+ /** 读最近 limit 条,**最新在前**(与 /capture-log 接口语义一致)。 */
34
+ export async function readCaptureLog(limit = 100, env = process.env) {
35
+ try {
36
+ const txt = await fs.readFile(captureLogPath(env), 'utf8')
37
+ const lines = txt.split('\n').filter(Boolean)
38
+ const out = []
39
+ for (const line of lines.slice(-limit)) {
40
+ try { out.push(JSON.parse(line)) } catch { /* 跳过坏行 */ }
41
+ }
42
+ return out.reverse()
43
+ } catch { return [] }
44
+ }
45
+
46
+ /** 从日志推健康状态(独立 Web 页没有宿主内存状态,只能看最近一条结论)。 */
47
+ export function deriveHealth(entries) {
48
+ for (const e of entries || []) {
49
+ if (e.action === 'fail') return { ok: false, reason: e.reason || '', since: e.time || 0, lastOkAt: 0, lastFailAt: e.time || 0 }
50
+ if (e.action === 'created' || e.action === 'appended') return { ok: true, reason: '', since: 0, lastOkAt: e.time || 0, lastFailAt: 0 }
51
+ }
52
+ return null
53
+ }
54
+
55
+ /** 行数超上限时重写为最近 CAPTURE_LOG_MAX_LINES 行(由宿主定期调用)。 */
56
+ export async function rotateCaptureLog(env = process.env) {
57
+ try {
58
+ const p = captureLogPath(env)
59
+ const txt = await fs.readFile(p, 'utf8')
60
+ const lines = txt.split('\n').filter(Boolean)
61
+ if (lines.length <= CAPTURE_LOG_MAX_LINES) return false
62
+ await fs.writeFile(p, lines.slice(-CAPTURE_LOG_MAX_LINES).join('\n') + '\n', 'utf8')
63
+ return true
64
+ } catch { return false }
65
+ }
@@ -1,126 +1,134 @@
1
- // 记忆核心 · 独立进程沉淀管线(MCP / CLI / hooks 共用)。
2
- //
3
- // 复刻 index.js runCapture 的判定链(预筛→配额→LLM 蒸馏→新建/追加→词法兜底),
4
- // 但 LLM 来源是 lib/llm-openai.js 的 OpenAI 兼容适配器而非 DSH llm 服务。
5
- // 未配置 LLM 时降级:跳过蒸馏,直接把原文写成一张卡(kind/tool 或调用方指定),
6
- // 保证「无 LLM 环境也能沉淀,只是不压缩」。
7
-
8
- import path from 'node:path'
9
- import os from 'node:os'
10
- import fs from 'node:fs'
11
- import { captureCard, captureUpdate, pickNeighbors, summarizeTurnDetailed, routeCandidates } from './capture.js'
12
- import { resolveVaultDir, defaultVaultRoot } from './vault-resolve.js'
13
- import { createLlmFromEnv } from './llm-openai.js'
14
-
15
- /**
16
- * 解析当前 vault 目录。
17
- *
18
- * 优先级必须与宿主 index.js 的 vaultDir() **完全一致**:
19
- * MEMORY_VAULT_DIR → activeVault 命中 vaultProfiles → vaultDir → 默认 ~/.dsh/memory-vault。
20
- * 旧实现只认 MEMORY_VAULT_DIR / 默认路径,于是用户在宿主里切库后,CLI / MCP / hooks /
21
- * 独立 web / sweep 仍然写默认库 —— 同一台机器上记忆被劈成两份(dsh-memory-eternal#5)。
22
- *
23
- * @param {NodeJS.ProcessEnv} [env]
24
- * @returns {string} 绝对路径
25
- */
26
- export function defaultVaultDir(env = process.env) {
27
- if (env.MEMORY_VAULT_DIR && env.MEMORY_VAULT_DIR.trim()) return path.resolve(env.MEMORY_VAULT_DIR.trim())
28
- try {
29
- const cfg = readConfig(env) || {}
30
- // 与宿主共用同一套解析(含 match.workspace 的按项目选库)
31
- return resolveVaultDir({
32
- env,
33
- profiles: cfg.vaultProfiles,
34
- activeVault: cfg.activeVault,
35
- configured: cfg.vaultDir,
36
- }).root
37
- } catch { /* 读不到共享配置就回落默认库 */ }
38
- return defaultVaultRoot(env)
39
- }
40
-
41
- // 读取 DSH 设置的完整配置(写入的共享文件),使独立 web / MCP hook 捕获与 DSH 设置同步。
42
- const CONFIG_FILE = 'memory-eternal-config.json'
43
- export function configFilePath(env = process.env) {
44
- return path.join(env.DSH_HOME || path.join(os.homedir(), '.dsh'), CONFIG_FILE)
45
- }
46
- function readConfig(env = process.env) {
47
- try {
48
- return JSON.parse(fs.readFileSync(configFilePath(env), 'utf8'))
49
- } catch { return null }
50
- }
51
- function splitList(x) { return (Array.isArray(x) ? x : String(x || '').split(',')).map((s) => String(s).trim()).filter(Boolean) }
52
-
53
- /**
54
- * 沉淀一段对话文本到 vault。
55
- * @returns {Promise<{ok:boolean, action:'created'|'appended'|'skipped'|'failed', path?:string, reason?:string, degraded?:boolean}>}
56
- */
57
- export async function runStandaloneCapture(vaultRoot, text, { source = '', llm, env = process.env, minChars = 120 } = {}) {
58
- const clean = String(text || '').trim()
59
- if (clean.length < minChars) return { ok: false, action: 'skipped', reason: 'text too short' }
60
-
61
- // 自动审核:共享配置文件(DSH)优先,其次 env,默认 all(独立进程无 DSH settings)
62
- const resolveAuditStatus = (kind, sub) => {
63
- const shared = readConfig(env)
64
- const mode = shared?.auditMode || env.MEMORY_AUDIT_MODE || 'all'
65
- const agents = shared?.auditExemptAgents ? splitList(shared.auditExemptAgents) : splitList(env.MEMORY_AUDIT_EXEMPT_AGENTS || '')
66
- const kinds = shared?.auditExemptKinds ? splitList(shared.auditExemptKinds) : splitList(env.MEMORY_AUDIT_EXEMPT_KINDS || '')
67
- if (mode === 'none') return 'approved'
68
- if (agents.includes('__all__') || agents.includes(sub)) return 'approved'
69
- if (kinds.includes('__all__') || kinds.includes(kind)) return 'approved'
70
- return 'pending'
71
- }
72
-
73
- const llmClient = llm ?? createLlmFromEnv(env)
74
- // 与宿主同样的候选路由:显式配置优先,其余按注册顺序兜底(别写死 providers[0],见 issue #3)
75
- const routes = llmClient ? await routeCandidates(llmClient, { provider: shared?.captureProvider, model: shared?.captureModel }) : []
76
- const route = routes[0] ?? null
77
-
78
- // 有 LLM:蒸馏判定链(与 DSH 侧一致)
79
- if (llmClient && route) {
80
- try {
81
- const neighbors = await pickNeighbors(vaultRoot, { title: '', body: clean.slice(0, 400) }, 8)
82
- const detailed = await summarizeTurnDetailed(llmClient, route, clean, { existing: neighbors, signal: AbortSignal.timeout(45000) })
83
- const result = detailed.card
84
- // 独立进程也必须把真实失败暴露出来(走 stderr),别再静默降级成「无输出」
85
- if (detailed.failure) {
86
- process.stderr.write(`[memory-eternal] 蒸馏失败(${route.provider}/${route.model}):${detailed.failure.code} ${detailed.failure.message}\n`)
87
- }
88
- if (result && result.save === true) {
89
- if (result.append_to) {
90
- await captureUpdate(vaultRoot, result.append_to, result.update, { threshold: 0.62 })
91
- return { ok: true, action: 'appended', path: result.append_to }
92
- }
93
- const card = { kind: result.kind, title: result.title, tags: result.tags, body: result.body, source, status: resolveAuditStatus(result.kind, source || 'agent'), submittedBy: source || 'agent', severity: 'info', reason: 'AI 自动沉淀(蒸馏卡)' }
94
- const out = await captureCard(vaultRoot, card, { threshold: 0.62 })
95
- if (out.ok) return { ok: true, action: 'created', path: out.path ?? out.rel }
96
- if (out.duplicate) {
97
- await captureUpdate(vaultRoot, out.duplicate.path, `${result.title}:${result.body.slice(0, 400)}`, { threshold: 0.62 })
98
- return { ok: true, action: 'appended', path: out.duplicate.path }
99
- }
100
- return { ok: false, action: 'failed', reason: String(out.reason || 'write failed') }
101
- }
102
- if (result && result.save === false) return { ok: true, action: 'skipped', reason: 'model judged not durable' }
103
- } catch (error) {
104
- // LLM 失败 → 落入降级路径,不能让一次网络抖动丢掉沉淀
105
- }
106
- }
107
-
108
- // 降级:无 LLM(或 LLM 调用失败)→ 原文卡(kind 默认 content,dedup 开)
109
- const out = await captureCard(vaultRoot, {
110
- kind: 'content',
111
- title: clean.replace(/\s+/g, ' ').slice(0, 40) || '未命名记录',
112
- tags: ['raw'],
113
- body: clean,
114
- source: source || 'manual',
115
- status: resolveAuditStatus('content', source || 'agent'),
116
- submittedBy: source || 'agent',
117
- severity: 'info',
118
- reason: 'AI 自动沉淀(原文卡)',
119
- }, { threshold: 0.62 })
120
- if (out.ok) return { ok: true, action: 'created', path: out.path ?? out.rel, degraded: true }
121
- if (out.duplicate) {
122
- await captureUpdate(vaultRoot, out.duplicate.path, clean.slice(0, 400), { threshold: 0.62 })
123
- return { ok: true, action: 'appended', path: out.duplicate.path, degraded: true }
124
- }
125
- return { ok: false, action: 'failed', reason: String(out.reason || 'write failed') }
126
- }
1
+ // 记忆核心 · 独立进程沉淀管线(MCP / CLI / hooks 共用)。
2
+ //
3
+ // 复刻 index.js runCapture 的判定链(预筛→配额→LLM 蒸馏→新建/追加→词法兜底),
4
+ // 但 LLM 来源是 lib/llm-openai.js 的 OpenAI 兼容适配器而非 DSH llm 服务。
5
+ // 未配置 LLM 时降级:跳过蒸馏,直接把原文写成一张卡(kind/tool 或调用方指定),
6
+ // 保证「无 LLM 环境也能沉淀,只是不压缩」。
7
+
8
+ import path from 'node:path'
9
+ import os from 'node:os'
10
+ import fs from 'node:fs'
11
+ import { captureCard, captureUpdate, pickNeighbors, summarizeTurnDetailed, routeCandidates, stripRuntimeNoise, hasUsableContent, deriveTitle, isUsableTitle } from './capture.js'
12
+ import { resolveVaultDir, defaultVaultRoot } from './vault-resolve.js'
13
+ import { createLlmFromEnv } from './llm-openai.js'
14
+
15
+ /**
16
+ * 解析当前 vault 目录。
17
+ *
18
+ * 优先级必须与宿主 index.js 的 vaultDir() **完全一致**:
19
+ * MEMORY_VAULT_DIR → activeVault 命中 vaultProfiles → vaultDir → 默认 ~/.dsh/memory-vault。
20
+ * 旧实现只认 MEMORY_VAULT_DIR / 默认路径,于是用户在宿主里切库后,CLI / MCP / hooks /
21
+ * 独立 web / sweep 仍然写默认库 —— 同一台机器上记忆被劈成两份(dsh-memory-eternal#5)。
22
+ *
23
+ * @param {NodeJS.ProcessEnv} [env]
24
+ * @returns {string} 绝对路径
25
+ */
26
+ export function defaultVaultDir(env = process.env) {
27
+ if (env.MEMORY_VAULT_DIR && env.MEMORY_VAULT_DIR.trim()) return path.resolve(env.MEMORY_VAULT_DIR.trim())
28
+ try {
29
+ const cfg = readConfig(env) || {}
30
+ // 与宿主共用同一套解析(含 match.workspace 的按项目选库)
31
+ return resolveVaultDir({
32
+ env,
33
+ profiles: cfg.vaultProfiles,
34
+ activeVault: cfg.activeVault,
35
+ configured: cfg.vaultDir,
36
+ }).root
37
+ } catch { /* 读不到共享配置就回落默认库 */ }
38
+ return defaultVaultRoot(env)
39
+ }
40
+
41
+ // 读取 DSH 设置的完整配置(写入的共享文件),使独立 web / MCP hook 捕获与 DSH 设置同步。
42
+ const CONFIG_FILE = 'memory-eternal-config.json'
43
+ export function configFilePath(env = process.env) {
44
+ return path.join(env.DSH_HOME || path.join(os.homedir(), '.dsh'), CONFIG_FILE)
45
+ }
46
+ function readConfig(env = process.env) {
47
+ try {
48
+ return JSON.parse(fs.readFileSync(configFilePath(env), 'utf8'))
49
+ } catch { return null }
50
+ }
51
+ function splitList(x) { return (Array.isArray(x) ? x : String(x || '').split(',')).map((s) => String(s).trim()).filter(Boolean) }
52
+
53
+ /**
54
+ * 沉淀一段对话文本到 vault。
55
+ * @returns {Promise<{ok:boolean, action:'created'|'appended'|'skipped'|'failed', path?:string, reason?:string, degraded?:boolean}>}
56
+ */
57
+ export async function runStandaloneCapture(vaultRoot, text, { source = '', llm, env = process.env, minChars = 120 } = {}) {
58
+ // 入料噪声闸门(P0):先剥运行时注入(环境快照 / team 广播 / teammate 原文 / 工具说明),
59
+ // 再判断是否还有真实内容。此处是 MCP memory_capture / CLI / hooks 的共同入口。
60
+ const clean = stripRuntimeNoise(text)
61
+ if (clean.length < minChars) return { ok: false, action: 'skipped', reason: 'text too short' }
62
+ if (!hasUsableContent(clean)) return { ok: false, action: 'skipped', reason: 'text is runtime noise' }
63
+ const title = deriveTitle(clean)
64
+ if (!isUsableTitle(title)) return { ok: false, action: 'skipped', reason: 'cannot derive a usable title' }
65
+
66
+ // 自动审核:共享配置文件(DSH)优先,其次 env,默认 all(独立进程无 DSH settings)
67
+ // 注:`shared` 原先在回调里局部读取,第 80 行的路由兜底却引用了外层同名变量 → ReferenceError
68
+ // 被下面的 try/catch 静默吞掉,导致 captureProvider/captureModel 在本进程从不生效。这里提到函数级。
69
+ const shared = readConfig(env)
70
+ const resolveAuditStatus = (kind, sub) => {
71
+ const mode = shared?.auditMode || env.MEMORY_AUDIT_MODE || 'all'
72
+ const agents = shared?.auditExemptAgents ? splitList(shared.auditExemptAgents) : splitList(env.MEMORY_AUDIT_EXEMPT_AGENTS || '')
73
+ const kinds = shared?.auditExemptKinds ? splitList(shared.auditExemptKinds) : splitList(env.MEMORY_AUDIT_EXEMPT_KINDS || '')
74
+ if (mode === 'none') return 'approved'
75
+ if (agents.includes('__all__') || agents.includes(sub)) return 'approved'
76
+ if (kinds.includes('__all__') || kinds.includes(kind)) return 'approved'
77
+ return 'pending'
78
+ }
79
+
80
+ const llmClient = llm ?? createLlmFromEnv(env)
81
+ // 与宿主同样的候选路由:显式配置优先,其余按注册顺序兜底(别写死 providers[0],见 issue #3)
82
+ const routes = llmClient ? await routeCandidates(llmClient, { provider: shared?.captureProvider, model: shared?.captureModel }) : []
83
+ const route = routes[0] ?? null
84
+
85
+ // 有 LLM:蒸馏判定链(与 DSH 侧一致)
86
+ if (llmClient && route) {
87
+ try {
88
+ const neighbors = await pickNeighbors(vaultRoot, { title: '', body: clean.slice(0, 400) }, 8)
89
+ const detailed = await summarizeTurnDetailed(llmClient, route, clean, { existing: neighbors, signal: AbortSignal.timeout(45000) })
90
+ const result = detailed.card
91
+ // 独立进程也必须把真实失败暴露出来(走 stderr),别再静默降级成「无输出」
92
+ if (detailed.failure) {
93
+ process.stderr.write(`[memory-eternal] 蒸馏失败(${route.provider}/${route.model}):${detailed.failure.code} ${detailed.failure.message}\n`)
94
+ }
95
+ if (result && result.save === true) {
96
+ if (result.append_to) {
97
+ await captureUpdate(vaultRoot, result.append_to, result.update, { threshold: 0.62 })
98
+ return { ok: true, action: 'appended', path: result.append_to }
99
+ }
100
+ const card = { kind: result.kind, title: result.title, tags: result.tags, body: result.body, source, status: resolveAuditStatus(result.kind, source || 'agent'), submittedBy: source || 'agent', severity: 'info', reason: 'AI 自动沉淀(蒸馏卡)' }
101
+ const out = await captureCard(vaultRoot, card, { threshold: 0.62 })
102
+ if (out.ok) return { ok: true, action: 'created', path: out.path ?? out.rel }
103
+ if (out.duplicate) {
104
+ await captureUpdate(vaultRoot, out.duplicate.path, `${result.title}:${result.body.slice(0, 400)}`, { threshold: 0.62 })
105
+ return { ok: true, action: 'appended', path: out.duplicate.path }
106
+ }
107
+ return { ok: false, action: 'failed', reason: String(out.reason || 'write failed') }
108
+ }
109
+ if (result && result.save === false) return { ok: true, action: 'skipped', reason: 'model judged not durable' }
110
+ } catch (error) {
111
+ // LLM 失败 → 落入降级路径,不能让一次网络抖动丢掉沉淀
112
+ }
113
+ }
114
+
115
+ // 降级:无 LLM(或 LLM 调用失败)→ 原文卡(kind 默认 content,dedup 开)
116
+ // 标题用 deriveTitle 派生(不再 slice 硬切);正文用剥离噪声后的 clean。
117
+ const out = await captureCard(vaultRoot, {
118
+ kind: 'content',
119
+ title,
120
+ tags: ['raw'],
121
+ body: clean,
122
+ source: source || 'manual',
123
+ status: resolveAuditStatus('content', source || 'agent'),
124
+ submittedBy: source || 'agent',
125
+ severity: 'info',
126
+ reason: 'AI 自动沉淀(原文卡)',
127
+ }, { threshold: 0.62 })
128
+ if (out.ok) return { ok: true, action: 'created', path: out.path ?? out.rel, degraded: true }
129
+ if (out.duplicate) {
130
+ await captureUpdate(vaultRoot, out.duplicate.path, clean.slice(0, 400), { threshold: 0.62 })
131
+ return { ok: true, action: 'appended', path: out.duplicate.path, degraded: true }
132
+ }
133
+ return { ok: false, action: 'failed', reason: String(out.reason || 'write failed') }
134
+ }