qmd-autosearch 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/index.js +285 -0
  4. package/package.json +25 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 qmd-autosearch authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # qmd-autosearch
2
+
3
+ 自动补搜插件:当模型在知识库目录执行 `grep` / `glob` 搜索时,自动补充一次 QMD 语义检索,并把结果注入模型上下文。
4
+
5
+ DeepSeek Harness 插件(`dsh-plugin`)。零外部依赖。
6
+
7
+ ## 功能
8
+
9
+ - **自动触发**:模型在 QMD 集合对应的目录内执行 `grep` / `glob`(顶层调用)即触发,无需显式调用
10
+ - **语义补搜**:QMD 单次查询(lex + vec,服务端 LLM 重排),检索范围限定在配置的 collections
11
+ - **智能查询词**:以最近的用户消息(任务要求)为语义主查询;消息过短或与搜索主题无关时,回退使用 grep pattern
12
+ - **异步注入**:结果排队到 `agent.inbox.nextStep`,下一个 pre-step 注入模型上下文,不阻塞当前工具调用
13
+ - **防重复**:同一 agent 内相同(工具、路径、pattern)签名只补搜一次
14
+ - **容错**:QMD 服务不可用或查询无结果时静默跳过,不影响原流程
15
+
16
+ ## 工作原理
17
+
18
+ ```
19
+ grep/glob 执行完成 → tools/result 钩子
20
+ → 路径在触发范围内(由 QMD status 解析的集合路径)?
21
+ → QMD query(collections 限定 + LLM 重排)
22
+ → 结果排队 agent.inbox.nextStep
23
+ → 下一个 pre-step 注入模型上下文(user 角色 system-reminder 风格)
24
+ ```
25
+
26
+ ## 安装
27
+
28
+ ### 方式一:npm 安装(发布后可用)
29
+
30
+ ```bash
31
+ dsh plugin --profile web add qmd-autosearch
32
+ ```
33
+
34
+ ### 方式二:本地路径引用(开发调试)
35
+
36
+ 在 `~/.dsh/profiles/web/cordis.patch.yml` 的 insert 列表追加:
37
+
38
+ ```yaml
39
+ - insert:
40
+ - id: qmd-autosearch
41
+ name: 'file:///path/to/qmd-autosearch/index.js'
42
+ config:
43
+ qmdUrl: http://localhost:6179/mcp
44
+ collections:
45
+ - knowledge-concepts-keynes
46
+ - knowledge-principles-keynes
47
+ limit: 5
48
+ minScore: 25
49
+ ```
50
+
51
+ ## 配置
52
+
53
+ | 配置项 | 必填 | 默认值 | 说明 |
54
+ |---|---|---|---|
55
+ | `qmdUrl` | ✅ | 无 | QMD MCP 服务地址(如 `http://localhost:6179/mcp`) |
56
+ | `collections` | ✅ | 无 | 搜索范围:QMD 集合名列表(`qmd collection list` 可查) |
57
+ | `limit` | 否 | `5` | 注入结果的条数上限 |
58
+ | `minScore` | 否 | `25` | 最低匹配度过滤(百分数,低于此值的命中丢弃) |
59
+ | `triggerRoots` | 否 | 自动 | 触发判断的文件系统根路径列表;缺省时从 QMD `status` 动态解析集合路径 |
60
+
61
+ > 未配置 `qmdUrl` / `collections` 时插件禁用并输出 warn 日志。
62
+
63
+ ## 使用
64
+
65
+ 插件**全自动运行,无需手动调用**。配置完成后,正常使用 DSH 即可:
66
+
67
+ 1. 让模型在知识库目录内搜索(如 `grep 某个概念`)
68
+ 2. 插件检测到搜索发生在 QMD 集合目录内,自动补充一次语义检索
69
+ 3. 结果作为补充提示注入模型的下一步上下文,模型可据此用 `read` 读取命中文档正文
70
+
71
+ ### 效果示例
72
+
73
+ 模型执行 `grep 内需` 后,下一步上下文中会出现:
74
+
75
+ ```text
76
+ <system-reminder>
77
+ QMD 语义检索补充:你在知识库目录执行了「grep 内需」,系统已自动用语义检索(范围限定 QMD 集合,LLM 重排)补搜,查询词「内需」。结果如下(docid 映射为绝对路径后用 read 读取正文):
78
+
79
+ Found 3 results for "内需":
80
+ #abc123 85% <collection>/concepts/<file>.md - <标题>
81
+ #def456 42% <collection>/principles/<file>.md - <标题>
82
+ ...
83
+ </system-reminder>
84
+ ```
85
+
86
+ ### 验证插件已生效
87
+
88
+ - 让模型在集合目录内 grep 一个词,观察下一步是否出现上述补充消息
89
+ - 或查看宿主日志中的 `qmd-autosearch: 触发路径池(来自 QMD 集合)N 个`(插件启动后首次触发时输出)
90
+
91
+ ## 前置条件
92
+
93
+ - 运行中的 QMD MCP 服务(`qmd mcp --http --port 6179`)
94
+ - 已建立至少一个 collection(`qmd collection add <name> <path>`)
95
+
96
+ ## License
97
+
98
+ MIT
package/index.js ADDED
@@ -0,0 +1,285 @@
1
+ // qmd-autosearch — 自动补搜插件
2
+ // 触发:模型在 QMD 集合对应目录内执行 grep/glob(顶层调用)
3
+ // 搜索:QMD 单次调用(服务端 LLM 重排),范围限定在配置的 collections
4
+ // 查询词:以最近 user 消息(任务要求)为主,grep pattern 仅作词法辅助
5
+ // 注入:结果排队到 agent.inbox.nextStep,下一个 pre-step 注入模型上下文
6
+ // 配置(必填):qmdUrl = QMD MCP 服务地址;collections = 搜索范围集合列表
7
+ import { randomUUID } from "node:crypto";
8
+ import { resolve } from "node:path";
9
+ import { realpathSync } from "node:fs";
10
+
11
+ export const name = "qmd-autosearch";
12
+
13
+ const SEARCH_TOOLS = new Set(["grep", "glob"]);
14
+ const INTENT_MAX_CHARS = 300;
15
+
16
+ function freezeMessage(input) {
17
+ return Object.freeze({
18
+ ...input,
19
+ id: input.id ?? randomUUID()
20
+ });
21
+ }
22
+
23
+ function createUserMessage(input) {
24
+ return freezeMessage({
25
+ ...input,
26
+ role: "user"
27
+ });
28
+ }
29
+
30
+ // 把 grep 正则 pattern 清洗成 QMD 词法查询(正则元字符 → 空格)
31
+ function cleanPattern(pattern) {
32
+ return String(pattern ?? "")
33
+ .replace(/[|()[\]{}*+?^$\\]/g, " ")
34
+ .replace(/\s+/g, " ")
35
+ .trim();
36
+ }
37
+
38
+ function inRoot(root, path) {
39
+ return path === root || path.startsWith(root + "/") || path.startsWith(root + "\\");
40
+ }
41
+
42
+ // ── QMD MCP 直连(streamable-http)────────────────────────────────────
43
+ async function mcpRequest(url, sessionId, method, params) {
44
+ const headers = {
45
+ "Content-Type": "application/json",
46
+ "Accept": "application/json, text/event-stream"
47
+ };
48
+ if (sessionId !== null) headers["mcp-session-id"] = sessionId;
49
+ const res = await fetch(url, {
50
+ method: "POST",
51
+ headers,
52
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params })
53
+ });
54
+ const nextSessionId = res.headers.get("mcp-session-id") ?? sessionId;
55
+ const text = await res.text();
56
+ let payload;
57
+ try {
58
+ payload = JSON.parse(text);
59
+ } catch {
60
+ const messages = text
61
+ .split("\n")
62
+ .filter((line) => line.startsWith("data:"))
63
+ .map((line) => JSON.parse(line.slice(5)));
64
+ const error = messages.find((m) => m.error)?.error;
65
+ if (error) throw new Error(error.message);
66
+ payload = messages.find((m) => m.result) ?? { result: void 0 };
67
+ }
68
+ if (payload.error) throw new Error(payload.error.message);
69
+ return { result: payload.result, sessionId: nextSessionId };
70
+ }
71
+
72
+ function extractText(callResult) {
73
+ return (callResult?.content ?? [])
74
+ .filter((block) => block.type === "text")
75
+ .map((block) => block.text)
76
+ .join("\n")
77
+ .trim();
78
+ }
79
+
80
+ // 解析 QMD 结果行:"#3db89d 97% <collection>/<path>.md - <title>"
81
+ function parseHits(text) {
82
+ const hits = [];
83
+ for (const line of text.split("\n")) {
84
+ const m = line.match(/^#([0-9a-f]+)\s+(\d+)%\s+(\S+)\s+-\s+(.+)$/);
85
+ if (m) hits.push({ id: m[1], score: Number(m[2]), path: m[3], title: m[4] });
86
+ }
87
+ return hits;
88
+ }
89
+
90
+ // 解析 status 文本:" - knowledge-concepts-keynes: /abs/path (1016 docs)"(行首允许缩进)
91
+ // 返回所有非 null 集合路径(用作触发判断的文件系统范围)
92
+ function parseCollectionRoots(statusText) {
93
+ const roots = [];
94
+ for (const line of statusText.split("\n")) {
95
+ const m = line.match(/^\s*-\s+([\w-]+):\s+(null|\/\S+)\s+\(\d+\s+docs?\)/);
96
+ if (m && m[2] !== "null") {
97
+ try {
98
+ roots.push(realpathSync(m[2]));
99
+ } catch {
100
+ roots.push(resolve(m[2]));
101
+ }
102
+ }
103
+ }
104
+ return roots;
105
+ }
106
+
107
+ async function qmdSearch(qmdUrl, collections, intentQuery, pattern, toolName, { limit, minScore }) {
108
+ const lexQuery = cleanPattern(pattern);
109
+ const query = (intentQuery || lexQuery).slice(0, INTENT_MAX_CHARS);
110
+ if (!query) return { query, text: "" };
111
+ const { sessionId } = await mcpRequest(qmdUrl, null, "initialize", {
112
+ protocolVersion: "2025-03-26",
113
+ capabilities: {},
114
+ clientInfo: { name: "qmd-autosearch", version: "0.1.0" }
115
+ });
116
+ const searches = [
117
+ { type: "vec", query } // 语义主查询:任务/user 要求(权重 2x)
118
+ ];
119
+ if (lexQuery && lexQuery !== query) searches.push({ type: "lex", query: lexQuery }); // 词法辅助
120
+ const { result: callResult } = await mcpRequest(qmdUrl, sessionId, "tools/call", {
121
+ name: "query",
122
+ arguments: {
123
+ searches,
124
+ collections,
125
+ limit,
126
+ rerank: true,
127
+ intent: `模型在知识库目录用 ${toolName} 搜索了 "${pattern}",自动补充语义检索结果`
128
+ }
129
+ });
130
+ const hits = parseHits(extractText(callResult)).filter((hit) => hit.score >= minScore);
131
+ if (hits.length === 0) return { query, text: "" };
132
+ const text = `Found ${hits.length} results for "${query}":\n\n` +
133
+ hits.map((hit) => `#${hit.id} ${hit.score}% ${hit.path} - ${hit.title}`).join("\n");
134
+ return { query, text };
135
+ }
136
+
137
+ function renderSupplement(toolName, pattern, payload) {
138
+ return `<system-reminder>
139
+ QMD 语义检索补充:你在知识库目录执行了「${toolName} ${pattern}」,系统已按当前任务要求自动用语义检索(范围限定 QMD 集合,LLM 重排)补搜,查询词「${payload.query}」。结果如下(docid 映射为绝对路径后用 read 读取正文,规则见全局指引):
140
+
141
+ ${payload.text}
142
+ </system-reminder>`;
143
+ }
144
+
145
+ // 从 agent 会话事件中取最近的用户消息文本(任务要求;排除插件/指令注入)
146
+ function recentUserText(agent) {
147
+ const nodes = agent.session?.surface?.nodes;
148
+ if (!nodes) return "";
149
+ for (let i = nodes.length - 1; i >= 0; i--) {
150
+ const event = agent.session.events?.[nodes[i]];
151
+ if (event?.type !== "user/message") continue;
152
+ const source = event.data?.source;
153
+ if (source?.kind === "plugin" || source?.kind === "agent-instructions") continue;
154
+ const text = (event.data?.content ?? [])
155
+ .filter((block) => block.type === "text")
156
+ .map((block) => block.text)
157
+ .join(" ")
158
+ .trim();
159
+ if (text) return text;
160
+ }
161
+ return "";
162
+ }
163
+
164
+ // 选择语义主查询词:user 消息足够表达意图时用任务要求,否则回退 grep pattern
165
+ // 规则:≥20 字直接采用;10-19 字且包含 pattern 关键词才采用;其余(过短/无关运维消息)回退 pattern
166
+ function chooseIntentQuery(userText, pattern) {
167
+ const text = String(userText ?? "").trim();
168
+ const cleaned = cleanPattern(pattern);
169
+ if (!text) return cleaned;
170
+ if (text.length >= 20) return text;
171
+ const tokens = cleaned.split(" ").filter((token) => token.length >= 2);
172
+ if (text.length >= 10 && tokens.some((token) => text.includes(token))) return text;
173
+ return cleaned;
174
+ }
175
+
176
+ // ── 插件主体 ────────────────────────────────────────────────────────────
177
+ export function apply(ctx, config) {
178
+ // 必填配置:qmdUrl(QMD MCP 服务地址)、collections(搜索范围集合列表)
179
+ // 缺失时插件禁用并告警——不提供环境相关的默认值(适用于公开发布)
180
+ const qmdUrl = config.qmdUrl ?? "";
181
+ const collections = config.collections ?? [];
182
+ if (!qmdUrl || collections.length === 0) {
183
+ ctx.logger.warn(
184
+ "qmd-autosearch: 未配置 qmdUrl 或 collections,插件已禁用。" +
185
+ "请在 cordis.patch.yml 的 config 中显式指定,例如:\n" +
186
+ " config:\n" +
187
+ " qmdUrl: http://localhost:6179/mcp\n" +
188
+ " collections:\n" +
189
+ " - <collection-name>"
190
+ );
191
+ return;
192
+ }
193
+ const limit = config.limit ?? 5;
194
+ const minScore = config.minScore ?? 25;
195
+ const seen = new WeakMap();
196
+ // 触发路径池:配置的 triggerRoots 优先;否则从 QMD status 解析集合路径(懒加载)
197
+ let triggerRoots = (config.triggerRoots ?? []).map((p) => {
198
+ try {
199
+ return realpathSync(p);
200
+ } catch {
201
+ return resolve(p);
202
+ }
203
+ });
204
+ let rootsLoaded = false;
205
+ const loadTriggerRoots = async () => {
206
+ if (rootsLoaded || triggerRoots.length > 0) return;
207
+ rootsLoaded = true;
208
+ try {
209
+ const { sessionId } = await mcpRequest(qmdUrl, null, "initialize", {
210
+ protocolVersion: "2025-03-26",
211
+ capabilities: {},
212
+ clientInfo: { name: "qmd-autosearch", version: "0.1.0" }
213
+ });
214
+ const { result } = await mcpRequest(qmdUrl, sessionId, "tools/call", {
215
+ name: "status",
216
+ arguments: {}
217
+ });
218
+ triggerRoots = parseCollectionRoots(extractText(result));
219
+ ctx.logger.info("qmd-autosearch: 触发路径池(来自 QMD 集合)%d 个", triggerRoots.length);
220
+ } catch (error) {
221
+ ctx.logger.warn("qmd-autosearch: 解析 QMD 集合路径失败: %o", error);
222
+ }
223
+ };
224
+
225
+ const isOurs = (message) => message.source?.kind === "plugin" && message.source?.plugin === name;
226
+
227
+ ctx.on("tools/result", (exec) => {
228
+ if (exec.parent !== void 0) return; // 只处理顶层调用
229
+ if (exec.agent === void 0 || exec.signal?.aborted) return;
230
+ if (!SEARCH_TOOLS.has(exec.name)) return;
231
+ const args = exec.arguments;
232
+ if (typeof args !== "object" || args === null) return;
233
+ const path = typeof args.path === "string" ? args.path : typeof args.cwd === "string" ? args.cwd : "";
234
+ const pattern = typeof args.pattern === "string" ? args.pattern : "";
235
+ if (!path || !pattern) return;
236
+ const resolvedPath = resolve(path);
237
+
238
+ loadTriggerRoots().then(() => {
239
+ if (triggerRoots.length === 0) return;
240
+ if (!triggerRoots.some((root) => inRoot(root, resolvedPath) || inRoot(root, path))) return;
241
+
242
+ // 防重:同 agent 内同签名只补搜一次
243
+ const sig = `${exec.name}:${resolvedPath}:${pattern}`;
244
+ let cache = seen.get(exec.agent);
245
+ if (cache?.has(sig)) return;
246
+ if (cache === void 0) {
247
+ cache = new Set();
248
+ seen.set(exec.agent, cache);
249
+ }
250
+ cache.add(sig);
251
+ if (cache.size > 32) cache.clear();
252
+
253
+ const agent = exec.agent;
254
+ const intentQuery = chooseIntentQuery(recentUserText(agent), pattern);
255
+ qmdSearch(qmdUrl, collections, intentQuery, pattern, exec.name, { limit, minScore })
256
+ .then((payload) => {
257
+ if (!payload.text) return;
258
+ const message = createUserMessage({
259
+ content: [{ type: "text", text: renderSupplement(exec.name, pattern, payload) }],
260
+ source: { kind: "plugin", plugin: name }
261
+ });
262
+ agent.inbox.prepend("next-step", message);
263
+ })
264
+ .catch((error) => {
265
+ ctx.logger.warn("qmd-autosearch: %o", error);
266
+ });
267
+ });
268
+ });
269
+
270
+ ctx.on("agent/pre-step", async ({ agent, messages, step }, next) => {
271
+ const decision = await next();
272
+ const pending = agent.inbox.nextStep.filter(isOurs);
273
+ if (pending.length === 0) return decision;
274
+ if (decision.kind === "reject" || (step === 1 && decision.messages.length === 0)) {
275
+ for (const message of pending) agent.inbox.remove(message.id);
276
+ return decision;
277
+ }
278
+ const lastClaimedIndex = decision.messages.findLastIndex((message) => messages.includes(message));
279
+ for (const message of pending) agent.inbox.remove(message.id);
280
+ return {
281
+ kind: "enter",
282
+ messages: decision.messages.toSpliced(lastClaimedIndex + 1, 0, ...pending)
283
+ };
284
+ });
285
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "qmd-autosearch",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "index.js",
6
+ "description": "DSH plugin: auto-supplement QMD semantic search when the model greps/globs a knowledge-base directory",
7
+ "keywords": [
8
+ "dsh-plugin",
9
+ "deepseek-harness",
10
+ "dsh",
11
+ "qmd",
12
+ "semantic-search",
13
+ "mcp",
14
+ "knowledge-base"
15
+ ],
16
+ "files": [
17
+ "index.js",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "license": "MIT"
25
+ }