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 +168 -1
- package/bin/dsh-memory.mjs +148 -0
- package/hooks/claude-session.js +56 -0
- package/index.js +35 -169
- package/lib/api.js +187 -0
- package/lib/capture-run.js +69 -0
- package/lib/capture.js +23 -2
- package/lib/client.js +5 -5
- package/lib/llm-openai.js +84 -0
- package/lib/mcp.js +162 -0
- package/lib/setup.js +202 -0
- package/lib/sweep.js +56 -0
- package/lib/vault.js +8 -2
- package/lib/web.js +145 -0
- package/package.json +15 -5
- package/web/app.js +223 -0
- package/web/index.html +46 -0
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,
|
|
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
|
-
|
|
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
|
-
}
|