memory-eternal 0.16.0 → 0.18.0
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 +27 -27
- package/.codex-plugin/plugin.json +24 -24
- package/.cursor-plugin/plugin.json +14 -14
- package/README.md +81 -599
- package/README.zh-CN.md +138 -0
- package/bin/dsh-memory.mjs +33 -3
- package/cursor-extension/package.json +54 -54
- package/dsh-tui-plugin/dsh-plugin.json +42 -42
- package/dsh-tui-plugin/package.json +51 -51
- package/index.js +86 -11
- package/lib/api.js +57 -6
- package/lib/capture-run.js +35 -8
- package/lib/capture.js +37 -2
- package/lib/client.js +33 -21
- package/lib/core-memory.js +45 -0
- package/lib/db.js +21 -0
- package/lib/ledger.js +107 -0
- package/lib/mcp.js +8 -2
- package/lib/setup.js +132 -33
- package/lib/vault.js +20 -0
- package/orca-plugin/orca-plugin.json +36 -36
- package/package.json +145 -145
- package/scripts/release.mjs +5 -4
- package/web/app.js +38 -26
- package/README.en.md +0 -623
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// 核心记忆(前置强制记忆):被人工批准并标记为 core 的卡片,
|
|
2
|
+
// 每次会话都注入 systemPrompt,不依赖模型主动调用 memory_recall。
|
|
3
|
+
//
|
|
4
|
+
// 注意:只读取主库(cards 表)里 kind='core' 的已批准卡片;
|
|
5
|
+
// 待审/驳回卡永远不会进入这里。
|
|
6
|
+
|
|
7
|
+
import { listCards } from './vault.js'
|
|
8
|
+
|
|
9
|
+
export const CORE_KIND = 'core'
|
|
10
|
+
export const CORE_HEADER = '【核心记忆 · 必须遵守】\n以下内容已被用户批准为核心记忆,每次会话都必须优先遵守;与其他记忆或默认习惯冲突时,以此处为准。'
|
|
11
|
+
|
|
12
|
+
export function normalizeCoreOptions({ enabled = true, limit = 6, maxChars = 2000 } = {}) {
|
|
13
|
+
return {
|
|
14
|
+
enabled: enabled !== false,
|
|
15
|
+
limit: Math.min(Math.max(Math.floor(Number(limit) || 6), 1), 20),
|
|
16
|
+
maxChars: Math.min(Math.max(Math.floor(Number(maxChars) || 2000), 200), 10000),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 生成要注入 systemPrompt 的核心记忆文本;没有核心卡时返回空文本。 */
|
|
21
|
+
export async function coreMemoryPrompt(root, opts = {}) {
|
|
22
|
+
const { enabled, limit, maxChars } = normalizeCoreOptions(opts)
|
|
23
|
+
if (!enabled) return { ok: true, count: 0, text: '', chars: 0 }
|
|
24
|
+
let cards = []
|
|
25
|
+
try {
|
|
26
|
+
cards = await listCards(root, { kind: CORE_KIND, limit })
|
|
27
|
+
} catch {
|
|
28
|
+
return { ok: false, count: 0, text: '', chars: 0 }
|
|
29
|
+
}
|
|
30
|
+
const lines = []
|
|
31
|
+
let chars = CORE_HEADER.length
|
|
32
|
+
for (const card of cards) {
|
|
33
|
+
const title = String(card.title || '').replace(/\s+/g, ' ').trim()
|
|
34
|
+
const summary = String(card.summary || '').replace(/\s+/g, ' ').trim()
|
|
35
|
+
if (!title && !summary) continue
|
|
36
|
+
let line = '- ' + (title || '核心记忆') + (summary ? ':' + summary : '')
|
|
37
|
+
if (line.length > maxChars) line = line.slice(0, maxChars)
|
|
38
|
+
if (chars + 1 + line.length > maxChars && lines.length) break
|
|
39
|
+
lines.push(line)
|
|
40
|
+
chars += 1 + line.length
|
|
41
|
+
}
|
|
42
|
+
if (!lines.length) return { ok: true, count: 0, text: '', chars: 0 }
|
|
43
|
+
const text = CORE_HEADER + '\n' + lines.join('\n')
|
|
44
|
+
return { ok: true, count: lines.length, text: text.slice(0, maxChars), chars: Math.min(chars, maxChars) }
|
|
45
|
+
}
|
package/lib/db.js
CHANGED
|
@@ -221,6 +221,25 @@ function initTables(db) {
|
|
|
221
221
|
created_at TEXT DEFAULT (datetime('now'))
|
|
222
222
|
);
|
|
223
223
|
|
|
224
|
+
-- 记忆账本:召回注入 / 蒸馏调用的开销流水(回答「到底省不省 token」)
|
|
225
|
+
CREATE TABLE IF NOT EXISTS ledger (
|
|
226
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
227
|
+
ts INTEGER NOT NULL,
|
|
228
|
+
kind TEXT NOT NULL,
|
|
229
|
+
session TEXT DEFAULT '',
|
|
230
|
+
results INTEGER DEFAULT 0,
|
|
231
|
+
chars INTEGER DEFAULT 0,
|
|
232
|
+
chars_in INTEGER DEFAULT 0,
|
|
233
|
+
llm_calls INTEGER DEFAULT 0,
|
|
234
|
+
provider TEXT DEFAULT '',
|
|
235
|
+
model TEXT DEFAULT '',
|
|
236
|
+
ms INTEGER DEFAULT 0,
|
|
237
|
+
ok INTEGER DEFAULT 1,
|
|
238
|
+
note TEXT DEFAULT ''
|
|
239
|
+
);
|
|
240
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_ts ON ledger(ts);
|
|
241
|
+
CREATE INDEX IF NOT EXISTS idx_ledger_kind ON ledger(kind);
|
|
242
|
+
|
|
224
243
|
-- 审核日志:记录所有 status 变更,不可篡改
|
|
225
244
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
226
245
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -354,6 +373,8 @@ export function checkMainStoreInvariant(root) {
|
|
|
354
373
|
* @returns {'pending' | 'approved'}
|
|
355
374
|
*/
|
|
356
375
|
export function enforceAudit(root, kind, submittedBy, fallback = 'pending') {
|
|
376
|
+
// 核心记忆必须人工审核:无论 auditMode / 免审名单如何配置,写入时一律待审。
|
|
377
|
+
if (String(kind) === 'core') return 'pending'
|
|
357
378
|
const db = getDb(root)
|
|
358
379
|
const get = (key) => { try { return db.prepare('SELECT value FROM config WHERE key = ?').get(key)?.value } catch { return null } }
|
|
359
380
|
const mode = get('auditMode')
|
package/lib/ledger.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// 记忆账本:记录「记忆本身的开销」——召回注入了多少字符、蒸馏调了多少次 LLM。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有它:README 一直说「省 token」,但省不省取决于「被避免的上下文开销」是否大于
|
|
4
|
+
// 「注入 + 蒸馏调用」的成本。没有账本,所有调参都是猜;有账本后至少能回答「我们花了多少」,
|
|
5
|
+
// 再结合召回有用率(feedback 表)判断净收益方向。
|
|
6
|
+
//
|
|
7
|
+
// 只记账,不参与主流程:任何异常都吞掉,绝不能让统计把沉淀/召回弄失败。
|
|
8
|
+
|
|
9
|
+
import { getDb } from './db.js'
|
|
10
|
+
|
|
11
|
+
const DAY_MS = 86400000
|
|
12
|
+
|
|
13
|
+
/** 记一条流水。失败静默返回 false。 */
|
|
14
|
+
export function recordLedger(root, entry = {}) {
|
|
15
|
+
try {
|
|
16
|
+
const db = getDb(root)
|
|
17
|
+
db.prepare(`
|
|
18
|
+
INSERT INTO ledger (ts, kind, session, results, chars, chars_in, llm_calls, provider, model, ms, ok, note)
|
|
19
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
20
|
+
`).run(
|
|
21
|
+
Number(entry.ts) || Date.now(),
|
|
22
|
+
String(entry.kind || 'other').slice(0, 20),
|
|
23
|
+
String(entry.session || '').slice(0, 80),
|
|
24
|
+
Math.max(0, Number(entry.results) || 0),
|
|
25
|
+
Math.max(0, Number(entry.chars) || 0),
|
|
26
|
+
Math.max(0, Number(entry.charsIn) || 0),
|
|
27
|
+
Math.max(0, Number(entry.llmCalls) || 0),
|
|
28
|
+
String(entry.provider || '').slice(0, 60),
|
|
29
|
+
String(entry.model || '').slice(0, 80),
|
|
30
|
+
Math.max(0, Number(entry.ms) || 0),
|
|
31
|
+
entry.ok === false ? 0 : 1,
|
|
32
|
+
String(entry.note || '').slice(0, 200),
|
|
33
|
+
)
|
|
34
|
+
return true
|
|
35
|
+
} catch { return false }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const localDay = (ts) => {
|
|
39
|
+
const d = new Date(Number(ts) || 0)
|
|
40
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
41
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 近 N 天汇总(默认 7 天,最多 90 天)。
|
|
46
|
+
* - totals:召回次数/注入字符、沉淀次数/LLM 调用/输入输出字符、粗估 token
|
|
47
|
+
* - byDay:逐日明细(used by 面板画趋势)
|
|
48
|
+
* - feedback:召回有用率(feedback 表,只有 agent 显式标记才有数据)
|
|
49
|
+
*/
|
|
50
|
+
export function ledgerSummary(root, { days = 7, now = Date.now() } = {}) {
|
|
51
|
+
const db = getDb(root)
|
|
52
|
+
const n = Math.min(Math.max(Math.floor(Number(days) || 7), 1), 90)
|
|
53
|
+
const cutoff = Number(now) - n * DAY_MS
|
|
54
|
+
const rows = db.prepare('SELECT * FROM ledger WHERE ts >= ? ORDER BY ts').all(cutoff)
|
|
55
|
+
const recalls = rows.filter((r) => r.kind === 'recall')
|
|
56
|
+
const captures = rows.filter((r) => r.kind === 'capture')
|
|
57
|
+
const sum = (arr, k) => arr.reduce((s, r) => s + (Number(r[k]) || 0), 0)
|
|
58
|
+
|
|
59
|
+
const byDay = []
|
|
60
|
+
const index = new Map()
|
|
61
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
62
|
+
const key = localDay(Number(now) - i * DAY_MS)
|
|
63
|
+
const row = { date: key, recalls: 0, injectedChars: 0, captures: 0, llmCalls: 0, captureInChars: 0, captureOutChars: 0 }
|
|
64
|
+
index.set(key, row)
|
|
65
|
+
byDay.push(row)
|
|
66
|
+
}
|
|
67
|
+
for (const r of rows) {
|
|
68
|
+
const row = index.get(localDay(r.ts))
|
|
69
|
+
if (!row) continue
|
|
70
|
+
if (r.kind === 'recall') { row.recalls += 1; row.injectedChars += Number(r.chars) || 0 }
|
|
71
|
+
if (r.kind === 'capture') { row.captures += 1; row.llmCalls += Number(r.llm_calls) || 0; row.captureInChars += Number(r.chars_in) || 0; row.captureOutChars += Number(r.chars) || 0 }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 召回有用率:只有调用方显式回传 useful 时才有数据;没有数据时 rate = null(不要假装 0%)
|
|
75
|
+
let useful = 0
|
|
76
|
+
let useless = 0
|
|
77
|
+
try {
|
|
78
|
+
const fb = db.prepare("SELECT useful, COUNT(*) AS n FROM feedback WHERE created_at >= datetime('now', ?) GROUP BY useful").all(`-${n} days`)
|
|
79
|
+
for (const r of fb) { if (Number(r.useful) === 1) useful = Number(r.n) || 0; else useless = Number(r.n) || 0 }
|
|
80
|
+
} catch { /* feedback 表缺失也不影响账本 */ }
|
|
81
|
+
|
|
82
|
+
const injectedChars = sum(recalls, 'chars')
|
|
83
|
+
const captureInChars = sum(captures, 'chars_in')
|
|
84
|
+
const captureOutChars = sum(captures, 'chars')
|
|
85
|
+
// 粗估 token:中英混排约 2 字符 ≈ 1 token(保守口径,只用于看量级,不做计费)
|
|
86
|
+
const estTokens = Math.round((injectedChars + captureInChars + captureOutChars) / 2)
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
days: n,
|
|
91
|
+
since: cutoff,
|
|
92
|
+
totals: {
|
|
93
|
+
recalls: recalls.length,
|
|
94
|
+
recallResults: sum(recalls, 'results'),
|
|
95
|
+
injectedChars,
|
|
96
|
+
captures: captures.length,
|
|
97
|
+
captureOk: captures.filter((r) => Number(r.ok) === 1).length,
|
|
98
|
+
captureFail: captures.filter((r) => Number(r.ok) !== 1).length,
|
|
99
|
+
llmCalls: sum(captures, 'llm_calls'),
|
|
100
|
+
captureInChars,
|
|
101
|
+
captureOutChars,
|
|
102
|
+
estTokens,
|
|
103
|
+
},
|
|
104
|
+
byDay,
|
|
105
|
+
feedback: { useful, useless, total: useful + useless, rate: useful + useless ? useful / (useful + useless) : null },
|
|
106
|
+
}
|
|
107
|
+
}
|
package/lib/mcp.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import readline from 'node:readline'
|
|
17
17
|
import { search, overview, stats, ensureVault, listCards, countCards } from './vault.js'
|
|
18
18
|
import { runStandaloneCapture, defaultVaultDir } from './capture-run.js'
|
|
19
|
+
import { recordLedger } from './ledger.js'
|
|
19
20
|
|
|
20
21
|
const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05']
|
|
21
22
|
const DEFAULT_PROTOCOL_VERSION = '2024-11-05'
|
|
@@ -83,13 +84,18 @@ export async function callTool(name, args, vaultRoot, meta = {}) {
|
|
|
83
84
|
if (!query) return toolResult('(未提供检索词)')
|
|
84
85
|
const limit = Math.min(Math.max(Number(args?.limit) || 5, 1), 20)
|
|
85
86
|
const hits = await search(vaultRoot, query, { limit, minScore: 2 })
|
|
86
|
-
if (hits.length === 0)
|
|
87
|
+
if (hits.length === 0) {
|
|
88
|
+
recordLedger(vaultRoot, { kind: 'recall', results: 0, chars: 0, note: query.slice(0, 120) })
|
|
89
|
+
return toolResult(`记忆库中没有与「${query}」相关的内容。`)
|
|
90
|
+
}
|
|
87
91
|
const lines = hits.map((h, i) => {
|
|
88
92
|
const tags = h.tags.length ? ` [${h.tags.join(', ')}]` : ''
|
|
89
93
|
const snippet = String(h.summary || '').replace(/\s+/g, ' ').trim().slice(0, 130)
|
|
90
94
|
return `### ${i + 1}. ${h.title}${tags}\n路径:${h.path}\n${snippet}`
|
|
91
95
|
})
|
|
92
|
-
|
|
96
|
+
const out = `从记忆核心检索到 ${hits.length} 条相关卡片:\n\n${lines.join('\n\n')}`
|
|
97
|
+
recordLedger(vaultRoot, { kind: 'recall', results: hits.length, chars: out.length, note: query.slice(0, 120) })
|
|
98
|
+
return toolResult(out)
|
|
93
99
|
}
|
|
94
100
|
case 'memory_capture': {
|
|
95
101
|
const text = String(args?.text || '')
|
package/lib/setup.js
CHANGED
|
@@ -13,6 +13,7 @@ import { promises as fs } from 'node:fs'
|
|
|
13
13
|
import path from 'node:path'
|
|
14
14
|
import os from 'node:os'
|
|
15
15
|
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { nodeBinary, usesElectronBinary } from './node-bin.js'
|
|
16
17
|
|
|
17
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
18
19
|
export const PACKAGE_ROOT = path.join(__dirname, '..')
|
|
@@ -63,8 +64,40 @@ async function backupOnce(file) {
|
|
|
63
64
|
return bak
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
/** 这个 node 路径是否其实是 Electron 宿主二进制(直接运行会拉起 GUI)。 */
|
|
68
|
+
export function needsElectronEnv(nodePath = nodeBinary()) {
|
|
69
|
+
const p = String(nodePath || '')
|
|
70
|
+
if (!p) return false
|
|
71
|
+
if (usesElectronBinary()) return true
|
|
72
|
+
if (!process.versions.electron) return false
|
|
73
|
+
try { return path.resolve(p) === path.resolve(process.execPath) } catch { return false }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 静态 hook 命令:真实 node 直接用;只能退回 Electron 二进制时补 ELECTRON_RUN_AS_NODE。 */
|
|
77
|
+
export function hookCommand(nodePath = nodeBinary(), script, suffix = '') {
|
|
78
|
+
const base = `${JSON.stringify(nodePath)} ${JSON.stringify(script)}${suffix ? ' ' + suffix : ''}`
|
|
79
|
+
if (!needsElectronEnv(nodePath)) return base
|
|
80
|
+
if (process.platform === 'win32') {
|
|
81
|
+
// cmd.exe 不支持 POSIX 的 VAR=value 前缀;用 set 后再执行
|
|
82
|
+
return `cmd /d /s /c "set ELECTRON_RUN_AS_NODE=1&& ${base.replace(/"/g, '\\"')}"`
|
|
83
|
+
}
|
|
84
|
+
return `ELECTRON_RUN_AS_NODE=1 ${base}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 历史配置自检:命令指向 Electron 二进制、却没有 ELECTRON_RUN_AS_NODE。 */
|
|
88
|
+
export function isLegacyElectronCommand(command) {
|
|
89
|
+
const s = String(command || '')
|
|
90
|
+
if (!process.versions.electron) return false // 普通 node 进程不能用 execPath 名字判断
|
|
91
|
+
if (!s || /ELECTRON_RUN_AS_NODE/i.test(s)) return false
|
|
92
|
+
const exe = path.basename(process.execPath || '').toLowerCase()
|
|
93
|
+
if (!exe) return false
|
|
94
|
+
return s.toLowerCase().includes(exe)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function mcpCommand(nodePath = nodeBinary()) {
|
|
98
|
+
const out = { command: nodePath, args: [BIN_PATH, 'mcp'] }
|
|
99
|
+
if (needsElectronEnv(nodePath)) out.env = { ELECTRON_RUN_AS_NODE: '1' }
|
|
100
|
+
return out
|
|
68
101
|
}
|
|
69
102
|
|
|
70
103
|
function sameMcpEntry(existing, want) {
|
|
@@ -72,12 +105,28 @@ function sameMcpEntry(existing, want) {
|
|
|
72
105
|
if (existing.type === 'stdio' || existing.type === undefined) {
|
|
73
106
|
return existing.command === want.command
|
|
74
107
|
&& JSON.stringify(existing.args ?? []) === JSON.stringify(want.args)
|
|
108
|
+
&& (!want.env || JSON.stringify(existing.env ?? {}) === JSON.stringify(want.env))
|
|
75
109
|
}
|
|
76
110
|
return false
|
|
77
111
|
}
|
|
78
112
|
|
|
79
113
|
// 是否已写入我们(capture.mjs)的会话结束 hook。
|
|
80
114
|
// marker 用于区分宿主:codex 走 hooks.json 的 Stop;cursor 走 hooks.json 的 stop/sessionEnd。
|
|
115
|
+
/** hooks 文件里是否有「指向 Electron 宿主 exe、却没带 ELECTRON_RUN_AS_NODE」的历史命令。 */
|
|
116
|
+
async function hooksNeedRepair(hooksJson, markers) {
|
|
117
|
+
try {
|
|
118
|
+
const cfg = JSON.parse(await fs.readFile(hooksJson, 'utf8'))
|
|
119
|
+
const walk = (v) => {
|
|
120
|
+
if (!v) return false
|
|
121
|
+
if (typeof v === 'string') return markers.some((m) => v.includes(m)) && isLegacyElectronCommand(v)
|
|
122
|
+
if (Array.isArray(v)) return v.some(walk)
|
|
123
|
+
if (typeof v === 'object') return Object.values(v).some(walk)
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
return walk(cfg)
|
|
127
|
+
} catch { return false }
|
|
128
|
+
}
|
|
129
|
+
|
|
81
130
|
async function hookState(hooksJson, agent, cursorStyle = false) {
|
|
82
131
|
try {
|
|
83
132
|
const cfg = JSON.parse(await fs.readFile(hooksJson, 'utf8'))
|
|
@@ -148,7 +197,7 @@ async function setupClaudeHooks({ nodePath, dryRun, log }) {
|
|
|
148
197
|
// ① SessionEnd:会话结束自动沉淀
|
|
149
198
|
const want = {
|
|
150
199
|
matcher: '',
|
|
151
|
-
hooks: [{ type: 'command', command:
|
|
200
|
+
hooks: [{ type: 'command', command: hookCommand(nodePath, hookScript, 'claude') }],
|
|
152
201
|
}
|
|
153
202
|
const hookKey = 'SessionEnd'
|
|
154
203
|
cfg.hooks[hookKey] = Array.isArray(cfg.hooks[hookKey]) ? cfg.hooks[hookKey] : []
|
|
@@ -158,7 +207,7 @@ async function setupClaudeHooks({ nodePath, dryRun, log }) {
|
|
|
158
207
|
// 或 npm 版本装到别处),旧实现只判「有没有 capture.mjs」就跳过,于是 hook 永远
|
|
159
208
|
// 指着那个旧副本(本机实测:Claude Code 一直在跑 web profile 的 0.9.23)。
|
|
160
209
|
// 与 Codex 分支同样的口径:只重写自己那条命令,其它 hook 一律不动。
|
|
161
|
-
const wanted =
|
|
210
|
+
const wanted = hookCommand(nodePath, hookScript, 'claude')
|
|
162
211
|
let rewrote = false
|
|
163
212
|
for (const c of existingCapture.hooks ?? []) {
|
|
164
213
|
if (typeof c?.command === 'string' && c.command.includes('capture.mjs') && c.command !== wanted) {
|
|
@@ -184,13 +233,25 @@ async function setupClaudeHooks({ nodePath, dryRun, log }) {
|
|
|
184
233
|
if (await exists(guardScript)) {
|
|
185
234
|
const guardKey = 'PreToolUse'
|
|
186
235
|
cfg.hooks[guardKey] = Array.isArray(cfg.hooks[guardKey]) ? cfg.hooks[guardKey] : []
|
|
187
|
-
|
|
188
|
-
|
|
236
|
+
const wantedGuard = hookCommand(nodePath, guardScript)
|
|
237
|
+
const existingGuard = cfg.hooks[guardKey].find((h) => h?.hooks?.some((c) => c?.command?.includes('secret-guard.mjs')))
|
|
238
|
+
if (existingGuard) {
|
|
239
|
+
// 已存在也要修命令:旧版本把 Electron 宿主 exe 写进了 hook(issue #53),
|
|
240
|
+
// 只判「有没有 secret-guard」会永远跳过修复。
|
|
241
|
+
let rewrote = false
|
|
242
|
+
for (const c of existingGuard.hooks ?? []) {
|
|
243
|
+
if (typeof c?.command === 'string' && c.command.includes('secret-guard.mjs') && c.command !== wantedGuard) {
|
|
244
|
+
c.command = wantedGuard
|
|
245
|
+
rewrote = true
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (rewrote) { written.push(guardKey + '(path)'); changed = true; log('↻ Claude Code PreToolUse hook 路径已更新') }
|
|
249
|
+
else log('✓ Claude Code PreToolUse 凭据守卫已存在,跳过')
|
|
189
250
|
} else {
|
|
190
251
|
// 只追加一个独立条目,保留用户已有的 PreToolUse 配置
|
|
191
252
|
cfg.hooks[guardKey].push({
|
|
192
253
|
matcher: 'Read|Bash|Grep|Glob',
|
|
193
|
-
hooks: [{ type: 'command', command:
|
|
254
|
+
hooks: [{ type: 'command', command: wantedGuard }],
|
|
194
255
|
})
|
|
195
256
|
written.push(guardKey)
|
|
196
257
|
changed = true
|
|
@@ -245,18 +306,40 @@ export function parseCodexMemoryEntry(text) {
|
|
|
245
306
|
* 纯函数:把 `[mcp_servers.memory]` 段对齐到 want(只动这一段的两行)。
|
|
246
307
|
* @returns {{text:string, changed:boolean}}
|
|
247
308
|
*/
|
|
309
|
+
export function codexEnvLine(env) {
|
|
310
|
+
if (!env || typeof env !== 'object') return ''
|
|
311
|
+
const keys = Object.keys(env)
|
|
312
|
+
if (!keys.length) return ''
|
|
313
|
+
return 'env = { ' + keys.map((k) => `${k} = ${JSON.stringify(String(env[k]))}`).join(', ') + ' }'
|
|
314
|
+
}
|
|
315
|
+
|
|
248
316
|
export function alignCodexMemorySection(text, want) {
|
|
249
317
|
const lines = String(text || '').split(/\r?\n/)
|
|
250
318
|
let inSec = false
|
|
251
319
|
let changed = false
|
|
320
|
+
let lastRelevant = -1
|
|
321
|
+
let envAt = -1
|
|
322
|
+
const wantCmd = 'command = ' + JSON.stringify(want.command)
|
|
323
|
+
const wantArgs = 'args = ' + JSON.stringify(want.args)
|
|
324
|
+
const wantEnv = codexEnvLine(want.env)
|
|
252
325
|
for (let i = 0; i < lines.length; i++) {
|
|
253
326
|
if (/^\s*\[/.test(lines[i])) { inSec = /^\s*\[mcp_servers\.memory\]\s*$/.test(lines[i].trim()); continue }
|
|
254
327
|
if (!inSec) continue
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (/^\s*
|
|
259
|
-
|
|
328
|
+
if (/^\s*command\s*=/.test(lines[i])) {
|
|
329
|
+
if (lines[i] !== wantCmd) { lines[i] = wantCmd; changed = true }
|
|
330
|
+
lastRelevant = i
|
|
331
|
+
} else if (/^\s*args\s*=/.test(lines[i])) {
|
|
332
|
+
if (lines[i] !== wantArgs) { lines[i] = wantArgs; changed = true }
|
|
333
|
+
lastRelevant = i
|
|
334
|
+
} else if (/^\s*env\s*=/.test(lines[i])) {
|
|
335
|
+
envAt = i
|
|
336
|
+
if (wantEnv && lines[i] !== wantEnv) { lines[i] = wantEnv; changed = true }
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (wantEnv && envAt < 0) {
|
|
340
|
+
const at = lastRelevant >= 0 ? lastRelevant + 1 : 0
|
|
341
|
+
lines.splice(at, 0, wantEnv)
|
|
342
|
+
changed = true
|
|
260
343
|
}
|
|
261
344
|
return { text: changed ? lines.join('\n') : String(text || ''), changed }
|
|
262
345
|
}
|
|
@@ -275,10 +358,12 @@ export async function setupCodex({ nodePath, dryRun = false, log = () => {} } =
|
|
|
275
358
|
// issue #44 现场的教训:TOML 里可能是**单引号**(`command = 'C:\…'`),老代码只认双引号 →
|
|
276
359
|
// 既读不到(status 报「配置里是 (空)」)也改不掉(`replace` 不命中),**却照样打印「已更新」**。
|
|
277
360
|
// 所以:① 读写都认两种引号;② 改完必须**校验文本真的变了**,没变就如实报失败。
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
const
|
|
281
|
-
|
|
361
|
+
// 必须按 [mcp_servers.memory] 段读取;直接在全文抓 command 会抓到别的 server(issue #44)。
|
|
362
|
+
const entry = parseCodexMemoryEntry(text)
|
|
363
|
+
const curCmd = entry.command
|
|
364
|
+
const argsOk = entry.args.length === want.args.length && want.args.every((a, i) => entry.args[i] === a)
|
|
365
|
+
const envOk = !want.env || /ELECTRON_RUN_AS_NODE\s*=\s*["']1["']/.test(text)
|
|
366
|
+
if (curCmd === want.command && argsOk && envOk) {
|
|
282
367
|
log('✓ Codex MCP 已是最新,跳过')
|
|
283
368
|
return [{ agent: 'codex', ok: true, reason: 'section exists' }]
|
|
284
369
|
}
|
|
@@ -303,6 +388,7 @@ export async function setupCodex({ nodePath, dryRun = false, log = () => {} } =
|
|
|
303
388
|
'[mcp_servers.memory]',
|
|
304
389
|
`command = ${JSON.stringify(want.command)}`,
|
|
305
390
|
`args = ${JSON.stringify(want.args)}`,
|
|
391
|
+
...(want.env ? [codexEnvLine(want.env)] : []),
|
|
306
392
|
'',
|
|
307
393
|
].join('\n')
|
|
308
394
|
if (!dryRun) {
|
|
@@ -367,9 +453,10 @@ export async function runSetup(opts = {}) {
|
|
|
367
453
|
}
|
|
368
454
|
const all = []
|
|
369
455
|
const want = (name) => only.length === 0 || only.includes(name)
|
|
370
|
-
|
|
371
|
-
if (want('
|
|
372
|
-
if (want('
|
|
456
|
+
const nodePath = nodeBinary()
|
|
457
|
+
if (want('claude')) all.push(...await setupClaude({ nodePath, withHooks, dryRun, log }))
|
|
458
|
+
if (want('codex')) all.push(...await setupCodex({ nodePath, dryRun, log }))
|
|
459
|
+
if (want('cursor')) all.push(...await setupCursor({ nodePath, dryRun, log }))
|
|
373
460
|
return { ok: true, results: all }
|
|
374
461
|
}
|
|
375
462
|
|
|
@@ -377,7 +464,7 @@ export async function runSetup(opts = {}) {
|
|
|
377
464
|
* 只读查询各 agent 的 MCP 配置状态。不修改任何文件。
|
|
378
465
|
* @returns {Promise<{ok:true, agents: Array, lastCheckedAt: number}>}
|
|
379
466
|
*/
|
|
380
|
-
export async function getSetupStatus({ nodePath =
|
|
467
|
+
export async function getSetupStatus({ nodePath = nodeBinary() } = {}) {
|
|
381
468
|
const agents = []
|
|
382
469
|
const want = mcpCommand(nodePath)
|
|
383
470
|
// claude-code
|
|
@@ -413,6 +500,10 @@ export async function getSetupStatus({ nodePath = process.execPath } = {}) {
|
|
|
413
500
|
} else {
|
|
414
501
|
agents[agents.length - 1].hook = 'missing'
|
|
415
502
|
}
|
|
503
|
+
const last = agents[agents.length - 1]
|
|
504
|
+
last.mcpNeedsRepair = mcpExists && isLegacyElectronCommand(entry?.command)
|
|
505
|
+
last.hookNeedsRepair = await hooksNeedRepair(path.join(home, '.claude', 'settings.json'), ['capture.mjs', 'secret-guard.mjs'])
|
|
506
|
+
last.needsRepair = Boolean(last.mcpNeedsRepair || last.hookNeedsRepair)
|
|
416
507
|
}
|
|
417
508
|
// codex
|
|
418
509
|
const codexToml = path.join(home, '.codex', 'config.toml')
|
|
@@ -433,12 +524,15 @@ export async function getSetupStatus({ nodePath = process.execPath } = {}) {
|
|
|
433
524
|
servers: listCodexMcpServers(text),
|
|
434
525
|
installed: true,
|
|
435
526
|
mcpConfigured: has,
|
|
436
|
-
mcpMatchesCurrentNode: has && configuredNodePath === nodePath,
|
|
527
|
+
mcpMatchesCurrentNode: has && configuredNodePath === nodePath && (!want.env || /ELECTRON_RUN_AS_NODE\s*=\s*["']1["']/.test(text)),
|
|
437
528
|
currentNodePath: nodePath,
|
|
438
529
|
configuredNodePath,
|
|
439
530
|
mcpPath: codexToml,
|
|
440
531
|
hook: await hookState(path.join(home, '.codex', 'hooks.json'), 'codex'),
|
|
532
|
+
mcpNeedsRepair: has && isLegacyElectronCommand(configuredNodePath),
|
|
533
|
+
hookNeedsRepair: await hooksNeedRepair(path.join(home, '.codex', 'hooks.json'), ['capture.mjs']),
|
|
441
534
|
})
|
|
535
|
+
agents[agents.length - 1].needsRepair = Boolean(agents[agents.length - 1].mcpNeedsRepair || agents[agents.length - 1].hookNeedsRepair)
|
|
442
536
|
}
|
|
443
537
|
// cursor
|
|
444
538
|
const cursorJson = path.join(home, '.cursor', 'mcp.json')
|
|
@@ -464,7 +558,10 @@ export async function getSetupStatus({ nodePath = process.execPath } = {}) {
|
|
|
464
558
|
configuredNodePath: entry?.command || '',
|
|
465
559
|
mcpPath: cursorJson,
|
|
466
560
|
hook: await hookState(path.join(home, '.cursor', 'hooks.json'), 'cursor', true),
|
|
561
|
+
mcpNeedsRepair: mcpExists && isLegacyElectronCommand(entry?.command),
|
|
562
|
+
hookNeedsRepair: await hooksNeedRepair(path.join(home, '.cursor', 'hooks.json'), ['capture.mjs']),
|
|
467
563
|
})
|
|
564
|
+
agents[agents.length - 1].needsRepair = Boolean(agents[agents.length - 1].mcpNeedsRepair || agents[agents.length - 1].hookNeedsRepair)
|
|
468
565
|
} catch { agents.push({ name: 'cursor', installed: true, mcpConfigured: false, mcpPath: cursorJson, reason: 'JSON 解析失败' }) }
|
|
469
566
|
}
|
|
470
567
|
// 兼容性探测:只列出「本机确实存在配置文件」的其它 MCP 客户端(只读,不提供挂载按钮)
|
|
@@ -484,10 +581,11 @@ export async function getSetupStatus({ nodePath = process.execPath } = {}) {
|
|
|
484
581
|
const summary = {
|
|
485
582
|
detectedAgents: agents.filter((a) => a.installed).length,
|
|
486
583
|
mountedAgents: agents.filter((a) => a.mcpConfigured).length,
|
|
584
|
+
needsRepair: agents.filter((a) => a.needsRepair).length,
|
|
487
585
|
totalServers: allServers.length,
|
|
488
586
|
allServers,
|
|
489
587
|
}
|
|
490
|
-
return { ok: true, agents, summary, lastCheckedAt: Date.now(), want: { command: nodePath, args: want.args } }
|
|
588
|
+
return { ok: true, agents, summary, lastCheckedAt: Date.now(), want: { command: nodePath, args: want.args, ...(want.env ? { env: want.env } : {}) } }
|
|
491
589
|
}
|
|
492
590
|
|
|
493
591
|
// -- 会话结束自动沉淀 hook(connect <agent>,agentmemory 的 `connect --with-hooks` 兜底路径)--
|
|
@@ -496,9 +594,9 @@ export async function getSetupStatus({ nodePath = process.execPath } = {}) {
|
|
|
496
594
|
|
|
497
595
|
/** capture.mjs 的完整调用(绝对路径,避免升级后路径内嵌版本号而失效)。 */
|
|
498
596
|
function captureCmd(agent) {
|
|
499
|
-
const node =
|
|
597
|
+
const node = nodeBinary()
|
|
500
598
|
const script = path.join(PACKAGE_ROOT, 'hooks', 'capture.mjs')
|
|
501
|
-
return
|
|
599
|
+
return hookCommand(node, script, agent)
|
|
502
600
|
}
|
|
503
601
|
|
|
504
602
|
/** 写一个幂等 hooks.json:events → [{command}](cursor 风格)或 {hooks:[{type,command}]}(codex 风格)。
|
|
@@ -550,7 +648,7 @@ export async function connectCursorHooks(opts = {}) {
|
|
|
550
648
|
/** 统一入口:claude 复用 setupClaude 的 SessionEnd(capture.mjs);codex/cursor 写 capture.mjs hooks。 */
|
|
551
649
|
export async function connectAgentHooks(agent, opts = {}) {
|
|
552
650
|
if (agent === 'claude' || agent === 'claude-code') {
|
|
553
|
-
const results = await setupClaude({ nodePath:
|
|
651
|
+
const results = await setupClaude({ nodePath: nodeBinary(), withHooks: true, log: opts.log })
|
|
554
652
|
return { ok: results.every((r) => r.ok), results }
|
|
555
653
|
}
|
|
556
654
|
if (agent === 'codex') return connectCodexHooks(opts)
|
|
@@ -578,13 +676,14 @@ export async function removeClaudeMcp({ dryRun = false, log = () => {} } = {}) {
|
|
|
578
676
|
if (await exists(settingsJson)) {
|
|
579
677
|
try {
|
|
580
678
|
const scfg = JSON.parse(await fs.readFile(settingsJson, 'utf8'))
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
679
|
+
let dirty = false
|
|
680
|
+
for (const key of ['SessionEnd', 'PreToolUse']) {
|
|
681
|
+
const list = scfg.hooks?.[key]
|
|
682
|
+
if (!Array.isArray(list)) continue
|
|
683
|
+
const kept = list.filter((h) => !(h.hooks || []).some((c) => c?.command?.includes('capture.mjs') || c?.command?.includes('secret-guard.mjs')))
|
|
684
|
+
if (kept.length !== list.length) { scfg.hooks[key] = kept; dirty = true; removed = true }
|
|
587
685
|
}
|
|
686
|
+
if (dirty && !dryRun) { await backupOnce(settingsJson); await fs.writeFile(settingsJson, JSON.stringify(scfg, null, 2), 'utf8') }
|
|
588
687
|
} catch {}
|
|
589
688
|
}
|
|
590
689
|
log(removed ? (dryRun ? '[dry-run] 将卸载 Claude Code MCP' : '✓ 已卸载 Claude Code MCP') : '✓ Claude Code MCP 未配置')
|
|
@@ -599,7 +698,7 @@ export async function removeCodexMcp({ dryRun = false, log = () => {} } = {}) {
|
|
|
599
698
|
const sectionRe = /^\s*\[mcp_servers\.memory\]\s*$/m
|
|
600
699
|
if (!sectionRe.test(text)) { log('✓ Codex MCP 未配置'); return { agent: 'codex', ok: true, removed: false } }
|
|
601
700
|
if (!dryRun) {
|
|
602
|
-
const newText = text.replace(/^\s*# memory-eternal.*\n?/m, '').replace(/^\s*\[mcp_servers\.memory\]\s*\n(?:command\s*=.*\n|args\s*=.*\n)+\s*/m, '')
|
|
701
|
+
const newText = text.replace(/^\s*# memory-eternal.*\n?/m, '').replace(/^\s*\[mcp_servers\.memory\]\s*\n(?:command\s*=.*\n|args\s*=.*\n|env\s*=.*\n)+\s*/m, '')
|
|
603
702
|
await backupOnce(codexToml)
|
|
604
703
|
await fs.writeFile(codexToml, newText.replace(/\n{3,}/g, '\n\n'), 'utf8')
|
|
605
704
|
}
|
|
@@ -626,7 +725,7 @@ export async function removeCursorMcp({ dryRun = false, log = () => {} } = {}) {
|
|
|
626
725
|
/** 对单个智能体执行安装/卸载 MCP。action: 'install' | 'uninstall'。agent: 'claude-code'|'codex'|'cursor' */
|
|
627
726
|
export async function mcpAgentAction(agent, action, { dryRun = false, log = () => {} } = {}) {
|
|
628
727
|
if (action === 'install') {
|
|
629
|
-
const nodePath =
|
|
728
|
+
const nodePath = nodeBinary()
|
|
630
729
|
if (agent === 'claude-code') return { ok: true, results: await setupClaude({ nodePath, dryRun, log }) }
|
|
631
730
|
if (agent === 'codex') return { ok: true, results: await setupCodex({ nodePath, dryRun, log }) }
|
|
632
731
|
if (agent === 'cursor') return { ok: true, results: await setupCursor({ nodePath, dryRun, log }) }
|
package/lib/vault.js
CHANGED
|
@@ -10,6 +10,7 @@ import { minhashSignature, lshCandidates } from './minhash.js'
|
|
|
10
10
|
|
|
11
11
|
/** 主题目录根。 */
|
|
12
12
|
export const KIND_ROOTS = {
|
|
13
|
+
core: '09-Core',
|
|
13
14
|
project: '02-Projects',
|
|
14
15
|
knowledge: '03-Knowledge',
|
|
15
16
|
content: '04-Content',
|
|
@@ -19,6 +20,9 @@ export const KIND_ROOTS = {
|
|
|
19
20
|
mistake: '08-Mistakes',
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/** 核心记忆:人工批准后,每次会话注入 systemPrompt 的卡片类型。 */
|
|
24
|
+
export const CORE_KIND = 'core'
|
|
25
|
+
|
|
22
26
|
export const CAPTURE_KINDS = ['project', 'knowledge', 'content', 'prompt', 'business', 'tool', 'mistake']
|
|
23
27
|
|
|
24
28
|
/** 从 Markdown 文本提取 frontmatter 与正文。 */
|
|
@@ -593,6 +597,22 @@ export function normalizeTags(tags) {
|
|
|
593
597
|
.slice(0, 20)
|
|
594
598
|
}
|
|
595
599
|
|
|
600
|
+
/**
|
|
601
|
+
* 把一张已批准的卡片设为核心记忆(kind=core),或恢复成普通知识卡。
|
|
602
|
+
* 只有主库(approved)卡片可以设为核心;审核中的卡必须先批准。
|
|
603
|
+
*/
|
|
604
|
+
export async function setCardKind(root, rel, kind) {
|
|
605
|
+
const db = getDb(root)
|
|
606
|
+
const target = String(kind || '').trim()
|
|
607
|
+
const existing = findCardRow(db, rel)
|
|
608
|
+
if (!existing) throw new Error('卡片不存在: ' + rel)
|
|
609
|
+
if (existing.table !== 'cards') throw new Error('只有已批准卡片才能设为核心记忆,请先在审核中心批准')
|
|
610
|
+
if (target !== CORE_KIND && !CAPTURE_KINDS.includes(target)) throw new Error('不支持的卡片类型: ' + target)
|
|
611
|
+
const now = new Date().toISOString()
|
|
612
|
+
db.prepare('UPDATE cards SET kind = ?, updated_at = ? WHERE path = ?').run(target, now, rel)
|
|
613
|
+
return { ok: true, path: rel, kind: target, updatedAt: now }
|
|
614
|
+
}
|
|
615
|
+
|
|
596
616
|
export async function writeCard(root, { kind, title, tags = [], body, source = '', status = 'pending', submittedBy = '', severity = 'info', reason = '', deletedAt = '' }, { threshold = 0.62, dedup = true, dedupAgainst = null } = {}) {
|
|
597
617
|
const db = getDb(root)
|
|
598
618
|
const kindRoot = KIND_ROOTS[kind] || KIND_ROOTS.knowledge
|