dsh-memory-eternal 0.4.28 → 0.5.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/README.md CHANGED
@@ -146,7 +146,169 @@ dsh plugin --profile web add F:/MyApp/eternal/dsh-memory-eternal
146
146
  > - 要**官方最新**(避免 npmmirror 滞后):`npm_config_registry=https://registry.npmjs.org/ dsh plugin --profile web add dsh-memory-eternal`。
147
147
  > - **profile 是 pnpm workspace**:`cd ~/.dsh/profiles/web && pnpm add dsh-memory-eternal@latest`(`npm install` 会报 `EUNSUPPORTEDPROTOCOL`)。
148
148
 
149
- 装完**重启 dsh web**:设置 → 记忆 出现知识库;侧边栏底部出现「记忆」按钮。此后每轮对话自动沉淀。
149
+ 装完**重启 dsh web**:设置 → 记忆 出现知识库(iframe 加载 Web 端);侧边栏底部出现「记忆」按钮。此后每轮对话自动沉淀。
150
+
151
+ ## 🌐 多宿主:装完即用(Claude Code / Codex / Cursor / 浏览器)
152
+
153
+ 从 v0.5 起,本插件不再只属于 DSH——**一个记忆库,所有 Agent 共享**:
154
+
155
+ ```
156
+ ┌────────── 核心引擎(vault.js + capture.js,零耦合)──────────┐
157
+ │ ① DSH 原生插件 ② MCP server ③ Web 端 │
158
+ │ 自动沉淀+召回 stdio 零依赖 UI 唯一真源 │
159
+ │ UI=iframe 壳 Claude/Codex/ 浏览器直接访问 │
160
+ │ Cursor DSH 渲染也走它 │
161
+ └──────────────────────────────────────────────────────────────┘
162
+ ```
163
+
164
+ ### 自动挂载(零手动)
165
+
166
+ 插件激活时自动检测本机已装的 Agent,**幂等写入 MCP 配置**(改动前自动备份):
167
+
168
+ - **Claude Code** → `~/.claude.json` 的 `mcpServers` + `~/.claude/settings.json` 的 SessionEnd hook(会话结束自动沉淀)
169
+ - **Codex CLI** → `~/.codex/config.toml` 的 `[mcp_servers.memory]`
170
+ - **Cursor** → `~/.cursor/mcp.json`
171
+
172
+ 下次打开对应工具,`memory_recall` / `memory_capture` / `memory_stats` 三个工具直接可用。不想自动配置?设置里关 `autoMcpSetup`,或环境变量 `MEMORY_ETERNAL_SKIP_AUTO=1`。
173
+
174
+ ### Web 端(默认常驻)
175
+
176
+ 插件激活时自动拉起独立 Web server(detached 进程,不占 DSH 宿主),**UI 唯一真源**——DSH 内的设置页与浏览器访问的是同一份:
177
+
178
+ ```bash
179
+ dsh-memory open # 确保 Web 存活 + 浏览器打开(默认 http://127.0.0.1:7999)
180
+ dsh-memory serve --port 8080 # 前台跑 Web server
181
+ ```
182
+
183
+ ### CLI 速查
184
+
185
+ ```bash
186
+ dsh-memory recall "数据库选型" # 检索
187
+ dsh-memory capture "重要结论..." # 手动沉淀
188
+ dsh-memory sweep ~/.claude/projects # 挖掘已有会话记录
189
+ dsh-memory setup --dry-run # 重跑自动挂载(幂等)
190
+ dsh-memory mcp # MCP stdio(挂给任意 MCP 客户端)
191
+ ```
192
+
193
+ ### 蒸馏 LLM(可选)
194
+
195
+ MCP / hooks / CLI 的自动蒸馏需要一个 OpenAI 兼容端点(DSH 内免配):
196
+
197
+ ```bash
198
+ export MEMORY_LLM_BASE_URL=https://api.deepseek.com # 或 Ollama/vLLM/LM Studio
199
+ export MEMORY_LLM_KEY=sk-xxx
200
+ export MEMORY_LLM_MODEL=deepseek-chat
201
+ ```
202
+
203
+ 未配置时:recall 全功能;capture 降级为原文卡(不蒸馏)。
204
+
205
+ ### 单一副本原则(防版本漂移)
206
+
207
+ MCP/hooks/CLI 全部指向 DSH profile 内那一份代码(`~/.dsh/profiles/web/node_modules/dsh-memory-eternal`)。**更新只需一条命令**:`cd ~/.dsh/profiles/web && pnpm add dsh-memory-eternal@latest`,DSH + MCP + Web 齐步走。纯 Claude Code 用户(无 DSH)可 `npm i -g dsh-memory-eternal`,独立副本独立库。卡格式带 `formatVersion` 守卫,跨版本只向后兼容读,多副本共存不会写坏库。
208
+
209
+ ---
210
+
211
+ ## 📖 使用
212
+
213
+ > **核心心智模型:一个库(`~/.dsh/memory-vault`,纯 Markdown + 可 git),一群 Agent(DSH / Claude / Codex / Cursor)自动记、随时忆,一个网页(`dsh-memory open`)看全局。**
214
+
215
+ ### DSH 内使用(现状,0 步骤)
216
+
217
+ ```bash
218
+ # 重启 dsh web 后生效新版插件
219
+ ```
220
+
221
+ - **自动沉淀**:每轮对话结束自动把可复用内容蒸馏成知识卡,无需操作
222
+ - **`memory_recall` 工具**:Agent 在需要历史决策/项目背景时主动调用
223
+ - **可视化**:设置 → 记忆 或侧边栏底部「记忆」按钮 → 内嵌打开 Web 端(同一份 UI)
224
+ - **多 Vault**:设置里 `vaultProfiles` 命名分库 + `activeVault` 切换;跨库聚合检索 `?all=1`
225
+
226
+ ### Claude Code 使用
227
+
228
+ 装完即用——MCP 与 SessionEnd hook 都已自动挂载:
229
+
230
+ - 会话中说「recall 一下数据库选型」→ Claude 自动调 `memory_recall`
231
+ - 会话结束/上下文压缩前 → hook 自动蒸馏入库
232
+ - 想看记了什么:`dsh-memory open`
233
+
234
+ ### Codex CLI / Cursor 使用
235
+
236
+ 重启对应工具后,MCP 已在工具的 MCP 列表里,会话内直接:
237
+
238
+ ```
239
+ 用 memory_recall 查一下项目的历史决策
240
+ 用 memory_stats 看库内有多少张卡
241
+ ```
242
+
243
+ ### Web 端使用
244
+
245
+ ```bash
246
+ dsh-memory open # 确保 Web 存活 + 浏览器打开(默认 http://127.0.0.1:7999)
247
+ dsh-memory serve --port 8080 # 自定义端口前台运行
248
+ ```
249
+
250
+ 打开后所见即所得:统计概览 / 中文片段检索 / 知识卡网格(删除/编辑/合并/导入导出)/ 增强知识图谱(力导向+时间维着色+右键菜单+框选+PNG 导出)。与 DSH 内嵌「记忆」页面渲染同一份 UI(单一真源),数据天然同步。
251
+
252
+ ### CLI 速查
253
+
254
+ ```bash
255
+ dsh-memory recall "数据库选型" # 检索知识卡(输出 JSON)
256
+ dsh-memory capture "重要结论..." # 手动沉淀(- 读 stdin)
257
+ dsh-memory sweep ~/.claude/projects # 挖掘已有会话记录
258
+ dsh-memory setup --dry-run # 预览自动挂载(幂等)
259
+ dsh-memory mcp # MCP stdio(手动挂任意 MCP 客户端)
260
+ dsh-memory serve --port 7999 # 前台 Web server
261
+ dsh-memory open # 确保 Web 存活 + 浏览器打开
262
+ ```
263
+
264
+ ### 蒸馏 LLM(可选)
265
+
266
+ MCP / hooks / CLI 的自动蒸馏需要一个 OpenAI 兼容端点(DSH 内免配——复用 dsh 的 llm):
267
+
268
+ ```bash
269
+ export MEMORY_LLM_BASE_URL=https://api.deepseek.com # 或 Ollama/vLLM/LM Studio
270
+ export MEMORY_LLM_KEY=sk-xxx
271
+ export MEMORY_LLM_MODEL=deepseek-chat
272
+ ```
273
+
274
+ 未配置时:recall 全功能;capture 降级为原文卡(不蒸馏,仍可正常用)。
275
+
276
+ ### zcode(智谱)说明
277
+
278
+ zcode 暂无原生 MCP 支持,可经社区 [zcode-open-bridge](https://github.com/tizerluo/zcode-open-bridge) 转 MCP 后挂载,或直接用 CLI(`dsh-memory capture / recall / sweep`)。
279
+
280
+ ---
281
+
282
+ ## 🔄 更新
283
+
284
+ **单副本原则**是更新能「一条命令搞定」的根基——所有入口都指向同一份代码。
285
+
286
+ ```bash
287
+ # DSH 用户(最常见):profile 里更新 → DSH + MCP + Web 齐步走
288
+ cd ~/.dsh/profiles/web
289
+ pnpm add dsh-memory-eternal@latest
290
+
291
+ # 纯 Claude Code / Cursor 用户(无 DSH):全局包更新
292
+ npm i -g dsh-memory-eternal@latest
293
+ ```
294
+
295
+ 更新完后:
296
+ - **DSH 用户**:重启 dsh web(插件 reload)→ `autoMcpSetup` 自动幂等跳过(配置已是最新),`autoWeb` 进程已是新版
297
+ - **Claude/Codex/Cursor 用户**:重启对应工具,stdin MCP 进程由 agent 重 spawn → 加载新版
298
+
299
+ 不会出现的版本漂移:所有 agent 入口指向同一份代码,不存在「DSH 是新版、Claude Code 是旧版」导致的格式/行为不一致。卡文件 `formatVersion` 字段是最后一道防线,跨版本只向后兼容读。
300
+
301
+ 如果想从 GitHub 拉未发布版(dev/特性分支):
302
+
303
+ ```bash
304
+ # DSH 用户
305
+ cd ~/.dsh/profiles/web && pnpm add github:EternalNight996/dsh-memory-eternal
306
+
307
+ # 全局
308
+ npm i -g github:EternalNight996/dsh-memory-eternal
309
+ ```
310
+
311
+ ---
150
312
 
151
313
  ### 🧭 插件发现 / 收录标准
152
314
 
@@ -178,6 +340,8 @@ dsh plugin --profile web add F:/MyApp/eternal/dsh-memory-eternal
178
340
  | activeVault | '' | 当前激活的 Vault(对应 vaultProfiles 中的 name) |
179
341
  | sessionBudgetChars | 80000 | 会话级 token 预算(字符数),供 harness 触发压缩 |
180
342
  | recallEmbedding | '' | 语义召回 provider(空=零依赖 bigram + LLM 判定兜底) |
343
+ | autoMcpSetup | 开 | 激活时自动把 MCP 挂到本机已装的 Claude Code/Codex/Cursor(幂等+备份) |
344
+ | autoWeb | 开 | 激活时确保 Web server 常驻(UI 唯一真源,DSH 渲染走 iframe) |
181
345
 
182
346
  ---
183
347
 
@@ -223,6 +387,7 @@ dsh-memory-eternal/
223
387
  - **新建卡片 kind 下拉可读背景色**(v0.4.27)
224
388
  - **图谱合并/删除即时联动整理建议**(v0.4.27)
225
389
  - **一键合并相似按钮从图谱工具栏移到整理建议面板**(v0.4.26)
390
+ - **多宿主**:MCP server + CLI `dsh-memory` + Web 端常驻 + 自动挂载 Claude/Codex/Cursor + SessionEnd hook + `formatVersion` 守卫(v0.5.0)
226
391
 
227
392
  **待办(核心功能优化)**:
228
393
  - [x] ~~多 Vault / 多 Profile~~、~~注入体积可配置~~、~~每日回顾+用量~~、~~自动归档整理~~、~~召回用户反馈~~、~~会话级 token 预算~~(v0.4.5)
@@ -231,11 +396,13 @@ dsh-memory-eternal/
231
396
  - [ ] 多 Vault 的**跨库合并/去重**(跨 profile 识别重复卡)
232
397
  - [ ] 语义召回**接入外部 embedding provider**(当前为本地零依赖加分)
233
398
  - [ ] 每日回顾**主动推送通道**(当前生成文件 + 手动查看,推送需 harness/通知渠道)
399
+ - [ ] **多宿主体验进一步收口**:zcode bridge 内置 / Codex sweep 增字段适配 / Web 端暗色主题微调
234
400
 
235
401
  ---
236
402
 
237
403
  ## 📦 发布记录
238
404
 
405
+ - **v0.5.0**:**多宿主化**——MCP server 零依赖 stdio(3 工具:recall/capture/stats),CLI 入口 `dsh-memory`(recall/capture/serve/open/mcp/setup/sweep),Web 端常驻(DSH 渲染走 iframe = 单一 UI 真源),自动挂载到 Claude Code/Codex/Cursor(幂等+备份),Claude Code SessionEnd hook 自动沉淀,卡格式 `formatVersion` 守卫,DSH 用户 / Claude Code 用户均「一条命令安装即用」。`api.js` 抽层 + `capture-run.js` 独立沉淀管线复用。
239
406
  - **v0.4.28**:侧边栏底部「记忆」按钮**独占一行**——不再与「主题」等 footer 按钮挤在同一行(`flex-wrap` 同时命中直接包含与中间隔 wrapper 两种情况)。
240
407
  - **v0.4.27**:新建卡片 kind 下拉可读背景色;图谱合并/删除节点后**即时联动整理建议**面板刷新。
241
408
  - **v0.4.26**:一键合并相似按钮从图谱工具栏移到整理建议面板,操作路径更直观。
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ // 记忆核心 · CLI(多宿主统一入口)
3
+ //
4
+ // 用法:
5
+ // dsh-memory recall <query> [--limit N] [--vault DIR] 检索知识卡
6
+ // dsh-memory capture <text | - > [--source TAG] 手动沉淀(- 读 stdin)
7
+ // dsh-memory serve [--port N] [--vault DIR] 启动 Web UI(前台常驻)
8
+ // dsh-memory open [--port N] 确保 Web 存活并用浏览器打开
9
+ // dsh-memory mcp MCP stdio server(各 agent 挂载)
10
+ // dsh-memory setup [--claude-only|--codex-only|--cursor-only] [--dry-run] [--no-hooks]
11
+ // 自动挂载 MCP 到已装的 agent
12
+ // dsh-memory sweep <dir> 挖掘 Claude Code 会话 JSONL
13
+ //
14
+ // 环境变量:
15
+ // MEMORY_VAULT_DIR vault 目录(默认 ~/.dsh/memory-vault)
16
+ // MEMORY_LLM_BASE_URL / MEMORY_LLM_KEY / MEMORY_LLM_MODEL 蒸馏 LLM(OpenAI 兼容)
17
+ // MEMORY_ETERNAL_SKIP_AUTO=1 setup 时不动外部配置
18
+
19
+ import path from 'node:path'
20
+ import process from 'node:process'
21
+
22
+ const argv = process.argv.slice(2)
23
+ const cmd = argv[0] || 'help'
24
+ const argOf = (name) => {
25
+ const i = argv.indexOf(name)
26
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null
27
+ }
28
+ const has = (name) => argv.includes(name)
29
+
30
+ /** 取位置参数(跳过子命令、flag 与 flag 的值) */
31
+ const positional = () => {
32
+ const valuesTaken = new Set(['--limit', '--vault', '--source', '--port'])
33
+ const out = []
34
+ for (let i = 1; i < argv.length; i++) {
35
+ const a = argv[i]
36
+ if (valuesTaken.has(a)) { i++; continue }
37
+ if (a.startsWith('--')) continue
38
+ out.push(a)
39
+ }
40
+ return out
41
+ }
42
+
43
+ async function main() {
44
+ switch (cmd) {
45
+ case 'recall': {
46
+ const query = positional().join(' ')
47
+ const { search } = await import('../lib/vault.js')
48
+ const { defaultVaultDir } = await import('../lib/capture-run.js')
49
+ const root = path.resolve(argOf('--vault') || defaultVaultDir())
50
+ const limit = Math.min(Math.max(Number(argOf('--limit')) || 5, 1), 20)
51
+ const hits = await search(root, query, { limit, minScore: 2 })
52
+ if (!hits.length) {
53
+ console.log(JSON.stringify({ ok: true, hits: [], note: `记忆库中没有与「${query}」相关的内容` }, null, 2))
54
+ return
55
+ }
56
+ console.log(JSON.stringify({ ok: true, vaultDir: root, hits }, null, 2))
57
+ return
58
+ }
59
+ case 'capture': {
60
+ const raw = positional().join(' ')
61
+ let text = raw
62
+ if (raw === '-' || (!raw && !process.stdin.isTTY)) {
63
+ text = await new Promise((resolve) => {
64
+ let buf = ''
65
+ process.stdin.setEncoding('utf8')
66
+ process.stdin.on('data', (c) => { buf += c })
67
+ process.stdin.on('end', () => resolve(buf))
68
+ })
69
+ }
70
+ const { runStandaloneCapture, defaultVaultDir } = await import('../lib/capture-run.js')
71
+ const root = path.resolve(defaultVaultDir())
72
+ const out = await runStandaloneCapture(root, text, { source: argOf('--source') || 'cli' })
73
+ console.log(JSON.stringify({ ...out, vaultDir: root }, null, 2))
74
+ process.exitCode = out.ok ? 0 : 1
75
+ return
76
+ }
77
+ case 'serve': {
78
+ const { startWebServer, DEFAULT_WEB_PORT } = await import('../lib/web.js')
79
+ const { defaultVaultDir } = await import('../lib/capture-run.js')
80
+ const port = Number(argOf('--port')) || DEFAULT_WEB_PORT
81
+ const vault = path.resolve(argOf('--vault') || defaultVaultDir())
82
+ await startWebServer({ port, vaultRoot: vault })
83
+ process.on('SIGINT', () => process.exit(0))
84
+ process.on('SIGTERM', () => process.exit(0))
85
+ return
86
+ }
87
+ case 'open': {
88
+ const { ensureWebServer, DEFAULT_WEB_PORT } = await import('../lib/web.js')
89
+ const { defaultVaultDir } = await import('../lib/capture-run.js')
90
+ const port = Number(argOf('--port')) || DEFAULT_WEB_PORT
91
+ const { url } = await ensureWebServer({ port, vaultRoot: path.resolve(defaultVaultDir()) })
92
+ console.log(url)
93
+ const plat = process.platform
94
+ const { exec } = await import('node:child_process')
95
+ if (plat === 'win32') exec(`start "" "${url}"`)
96
+ else if (plat === 'darwin') exec(`open "${url}"`)
97
+ else exec(`xdg-open "${url}"`)
98
+ return
99
+ }
100
+ case 'mcp': {
101
+ const { startMcpServer } = await import('../lib/mcp.js')
102
+ startMcpServer()
103
+ return
104
+ }
105
+ case 'setup': {
106
+ const { runSetup } = await import('../lib/setup.js')
107
+ const only = []
108
+ if (has('--claude-only')) only.push('claude')
109
+ if (has('--codex-only')) only.push('codex')
110
+ if (has('--cursor-only')) only.push('cursor')
111
+ const out = await runSetup({ only, withHooks: !has('--no-hooks'), dryRun: has('--dry-run') })
112
+ const done = out.results.filter((r) => r.ok).length
113
+ console.log(`\n记忆库目录:默认 ~/.dsh/memory-vault(MEMORY_VAULT_DIR 可改)`)
114
+ console.log(`完成:${done}/${out.results.length} 项。Web UI: dsh-memory open`)
115
+ return
116
+ }
117
+ case 'sweep': {
118
+ const dir = argv[1]
119
+ if (!dir || dir.startsWith('--')) {
120
+ console.error('用法:dsh-memory sweep <dir>(如 ~/.claude/projects)')
121
+ process.exitCode = 1
122
+ return
123
+ }
124
+ const { sweepSessions } = await import('../lib/sweep.js')
125
+ const out = await sweepSessions(dir)
126
+ console.log(JSON.stringify(out, null, 2))
127
+ return
128
+ }
129
+ default:
130
+ console.log(`dsh-memory — 记忆核心 CLI
131
+
132
+ 用法:
133
+ dsh-memory recall <query> [--limit N] 检索知识卡
134
+ dsh-memory capture <text | -> [--source T] 手动沉淀(- 读 stdin)
135
+ dsh-memory serve [--port N] Web UI(前台)
136
+ dsh-memory open 确保 Web 存活并打开浏览器
137
+ dsh-memory mcp MCP stdio server
138
+ dsh-memory setup [--dry-run] ... 自动挂载 MCP 到已装 agent
139
+ dsh-memory sweep <dir> 挖掘会话 JSONL
140
+
141
+ 环境变量:MEMORY_VAULT_DIR / MEMORY_LLM_BASE_URL / MEMORY_LLM_KEY / MEMORY_LLM_MODEL`)
142
+ }
143
+ }
144
+
145
+ main().catch((error) => {
146
+ console.error(String(error?.stack || error))
147
+ process.exit(1)
148
+ })
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // 记忆核心 · Claude Code SessionEnd/PreCompact hook(Windows 友好,纯 node)。
3
+ //
4
+ // Claude Code 在会话结束/压缩前触发,stdin 传入 JSON:
5
+ // { session_id, transcript_path, hook_event_name, cwd, ... }
6
+ // 本脚本解析 transcript JSONL,提取最近若干轮 user/assistant 文本,蒸馏入库。
7
+ // 由 lib/setup.js 自动注册到 ~/.claude/settings.json 的 hooks.SessionEnd。
8
+ //
9
+ // 特性:静默失败(hook 崩溃不能影响 Claude Code);幂等(重复跑同一段只追加)。
10
+
11
+ import { promises as fs } from 'node:fs'
12
+ import path from 'node:path'
13
+
14
+ async function main() {
15
+ let input = {}
16
+ try {
17
+ const raw = await new Promise((resolve) => {
18
+ let buf = ''
19
+ process.stdin.setEncoding('utf8')
20
+ process.stdin.on('data', (c) => { buf += c })
21
+ process.stdin.on('end', () => resolve(buf))
22
+ setTimeout(() => resolve(buf), 2000)
23
+ })
24
+ input = JSON.parse(raw || '{}')
25
+ } catch { /* 静默 */ }
26
+
27
+ const transcriptPath = input.transcript_path
28
+ if (!transcriptPath) return
29
+ let lines
30
+ try { lines = (await fs.readFile(transcriptPath, 'utf8')).split('\n').filter(Boolean) } catch { return }
31
+
32
+ // 提取尾部对话文本(user 与 assistant 的 message.content 文本部分)
33
+ const texts = []
34
+ for (const line of lines.slice(-400)) {
35
+ let rec
36
+ try { rec = JSON.parse(line) } catch { continue }
37
+ const msg = rec?.message
38
+ if (!msg || !msg.role) continue
39
+ const content = msg.content
40
+ let text = ''
41
+ if (typeof content === 'string') text = content
42
+ else if (Array.isArray(content)) {
43
+ text = content.filter((c) => c?.type === 'text').map((c) => c.text).join('\n')
44
+ }
45
+ if (text && text.trim().length > 4) texts.push(`${msg.role}: ${text.trim()}`)
46
+ }
47
+ const conversation = texts.slice(-30).join('\n\n')
48
+ if (conversation.trim().length < 120) return
49
+
50
+ const { runStandaloneCapture, defaultVaultDir } = await import('../lib/capture-run.js')
51
+ await runStandaloneCapture(defaultVaultDir(), conversation, {
52
+ source: `claude-code:${input.session_id || path.basename(transcriptPath)}`,
53
+ })
54
+ }
55
+
56
+ main().catch(() => { /* hook 静默失败 */ })
package/index.js CHANGED
@@ -19,9 +19,9 @@ import path from 'node:path'
19
19
  import os from 'node:os'
20
20
  import z from '@deepseek-ai/schemastery'
21
21
  import { defineTool } from '@deepseek-ai/dsh-tools'
22
- import { ensureVault, listCards, readCard, search, searchAll, graph, graphAll, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, readFeedback, addFeedback, dailyBrief, generateDailyBrief, mergeCards, dailyCounts } from './lib/vault.js'
23
- import { compressExcerpt } from './lib/capture.js'
22
+ import { ensureVault, search, generateDailyBrief } from './lib/vault.js'
24
23
  import { summarizeTurn, extractLastTurn, sliceNewEvents, resolveRoute, captureCard, captureUpdate, pickNeighbors } from './lib/capture.js'
24
+ import { createApi, json } from './lib/api.js'
25
25
 
26
26
  export const name = 'memory-eternal'
27
27
  export const inject = ['systemPrompt', 'settings']
@@ -46,6 +46,10 @@ export const Config = z.object({
46
46
  recallEmbedding: z.string().default(''),
47
47
  // 会话级 token 预算(字符),供 harness 触发压缩/轮换;记忆侧提供估算与阈值
48
48
  sessionBudgetChars: z.number().default(80000),
49
+ // 多宿主:激活时自动把 MCP 挂载到本机已装的 Claude Code/Codex/Cursor(幂等,
50
+ // MEMORY_ETERNAL_SKIP_AUTO=1 可完全禁用);web server 默认常驻(UI 唯一真源)。
51
+ autoMcpSetup: z.boolean().default(true),
52
+ autoWeb: z.boolean().default(true),
49
53
  })
50
54
 
51
55
  const API_PREFIX = '/memory-eternal/api'
@@ -250,14 +254,24 @@ export function apply(ctx, config) {
250
254
  ctx.effect(() => () => clearInterval(briefTimer), 'memory-eternal: daily brief timer')
251
255
 
252
256
  // -- 3. 知识库 JSON API(客户端设置页数据源) ----------------------------
257
+ // 多宿主状态:web server 常驻地址(ensureWebServer 的结果,client 壳经
258
+ // /web-info 读取后用 iframe 渲染 web 端 UI——DSH 渲染也走 web,单一真源)。
259
+ let webInfo = { url: 'http://127.0.0.1:7999', port: 7999, alive: false }
260
+ const refreshWebInfo = (info) => { if (info && info.url) webInfo = { ...info, alive: true } }
261
+
253
262
  const webServer = ctx.get('webServer')
254
263
  if (webServer !== undefined) {
264
+ const handleApi = createApi({ vaultDir, vaultRoots, getSettings: settings.get })
255
265
  webServer.register({
256
266
  kind: 'prefix',
257
267
  path: API_PREFIX,
258
268
  handler: async (req, res) => {
259
269
  try {
260
- await handleApi(req, res, vaultDir())
270
+ const pathname = new URL(req.url, 'http://localhost').pathname
271
+ if (pathname === API_PREFIX + '/web-info') {
272
+ return json(res, 200, { ok: true, ...webInfo })
273
+ }
274
+ await handleApi(req, res)
261
275
  } catch (error) {
262
276
  json(res, 500, { ok: false, error: String(error?.message || error) })
263
277
  }
@@ -265,175 +279,27 @@ export function apply(ctx, config) {
265
279
  })
266
280
  }
267
281
 
282
+ // -- 4. 多宿主常驻:自动挂载 MCP + web server 保活 -------------------------
283
+ // 全部后台异步、静默失败:插件激活不能被外部环境问题卡住。
284
+ ctx.effect(() => {
285
+ const cfg0 = settings.get() ?? {}
286
+ if (cfg0.enabled !== false && cfg0.autoMcpSetup !== false && process.env.MEMORY_ETERNAL_SKIP_AUTO !== '1') {
287
+ import('./lib/setup.js')
288
+ .then((m) => m.runSetup({ log: () => {} }))
289
+ .catch(() => {})
290
+ }
291
+ if (cfg0.enabled !== false && cfg0.autoWeb !== false) {
292
+ import('./lib/web.js')
293
+ .then((m) => m.ensureWebServer({ vaultRoot: vaultDir() }))
294
+ .then(refreshWebInfo)
295
+ .catch((error) => console.error('[memory-eternal] web server failed:', error?.message || error))
296
+ }
297
+ return () => {}
298
+ }, 'memory-eternal: multi-host ensure')
299
+
268
300
  // 首次激活时确保 vault 目录存在。
269
301
  ctx.effect(() => {
270
302
  const root = vaultDir()
271
303
  ensureVault(root).catch((error) => console.error('[memory-eternal] ensureVault failed:', error))
272
304
  }, 'memory-eternal: ensure vault')
273
305
  }
274
-
275
- // -- API -------------------------------------------------------------------
276
-
277
- async function handleApi(req, res, vaultRoot) {
278
- const url = new URL(req.url, 'http://localhost')
279
- const route = url.pathname.slice(API_PREFIX.length).replace(/\/+$/, '') || '/overview'
280
- const query = url.searchParams
281
-
282
- switch (route) {
283
- case '/overview': {
284
- await ensureVault(vaultRoot)
285
- json(res, 200, { ok: true, vaultDir: vaultRoot, ...(await overview(vaultRoot)) })
286
- return
287
- }
288
- case '/cards': {
289
- const kind = query.get('kind') || ''
290
- const q = query.get('q') || ''
291
- const limit = Math.min(Number(query.get('limit')) || 200, 500)
292
- let cards = await listCards(vaultRoot)
293
- if (kind) cards = cards.filter((c) => c.kind === kind)
294
- if (q.trim()) {
295
- const hits = await search(vaultRoot, q, { limit: 200 })
296
- const hitPaths = new Set(hits.map((h) => h.path))
297
- cards = cards.filter((c) => hitPaths.has(c.path))
298
- }
299
- json(res, 200, { ok: true, cards: cards.slice(0, limit) })
300
- return
301
- }
302
- case '/card': {
303
- const rel = query.get('path') || ''
304
- if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
305
- const text = await readCard(vaultRoot, rel)
306
- json(res, 200, { ok: true, path: rel, text })
307
- return
308
- }
309
- case '/search': {
310
- const q = query.get('q') || ''
311
- if (!q.trim()) return json(res, 200, { ok: true, hits: [] })
312
- const all = query.get('all') === '1'
313
- const semantic = query.get('semantic') === '1'
314
- let hits
315
- try { hits = all ? await searchAll(vaultRoots(), q, { limit: 30, semantic }) : await search(vaultRoot, q, { limit: 30, semantic }) }
316
- catch (e) { hits = await search(vaultRoot, q, { limit: 30, semantic }) }
317
- json(res, 200, { ok: true, hits })
318
- return
319
- }
320
- case '/graph': {
321
- await ensureVault(vaultRoot)
322
- const all = query.get('all') === '1'
323
- let g
324
- try { g = all ? await graphAll(vaultRoots()) : await graph(vaultRoot) }
325
- catch (e) { g = await graph(vaultRoot) }
326
- json(res, 200, { ok: true, ...g })
327
- return
328
- }
329
- case '/todayBrief': {
330
- await ensureVault(vaultRoot)
331
- const s = await stats(vaultRoot)
332
- json(res, 200, { ok: true, today: s.today, brief: dailyBrief(s.todayCards) })
333
- return
334
- }
335
- case '/export': {
336
- await ensureVault(vaultRoot)
337
- json(res, 200, { ok: true, cards: await exportCards(vaultRoot) })
338
- return
339
- }
340
- case '/delete': {
341
- const rel = query.get('path') || ''
342
- if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
343
- await deleteCard(vaultRoot, rel)
344
- json(res, 200, { ok: true })
345
- return
346
- }
347
- case '/write': {
348
- let raw = ''
349
- for await (const chunk of req) raw += chunk
350
- let body
351
- try { body = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
352
- if (!body.body) return json(res, 400, { ok: false, error: '缺少正文' })
353
- await ensureVault(vaultRoot)
354
- const r = await writeCard(vaultRoot, { kind: body.kind || 'knowledge', title: body.title || '无标题', tags: body.tags || [], body: body.body, source: body.source || 'manual' }, { dedup: false })
355
- json(res, 200, r)
356
- return
357
- }
358
- case '/import': {
359
- let raw = ''
360
- for await (const chunk of req) raw += chunk
361
- if (raw.length > 20 * 1024 * 1024) return json(res, 413, { ok: false, error: '文件过大' })
362
- let payload
363
- try { payload = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
364
- const list = payload.cards || []
365
- if (!Array.isArray(list)) return json(res, 400, { ok: false, error: '缺少 cards 数组' })
366
- let imported = 0, skipped = 0
367
- for (const c of list) {
368
- const text = c.text || ''
369
- let kind = c.kind || 'knowledge', title = c.title || '导入记忆', tags = [], body = text, source = ''
370
- try { const p = parseCard(text); kind = p.meta.kind || kind; title = p.meta.title || title; tags = p.meta.tags || tags; body = p.body; source = p.meta.source } catch {}
371
- const r = await writeCard(vaultRoot, { kind, title, tags, body, source })
372
- if (r.ok) imported++; else skipped++
373
- }
374
- json(res, 200, { ok: true, imported, skipped })
375
- return
376
- }
377
- case '/merge': {
378
- const paths = (query.get('paths') || '').split(',').map((p) => p.trim()).filter(Boolean)
379
- const r = await mergeCards(vaultRoot, paths)
380
- if (!r.ok) return json(res, 400, r)
381
- json(res, 200, r)
382
- return
383
- }
384
- case '/stats': {
385
- await ensureVault(vaultRoot)
386
- const days = Math.min(Number(query.get('days')) || 30, 90)
387
- json(res, 200, { ok: true, ...(await stats(vaultRoot)), trend: await dailyCounts(vaultRoot, days) })
388
- return
389
- }
390
- case '/optimize': {
391
- // 非破坏性:只返回「整理建议」(相似卡对 + 陈旧卡),不自动删改。
392
- json(res, 200, { ok: true, ...(await optimizeCandidates(vaultRoot)) })
393
- return
394
- }
395
- case '/feedback': {
396
- let raw = ''
397
- for await (const chunk of req) raw += chunk
398
- let rec
399
- try { rec = JSON.parse(raw || '{}') } catch { return json(res, 400, { ok: false, error: 'JSON 解析失败' }) }
400
- if (!rec || !rec.path) return json(res, 400, { ok: false, error: '缺少 path' })
401
- await addFeedback(vaultRoot, { query: String(rec.query || ''), path: String(rec.path || ''), useful: rec.useful === true })
402
- json(res, 200, { ok: true })
403
- return
404
- }
405
- case '/budget': {
406
- try {
407
- const cfg = settings.get() ?? {}
408
- json(res, 200, { ok: true, budgetChars: cfg.sessionBudgetChars ?? 80000, recallLimit: cfg.recallLimit ?? 5, embedding: cfg.recallEmbedding || '' })
409
- } catch (e) {
410
- // 配置读取异常时降级返回默认值,避免面板整块红屏
411
- json(res, 200, { ok: true, budgetChars: 80000, recallLimit: 5, embedding: '' })
412
- }
413
- return
414
- }
415
- case '/compress': {
416
- // 记忆侧「压缩产物」接口:供 harness 在会话内压缩旧轮次时调用,返回一段可注入的摘要。
417
- const cfg = settings.get() ?? {}
418
- const body = new URLSearchParams(query)
419
- const text = body.get('text') || ''
420
- const maxChars = Math.min(Number(body.get('max')) || 2400, 6000)
421
- if (!text.trim()) return json(res, 400, { ok: false, error: '缺少 text' })
422
- const compressed = await compressExcerpt(text, maxChars)
423
- json(res, 200, { ok: true, compressed, budgetChars: cfg.sessionBudgetChars ?? 80000 })
424
- return
425
- }
426
- default:
427
- json(res, 404, { ok: false, error: '未知接口' })
428
- }
429
- }
430
-
431
- function json(res, status, body) {
432
- const payload = JSON.stringify(body)
433
- res.writeHead(status, {
434
- 'Content-Type': 'application/json; charset=utf-8',
435
- 'Content-Length': Buffer.byteLength(payload),
436
- 'Cache-Control': 'no-store',
437
- })
438
- res.end(payload)
439
- }