dsh-engramory-recall 0.7.0 → 0.8.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/CHANGELOG.md +21 -0
- package/README.md +48 -2
- package/mcp.js +384 -0
- package/package.json +9 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,27 @@
|
|
|
3
3
|
本项目的所有重要变更都记在这里。格式参照 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
|
4
4
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
5
5
|
|
|
6
|
+
## [0.8.0] — 2026-10-15
|
|
7
|
+
|
|
8
|
+
### 新增
|
|
9
|
+
|
|
10
|
+
- **零依赖 MCP 服务器(`mcp.js`)**:走 stdio 的只读 MCP 服务器,把同一份记忆库挂给任何支持
|
|
11
|
+
MCP 的宿主(Claude Code / Codex / Cursor…)。三个工具:`engramory_search`(同一个 BM25 内核)、
|
|
12
|
+
`engramory_read`(按文件名读原文,只认库根目录的 `.md`)、`engramory_status`(库位置 / 篇数 /
|
|
13
|
+
索引余量 / 指纹);每篇笔记另外注册成 MCP 资源(`engramory://note/<文件名>`)。
|
|
14
|
+
只用 `node:` 内置模块、不 import 任何宿主包、**只读** —— 写入闸门与归档依赖宿主工具层的钩子,
|
|
15
|
+
别的宿主没有这套接口,硬做只会把 fail-closed 换成口头约定。
|
|
16
|
+
装了包就多一个命令:`engramory-recall-mcp`(库路径用 `--store` 或环境变量 `ENGRAMORY_STORE`)。
|
|
17
|
+
- `npm run mcp:smoke`:拿脚本当 MCP 客户端,把服务器真的拉起来走一遍对话
|
|
18
|
+
(initialize → tools/list → status → search → resources/read → 越界读被拒),打印收发记录。
|
|
19
|
+
|
|
20
|
+
### 测试
|
|
21
|
+
|
|
22
|
+
- `test/mcp.test.mjs`:8 例,**全部起真进程**(协议版本协商、工具清单与 inputSchema、检索命中、
|
|
23
|
+
非法参数只回 isError 不炸进程、`../` 越界读一律拒、resources 往返、空库给「库在哪」的提示、
|
|
24
|
+
stdout 上只允许协议帧且日志只走 stderr,外加 `--version` / `--print-config` / 坏参数的退出码)。
|
|
25
|
+
合计 192 例全绿。
|
|
26
|
+
|
|
6
27
|
## [0.7.0] — 2026-10-15
|
|
7
28
|
|
|
8
29
|
### 变更
|
package/README.md
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
全部用 `node:` 内置模块实现:零第三方依赖、零网络、**默认零模型调用**、零常驻进程
|
|
11
11
|
(只有你显式开启可选的语义增强时,才会走宿主的模型服务)。
|
|
12
12
|
|
|
13
|
+
5. **别的宿主也能用**:`mcp.js` 是一个零依赖的 MCP(stdio)服务器,同一份记忆库可以挂给
|
|
14
|
+
Claude Code / Codex / Cursor 这类支持 MCP 的宿主 —— **只读**(检索与读笔记),写治理留在下面。
|
|
15
|
+
|
|
13
16
|
## 底线
|
|
14
17
|
|
|
15
18
|
| 底线 | 本插件如何守 |
|
|
@@ -77,7 +80,10 @@
|
|
|
77
80
|
- **无语义检索**:没有 embedding、没有向量库。同义改写、隐含意图要靠手工同义表覆盖,
|
|
78
81
|
没覆盖到的就是漏检。(可选的 `ctx.llm` 模块能做矛盾检测和摘要,但它**不参与检索排序**,
|
|
79
82
|
所以排序本身仍然是纯词面的。)
|
|
80
|
-
-
|
|
83
|
+
- **只有 DSH 里是「全功能」**:0.8.0 起带一个零依赖的 MCP 服务器(见下),任何支持 MCP
|
|
84
|
+
的宿主都能检索与读笔记;但写入闸门、归档、体检、沉淀提醒、面板这些**只在家用版(DSH 插件)
|
|
85
|
+
里有** —— 别的宿主拿到的是只读视图。`claude-mem` 覆盖 7 个宿主、`thoth-mem` 覆盖 4 个,
|
|
86
|
+
我们覆盖「1 个全功能 + N 个只读」。
|
|
81
87
|
- **没有多端或团队同步**:basic-memory 有云同步,本插件没有。
|
|
82
88
|
- **写入闸门可以被绕过**:`guard` 只拦 `write` / `edit` 工具调用,`bash` 重定向它看不见;
|
|
83
89
|
`@jipika/dsh-memory` 的 guard 挂在更底层的 `tools/pre-execute`,覆盖更广。
|
|
@@ -499,6 +505,9 @@ aliases: 梯子, 翻墙, 科学上网, 节点, 代理
|
|
|
499
505
|
| 自动合并 | 只出报告,不自动改文件 | 已实现(只报告) |
|
|
500
506
|
| 语义冲突检测 | 走宿主 `ctx.llm`,**默认关闭** | 已实现(`semanticConflicts`) |
|
|
501
507
|
| 自动摘要 | 走宿主 `ctx.llm`,**默认关闭** | 已实现(`semanticSummary`) |
|
|
508
|
+
| 跨宿主分发 | 零依赖 stdio MCP 服务器,只读 | 已实现(`mcp.js`) |
|
|
509
|
+
| 跨会话覆盖 | 「结果要多样」的选桶护栏(MMR / 覆盖优先) | 量过了,**被证伪**(见「被实测证伪的路」),收益改在长度归一 `B` 上拿到 |
|
|
510
|
+
| 可选语义检索 | 可插拔 seam(向量/API 都行),默认关闭 | 计划中 |
|
|
502
511
|
|
|
503
512
|
后两条默认关闭,是为了让「零模型调用」这条基线保持可复现。开启是用户的显式选择,
|
|
504
513
|
而不是装完就生效:一个会自己调模型改写你笔记的插件不该默认打开。走 `ctx.llm`
|
|
@@ -580,14 +589,51 @@ dsh plugin --profile <profile> remove dsh-engramory-recall
|
|
|
580
589
|
指向已删的路径),添或删任何插件都会一起失败,而且报错说的是那个无关的包。
|
|
581
590
|
排查时看 `.plugin-manager/logs/operation-*/pnpm.log`,别盯被操作的那个包。
|
|
582
591
|
|
|
592
|
+
## 别的宿主:MCP 服务器(只读)
|
|
593
|
+
|
|
594
|
+
`mcp.js` 是一个零依赖的 MCP(Model Context Protocol)stdio 服务器。它只做三件事,
|
|
595
|
+
全部只读、全部本机、零模型调用:
|
|
596
|
+
|
|
597
|
+
| 工具 | 作用 |
|
|
598
|
+
|---|---|
|
|
599
|
+
| `engramory_search` | 同一个 BM25 内核(中文分词、177 条同义桥、别名、字段加权) |
|
|
600
|
+
| `engramory_read` | 按文件名读一篇笔记的原文(只认库里根目录的 `.md`,路径一律拒) |
|
|
601
|
+
| `engramory_status` | 库在哪、多少篇、索引几行几字节、指纹 —— 先确认读的是不是你以为的那个库 |
|
|
602
|
+
|
|
603
|
+
另外把每篇笔记也注册成 MCP 资源(`engramory://note/<文件名>`),客户端可以直接列出来读。
|
|
604
|
+
|
|
605
|
+
```jsonc
|
|
606
|
+
// Claude Code / Claude Desktop / Cursor 之类的 mcpServers 配置
|
|
607
|
+
{
|
|
608
|
+
"mcpServers": {
|
|
609
|
+
"engramory": {
|
|
610
|
+
"command": "node",
|
|
611
|
+
"args": ["/path/to/node_modules/dsh-engramory-recall/mcp.js", "--store", "~/memory/engramory"]
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
```bash
|
|
618
|
+
# 装了这个包就有了一个可直接调用的命令
|
|
619
|
+
engramory-recall-mcp --store ~/memory/engramory # 库路径也可以用环境变量 ENGRAMORY_STORE
|
|
620
|
+
npm run mcp:smoke # 自证:拉起来走一遍完整对话,打印收发记录
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
**为什么它是只读的**:写入闸门、归档、体检这些能力依赖宿主工具层的钩子(`tools/pre-execute`),
|
|
624
|
+
别的宿主没有这套接口;硬做一个「谁都能写」的版本,等于把 fail-closed 的闸门换成一句口头约定。
|
|
625
|
+
所以跨宿主给的是**看**的能力,治理留在家用版里。它也不 import 任何宿主包 ——
|
|
626
|
+
在别的宿主里最坏是「没被用上」,不会「装上就崩」。
|
|
627
|
+
|
|
583
628
|
## 测试与自检
|
|
584
629
|
|
|
585
630
|
```bash
|
|
586
|
-
npm test # 分词 / BM25 / 切桶 / 门控 / 闸门 / 索引闸门 / 技能注册 / 体检 / 审计 / 沉淀提醒 / 归档 / 打包 / 路由 / 公开基准 harness
|
|
631
|
+
npm test # 分词 / BM25 / 切桶 / 门控 / 闸门 / 索引闸门 / 技能注册 / 体检 / 审计 / 沉淀提醒 / 归档 / 打包 / 路由 / 公开基准 harness / MCP 服务器
|
|
587
632
|
npm run check # 接线自检:配置键认不认、库路径对不对、装的是哪一份、包清单漏没漏文件
|
|
588
633
|
npm run probe # 召回质量探针:3 组 query + 三道对照,跑在真实记忆库上
|
|
589
634
|
npm run probe -- --save # 把这次结果存成基线,之后每次跑自动比对
|
|
590
635
|
npm run bench:check # 合成基准 + 和 bench/expectations.json 对账(CI 跑的就是它)
|
|
636
|
+
npm run mcp:smoke # 拿脚本当 MCP 客户端,把服务器拉起来走一遍真实对话
|
|
591
637
|
node bench/longmemeval.mjs --data bench/.tmp/longmemeval_s_cleaned.json # 公开基准(数据要自己下,264MB)
|
|
592
638
|
```
|
|
593
639
|
|
package/mcp.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 零依赖的 MCP(Model Context Protocol)服务器,走 stdio。
|
|
4
|
+
*
|
|
5
|
+
* 为什么有这个东西:检索内核(`bm25.js` + `store.js`)本来就不依赖 DSH ——
|
|
6
|
+
* 记忆库就是一堆 markdown,索引就是词频。别的宿主(Claude Code、Codex、Cursor…)
|
|
7
|
+
* 只要支持 MCP,就应该能用同一份库。插件不该被绑死在一个宿主上。
|
|
8
|
+
*
|
|
9
|
+
* 四条自我约束(改掉任何一条就等于换了个东西):
|
|
10
|
+
* 1. 只用 `node:` 内置模块:零依赖、零子进程、零网络、零模型调用;
|
|
11
|
+
* 2. **只读**:没有任何写库的接口 —— 闸门、归档、体检那套留在 DSH 那一侧的家用版本里;
|
|
12
|
+
* 3. 不 import 任何宿主包(`@deepseek-ai/*`)。所以它在别的宿主里最坏是「没被装上」,
|
|
13
|
+
* 不会因为版本对不上而「装上就崩」;
|
|
14
|
+
* 4. **stdout 只允许出现协议帧**:日志一律走 stderr,否则客户端解析到一半就崩。
|
|
15
|
+
*
|
|
16
|
+
* 用法:`node mcp.js [--store <路径>]`,或者看 README 里各宿主的配置片段。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { basename, join, resolve, sep } from 'node:path';
|
|
22
|
+
import { createInterface } from 'node:readline';
|
|
23
|
+
import { pathToFileURL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
import { buildIndex, search } from './bm25.js';
|
|
26
|
+
import { countLines, DEFAULT_MAX_INDEX_BYTES, DEFAULT_MAX_INDEX_LINES, measureIndex } from './index-guard.js';
|
|
27
|
+
import { corpusFingerprint, readDocs } from './store.js';
|
|
28
|
+
|
|
29
|
+
export const SERVER_NAME = 'dsh-engramory-recall';
|
|
30
|
+
/** 声明支持的 MCP 协议版本。客户端报的版本在名单里就照它回,否则回我们最新的。 */
|
|
31
|
+
export const PROTOCOL_VERSION = '2025-06-18';
|
|
32
|
+
export const SUPPORTED_PROTOCOL_VERSIONS = new Set([PROTOCOL_VERSION, '2025-03-26', '2024-11-05']);
|
|
33
|
+
export const DEFAULT_STORE = '~/memory/engramory';
|
|
34
|
+
/** 单个工具结果的字符上限:MCP 客户端会把结果塞进上下文,给个天花板。 */
|
|
35
|
+
export const MAX_OUTPUT_CHARS = 40000;
|
|
36
|
+
export const MAX_LIMIT = 20;
|
|
37
|
+
export const DEFAULT_LIMIT = 5;
|
|
38
|
+
|
|
39
|
+
const pkg = (() => {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
42
|
+
} catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
})();
|
|
46
|
+
export const SERVER_VERSION = typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
47
|
+
|
|
48
|
+
/** 工具执行失败:message 会原样出现在 isError 的文本里,所以写人话。 */
|
|
49
|
+
export class McpError extends Error {}
|
|
50
|
+
|
|
51
|
+
export const TOOLS = [
|
|
52
|
+
{
|
|
53
|
+
name: 'engramory_search',
|
|
54
|
+
description:
|
|
55
|
+
'在本地 Engramory 记忆库里检索相关笔记(BM25 词面检索,含中文分词、同义桥与别名,本机执行、零模型调用)。返回文件名、分数与命中词。',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
additionalProperties: false,
|
|
59
|
+
properties: {
|
|
60
|
+
query: { type: 'string', description: '查询文本,中英文都行;口语说法会被同义表桥到技术词。' },
|
|
61
|
+
limit: {
|
|
62
|
+
type: 'integer',
|
|
63
|
+
minimum: 1,
|
|
64
|
+
maximum: MAX_LIMIT,
|
|
65
|
+
description: `返回几条,默认 ${DEFAULT_LIMIT}。`,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
required: ['query'],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'engramory_read',
|
|
73
|
+
description: '按文件名读出一篇笔记的完整 markdown 原文(只读库根目录下的 .md,不接受路径)。',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
additionalProperties: false,
|
|
77
|
+
properties: { file: { type: 'string', description: '库里的文件名,例如 reference-publish-plugin-to-npm.md' } },
|
|
78
|
+
required: ['file'],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'engramory_status',
|
|
83
|
+
description: '汇报记忆库的位置、笔记数、索引用量与上限,用来确认客户端读的是不是你以为的那个库。',
|
|
84
|
+
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
export const RESOURCE_TEMPLATE = {
|
|
89
|
+
uriTemplate: 'engramory://note/{file}',
|
|
90
|
+
name: '一篇笔记',
|
|
91
|
+
description: '把 {file} 换成 <store> 下的文件名。',
|
|
92
|
+
mimeType: 'text/markdown',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export function expandHome(value) {
|
|
96
|
+
const text = String(value ?? '').trim();
|
|
97
|
+
if (!text) return '';
|
|
98
|
+
if (text === '~') return homedir();
|
|
99
|
+
return text.startsWith('~/') ? join(homedir(), text.slice(2)) : text;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 库路径:命令行 > 环境变量 ENGRAMORY_STORE > 默认 `~/memory/engramory`。 */
|
|
103
|
+
export function resolveStorePath(raw) {
|
|
104
|
+
const expanded = expandHome(raw);
|
|
105
|
+
return expanded ? resolve(expanded) : resolve(expandHome(DEFAULT_STORE));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createSession(options = {}) {
|
|
109
|
+
const raw = options.store ?? process.env.ENGRAMORY_STORE ?? DEFAULT_STORE;
|
|
110
|
+
return { store: resolveStorePath(raw), storeRaw: String(raw ?? '').trim() || DEFAULT_STORE, index: null, fingerprint: '' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 索引按语料指纹缓存:库被改过就重建,没改过就复用(readDocs 自己也有 mtime 缓存)。 */
|
|
114
|
+
function indexFor(session) {
|
|
115
|
+
const docs = readDocs(session.store);
|
|
116
|
+
const fingerprint = corpusFingerprint(docs);
|
|
117
|
+
if (!session.index || session.fingerprint !== fingerprint) {
|
|
118
|
+
session.index = buildIndex(docs);
|
|
119
|
+
session.fingerprint = fingerprint;
|
|
120
|
+
}
|
|
121
|
+
return session.index;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 库里唯一的笔记(长笔记在索引里会切成多桶,这里按 id 折回一篇)。 */
|
|
125
|
+
function notesOf(session) {
|
|
126
|
+
const notes = new Map();
|
|
127
|
+
for (const doc of readDocs(session.store)) {
|
|
128
|
+
const existing = notes.get(doc.id);
|
|
129
|
+
if (!existing) notes.set(doc.id, { file: doc.id, title: doc.title || doc.id, description: doc.description || '' });
|
|
130
|
+
else if (!existing.description && doc.description) existing.description = doc.description;
|
|
131
|
+
}
|
|
132
|
+
return [...notes.values()];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function safeNotePath(session, file) {
|
|
136
|
+
const name = String(file ?? '').trim();
|
|
137
|
+
if (!name) throw new McpError('需要 file 参数:库里的 markdown 文件名。');
|
|
138
|
+
if (name !== basename(name) || name.startsWith('.') || !name.endsWith('.md')) {
|
|
139
|
+
throw new McpError(`不是库里的笔记文件名:${name}。只接受 <store> 下直接的 .md 文件名,不接受路径。`);
|
|
140
|
+
}
|
|
141
|
+
const full = join(session.store, name);
|
|
142
|
+
if (!resolve(full).startsWith(session.store + sep)) throw new McpError(`拒绝越界路径:${name}`);
|
|
143
|
+
return full;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readNote(session, file) {
|
|
147
|
+
const name = String(file ?? '').trim() || basename(safeNotePath(session, file));
|
|
148
|
+
const full = safeNotePath(session, name);
|
|
149
|
+
let text;
|
|
150
|
+
try {
|
|
151
|
+
text = readFileSync(full, 'utf8');
|
|
152
|
+
} catch {
|
|
153
|
+
throw new McpError(`读不到这篇笔记:${name}。用 engramory_status 看库的位置,或 engramory_search 找回文件名。`);
|
|
154
|
+
}
|
|
155
|
+
if (text.length > MAX_OUTPUT_CHARS) {
|
|
156
|
+
return `${text.slice(0, MAX_OUTPUT_CHARS)}\n\n…(笔记超过 ${MAX_OUTPUT_CHARS} 字符,这里截断了;完整原文在 ${name})`;
|
|
157
|
+
}
|
|
158
|
+
return text;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function toolSearch(session, args) {
|
|
162
|
+
const query = String(args?.query ?? '').trim();
|
|
163
|
+
if (!query) throw new McpError('需要非空的 query。');
|
|
164
|
+
const raw = Number(args?.limit);
|
|
165
|
+
const limit = Number.isFinite(raw) ? Math.min(MAX_LIMIT, Math.max(1, Math.trunc(raw))) : DEFAULT_LIMIT;
|
|
166
|
+
const docs = readDocs(session.store);
|
|
167
|
+
if (!docs.length) {
|
|
168
|
+
throw new McpError(`库是空的或不存在:${session.store}。用 --store <路径> 或环境变量 ENGRAMORY_STORE 指定。`);
|
|
169
|
+
}
|
|
170
|
+
const hits = search(indexFor(session), query, limit);
|
|
171
|
+
const header = `库:${session.store}(${notesOf(session).length} 篇)· 查询:${query}`;
|
|
172
|
+
if (!hits.length) return `${header}\n没有命中。换个词,或先用 engramory_status 确认库对不对。`;
|
|
173
|
+
const lines = hits.map((hit, i) => {
|
|
174
|
+
const snippet = String(hit.snippet || '').replace(/\s+/g, ' ').trim();
|
|
175
|
+
const matched = Array.isArray(hit.matched) && hit.matched.length ? hit.matched.join('、') : '—';
|
|
176
|
+
return [`${i + 1}. ${hit.id} · ${Number(hit.score).toFixed(2)}`, ` ${snippet}`, ` 命中:${matched}`].join('\n');
|
|
177
|
+
});
|
|
178
|
+
return [header, ...lines].join('\n');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function toolStatus(session) {
|
|
182
|
+
const notes = notesOf(session);
|
|
183
|
+
let indexText = '';
|
|
184
|
+
try {
|
|
185
|
+
indexText = readFileSync(join(session.store, 'MEMORY.md'), 'utf8');
|
|
186
|
+
} catch {
|
|
187
|
+
/* 索引缺失不是错误:没有索引也能按正文召回,只是宿主不会自动打开它。 */
|
|
188
|
+
}
|
|
189
|
+
// measureIndex 返回的是「超限时的人话描述 / 没超时 null」,行数与字节数得自己数。
|
|
190
|
+
const over = measureIndex(indexText, DEFAULT_MAX_INDEX_LINES, DEFAULT_MAX_INDEX_BYTES);
|
|
191
|
+
const indexLine = indexText
|
|
192
|
+
? `索引:${countLines(indexText)} 行 / ${Buffer.byteLength(indexText)} 字节(上限 ${DEFAULT_MAX_INDEX_LINES} 行 / ${DEFAULT_MAX_INDEX_BYTES} 字节${over ? `,已超限:${over}` : ''})`
|
|
193
|
+
: '索引:没有 MEMORY.md(召回仍可用,但宿主没有索引可读)';
|
|
194
|
+
return [
|
|
195
|
+
`库:${session.store}`,
|
|
196
|
+
`笔记:${notes.length} 篇`,
|
|
197
|
+
indexLine,
|
|
198
|
+
`指纹:${corpusFingerprint(readDocs(session.store)).slice(0, 12) || '(空库)'}`,
|
|
199
|
+
`服务器:${SERVER_NAME} ${SERVER_VERSION} · 只读 · 零模型调用`,
|
|
200
|
+
].join('\n');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const HANDLERS = {
|
|
204
|
+
engramory_search: toolSearch,
|
|
205
|
+
engramory_read: (session, args) => readNote(session, args?.file),
|
|
206
|
+
engramory_status: toolStatus,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export async function callTool(session, params) {
|
|
210
|
+
const name = String(params?.name ?? '');
|
|
211
|
+
const handler = HANDLERS[name];
|
|
212
|
+
if (!handler) {
|
|
213
|
+
return { content: [{ type: 'text', text: `没有这个工具:${name}。可用:${Object.keys(HANDLERS).join(' / ')}` }], isError: true };
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
return { content: [{ type: 'text', text: String(handler(session, params?.arguments ?? {})) }], isError: false };
|
|
217
|
+
} catch (error) {
|
|
218
|
+
const text = error instanceof McpError ? error.message : `工具 ${name} 出错:${error?.message ?? error}`;
|
|
219
|
+
return { content: [{ type: 'text', text }], isError: true };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function listResources(session) {
|
|
224
|
+
return {
|
|
225
|
+
resources: notesOf(session).map((note) => ({
|
|
226
|
+
uri: `engramory://note/${note.file}`,
|
|
227
|
+
name: note.file,
|
|
228
|
+
title: note.title,
|
|
229
|
+
description: note.description || undefined,
|
|
230
|
+
mimeType: 'text/markdown',
|
|
231
|
+
})),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function readResource(session, params) {
|
|
236
|
+
const uri = String(params?.uri ?? '');
|
|
237
|
+
const prefix = 'engramory://note/';
|
|
238
|
+
if (!uri.startsWith(prefix)) throw new McpError(`只认得 ${prefix}<文件名> 这种 URI,收到的是:${uri}`);
|
|
239
|
+
const file = decodeURIComponent(uri.slice(prefix.length));
|
|
240
|
+
return { contents: [{ uri, mimeType: 'text/markdown', text: readNote(session, file) }] };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function initializeResult(params) {
|
|
244
|
+
const asked = String(params?.protocolVersion ?? '');
|
|
245
|
+
return {
|
|
246
|
+
protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.has(asked) ? asked : PROTOCOL_VERSION,
|
|
247
|
+
capabilities: { tools: { listChanged: false }, resources: { listChanged: false, subscribe: false } },
|
|
248
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION, title: 'Engramory 记忆召回(本地 markdown,只读)' },
|
|
249
|
+
instructions:
|
|
250
|
+
'这是本机 markdown 记忆库的只读检索。先用 engramory_search 找相关笔记,再用 engramory_read 打开需要的那些;' +
|
|
251
|
+
'不要假设库里有什么,也不要通过它写任何东西。',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
|
|
256
|
+
const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
257
|
+
|
|
258
|
+
/** 处理一条 JSON-RPC 消息;通知返回 null(协议规定通知不回包)。 */
|
|
259
|
+
export async function handleMessage(session, message) {
|
|
260
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
261
|
+
return fail(null, -32600, 'Invalid Request:只接受单条 JSON-RPC 2.0 对象(不支持批量)。');
|
|
262
|
+
}
|
|
263
|
+
const { id, method } = message;
|
|
264
|
+
const notification = id === undefined || id === null;
|
|
265
|
+
if (typeof method !== 'string') {
|
|
266
|
+
return notification ? null : fail(id, -32600, 'Invalid Request:method 必须是字符串。');
|
|
267
|
+
}
|
|
268
|
+
if (method.startsWith('notifications/')) return null;
|
|
269
|
+
try {
|
|
270
|
+
switch (method) {
|
|
271
|
+
case 'initialize':
|
|
272
|
+
return ok(id, initializeResult(message.params));
|
|
273
|
+
case 'ping':
|
|
274
|
+
return ok(id, {});
|
|
275
|
+
case 'tools/list':
|
|
276
|
+
return ok(id, { tools: TOOLS });
|
|
277
|
+
case 'tools/call':
|
|
278
|
+
return ok(id, await callTool(session, message.params));
|
|
279
|
+
case 'resources/list':
|
|
280
|
+
return ok(id, listResources(session));
|
|
281
|
+
case 'resources/templates/list':
|
|
282
|
+
return ok(id, { resourceTemplates: [RESOURCE_TEMPLATE] });
|
|
283
|
+
case 'resources/read':
|
|
284
|
+
return ok(id, readResource(session, message.params));
|
|
285
|
+
case 'prompts/list':
|
|
286
|
+
return ok(id, { prompts: [] });
|
|
287
|
+
default:
|
|
288
|
+
if (notification) return null;
|
|
289
|
+
return fail(id, -32601, `Method not found: ${method}`);
|
|
290
|
+
}
|
|
291
|
+
} catch (error) {
|
|
292
|
+
const text = error instanceof McpError ? error.message : `内部错误:${error?.message ?? error}`;
|
|
293
|
+
return notification ? null : fail(id, -32603, text);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** stdio 主循环:一行一条消息,stdout 只写协议帧。 */
|
|
298
|
+
export async function serveStdio(session, options = {}) {
|
|
299
|
+
const input = options.input ?? process.stdin;
|
|
300
|
+
const output = options.output ?? process.stdout;
|
|
301
|
+
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
302
|
+
const pending = [];
|
|
303
|
+
rl.on('line', (line) => {
|
|
304
|
+
const text = line.trim();
|
|
305
|
+
if (!text) return;
|
|
306
|
+
let message;
|
|
307
|
+
try {
|
|
308
|
+
message = JSON.parse(text);
|
|
309
|
+
} catch {
|
|
310
|
+
output.write(`${JSON.stringify(fail(null, -32700, 'Parse error:这一行不是合法 JSON。'))}\n`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
// 逐条串行处理:保证响应顺序与请求顺序一致(工具调用本身很快,不值得为并发引入状态问题)。
|
|
314
|
+
const task = (pending.at(-1) ?? Promise.resolve())
|
|
315
|
+
.then(() => handleMessage(session, message))
|
|
316
|
+
.then((response) => {
|
|
317
|
+
if (response) output.write(`${JSON.stringify(response)}\n`);
|
|
318
|
+
})
|
|
319
|
+
.catch((error) => {
|
|
320
|
+
output.write(`${JSON.stringify(fail(message?.id ?? null, -32603, String(error?.message ?? error)))}\n`);
|
|
321
|
+
});
|
|
322
|
+
pending.push(task);
|
|
323
|
+
if (pending.length > 64) pending.shift();
|
|
324
|
+
});
|
|
325
|
+
await new Promise((done) => rl.on('close', done));
|
|
326
|
+
await Promise.all(pending);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export const USAGE = `用法:node mcp.js [--store <路径>] [--print-config] [--version] [--help]
|
|
330
|
+
|
|
331
|
+
零依赖、只读的 MCP stdio 服务器:把 Engramory markdown 记忆库暴露成
|
|
332
|
+
engramory_search / engramory_read / engramory_status 三个工具。
|
|
333
|
+
库路径优先级:--store > 环境变量 ENGRAMORY_STORE > ${DEFAULT_STORE}
|
|
334
|
+
日志一律写 stderr;stdout 只有 JSON-RPC 帧。`;
|
|
335
|
+
|
|
336
|
+
export function parseArgs(argv) {
|
|
337
|
+
const args = { store: '', help: false, version: false, printConfig: false };
|
|
338
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
339
|
+
const arg = argv[i];
|
|
340
|
+
if (arg === '--store') args.store = argv[++i] ?? '';
|
|
341
|
+
else if (arg.startsWith('--store=')) args.store = arg.slice('--store='.length);
|
|
342
|
+
else if (arg === '--help' || arg === '-h') args.help = true;
|
|
343
|
+
else if (arg === '--version' || arg === '-v') args.version = true;
|
|
344
|
+
else if (arg === '--print-config') args.printConfig = true;
|
|
345
|
+
else if (arg.trim()) args.error = `听不懂的参数:${arg}`;
|
|
346
|
+
}
|
|
347
|
+
return args;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
351
|
+
const args = parseArgs(argv);
|
|
352
|
+
if (args.error) {
|
|
353
|
+
process.stderr.write(`${args.error}\n\n${USAGE}\n`);
|
|
354
|
+
return 2;
|
|
355
|
+
}
|
|
356
|
+
if (args.help) {
|
|
357
|
+
process.stdout.write(`${USAGE}\n`);
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
if (args.version) {
|
|
361
|
+
process.stdout.write(`${SERVER_VERSION}\n`);
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
const session = createSession({ store: args.store || undefined });
|
|
365
|
+
if (args.printConfig) {
|
|
366
|
+
process.stdout.write(`${JSON.stringify({ name: SERVER_NAME, version: SERVER_VERSION, store: session.store })}\n`);
|
|
367
|
+
return 0;
|
|
368
|
+
}
|
|
369
|
+
process.stderr.write(`[${SERVER_NAME}] 库:${session.store} · 只读 · 日志只走 stderr\n`);
|
|
370
|
+
await serveStdio(session);
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
375
|
+
main().then(
|
|
376
|
+
(code) => {
|
|
377
|
+
process.exitCode = code;
|
|
378
|
+
},
|
|
379
|
+
(error) => {
|
|
380
|
+
process.stderr.write(`[${SERVER_NAME}] 退出:${error?.stack ?? error}\n`);
|
|
381
|
+
process.exitCode = 1;
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-engramory-recall",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Local BM25 auto-recall, fail-closed write guard, static doctor and a memory panel for the Engramory markdown store — no network, no model, no daemon.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"./index-guard": "./index-guard.js",
|
|
38
38
|
"./skill": "./skill.js",
|
|
39
39
|
"./llm": "./llm.js",
|
|
40
|
-
"./index": "./index.js"
|
|
40
|
+
"./index": "./index.js",
|
|
41
|
+
"./mcp": "./mcp.js"
|
|
41
42
|
},
|
|
42
43
|
"files": [
|
|
43
44
|
"index.js",
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
"index-guard.js",
|
|
52
53
|
"skill.js",
|
|
53
54
|
"llm.js",
|
|
55
|
+
"mcp.js",
|
|
54
56
|
"CHANGELOG.md",
|
|
55
57
|
"cordis.patch.yml",
|
|
56
58
|
"README.md"
|
|
@@ -73,6 +75,10 @@
|
|
|
73
75
|
"check": "node scripts/check-recall-wiring.mjs",
|
|
74
76
|
"probe": "node scripts/recall-probe.mjs",
|
|
75
77
|
"bench": "node bench/run.mjs",
|
|
76
|
-
"bench:check": "node bench/run.mjs --check"
|
|
78
|
+
"bench:check": "node bench/run.mjs --check",
|
|
79
|
+
"mcp:smoke": "node scripts/mcp-smoke.mjs"
|
|
80
|
+
},
|
|
81
|
+
"bin": {
|
|
82
|
+
"engramory-recall-mcp": "./mcp.js"
|
|
77
83
|
}
|
|
78
84
|
}
|