Skip to content

Commit 6fa3cd3

Browse files
committed
feat: add qmd
1 parent 2ebdd78 commit 6fa3cd3

21 files changed

Lines changed: 2607 additions & 4 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ coverage
77
repomix-output.xml
88
.worktrees
99
.claude/worktrees
10+
.search/

apps/cli/src/commands/search.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { Command } from 'commander'
2+
import * as p from '@clack/prompts'
3+
import { SearchManager, Workspace } from '@marchen-spec/core'
4+
import pc from 'picocolors'
5+
import { handleError } from '../utils/error.js'
6+
7+
/**
8+
* 注册 search 命令
9+
*
10+
* 语义搜索归档变更历史
11+
*
12+
* @param program - Commander 程序实例
13+
*/
14+
export function registerSearchCommand(program: Command): void {
15+
program
16+
.command('search <query>')
17+
.description('语义搜索归档变更历史')
18+
.option('-n, --limit <number>', '结果数量', '5')
19+
.option('--min-score <number>', '最低分数阈值', '0.3')
20+
.option('--json', '输出 JSON 格式')
21+
.option('--rebuild', '重建索引后搜索')
22+
.action(
23+
async (
24+
query: string,
25+
options: {
26+
limit?: string
27+
minScore?: string
28+
json?: boolean
29+
rebuild?: boolean
30+
},
31+
) => {
32+
try {
33+
const workspace = new Workspace()
34+
const search = new SearchManager(workspace)
35+
36+
if (!(await search.isAvailable())) {
37+
p.log.error('搜索功能不可用(qmd 加载失败)')
38+
process.exit(1)
39+
}
40+
41+
if (options.rebuild) {
42+
if (!options.json) p.log.info('正在重建索引...')
43+
await search.index()
44+
if (!options.json) p.log.success('索引重建完成')
45+
}
46+
47+
const results = await search.search(query, {
48+
limit: Number(options.limit),
49+
minScore: Number(options.minScore),
50+
})
51+
52+
if (options.json) {
53+
console.log(JSON.stringify(results, null, 2))
54+
await search.close()
55+
return
56+
}
57+
58+
if (results.length === 0) {
59+
p.log.info('未找到匹配结果')
60+
await search.close()
61+
return
62+
}
63+
64+
for (const r of results) {
65+
const score = Math.round(r.score * 100)
66+
const scoreColor =
67+
score >= 70
68+
? pc.green(`${score}%`)
69+
: score >= 40
70+
? pc.yellow(`${score}%`)
71+
: pc.dim(`${score}%`)
72+
p.log.info(`${pc.bold(r.path)} ${scoreColor}`)
73+
if (r.snippet) {
74+
p.log.message(pc.dim(r.snippet.slice(0, 200)))
75+
}
76+
}
77+
78+
await search.close()
79+
} catch (error) {
80+
handleError(error)
81+
}
82+
},
83+
)
84+
}

apps/cli/src/program.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { registerInitCommand } from './commands/init.js'
55
import { registerInstructionsCommand } from './commands/instructions.js'
66
import { registerListCommand } from './commands/list.js'
77
import { registerNewCommand } from './commands/new.js'
8+
import { registerSearchCommand } from './commands/search.js'
89
import { registerStatusCommand } from './commands/status.js'
910
import { registerUpdateCommand } from './commands/update.js'
1011

@@ -29,6 +30,7 @@ export function buildCliProgram(): Command {
2930
registerInstructionsCommand(program)
3031
registerListCommand(program)
3132
registerNewCommand(program)
33+
registerSearchCommand(program)
3234
registerStatusCommand(program)
3335
registerUpdateCommand(program)
3436

apps/cli/tsdown.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export default defineConfig({
44
entry: ['./src/index.ts'],
55
deps: {
66
alwaysBundle: [/^@marchen-spec\//],
7+
neverBundle: ['@tobilu/qmd', 'node-llama-cpp'],
78
},
89
platform: 'node',
910
format: ['esm'],
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
name: integrate-qmd-search
2+
schema: full
3+
createdAt: '2026-04-23T14:05:05.210Z'
4+
status: open
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
## 背景
2+
3+
MarchenSpec 的 archive 目录包含 30 个归档变更、132 个 markdown 文件、5400 行内容。当前 AI 获取历史的方式是 `cat changelog.md`(30 行摘要)或 `grep`(关键词匹配,无语义理解)。
4+
5+
qmd 是一个本地搜索引擎,提供 BM25 + 向量搜索 + LLM rerank 三级流水线,有 Node.js SDK 可直接 import。
6+
7+
## 目标与非目标
8+
9+
**目标:**
10+
- `marchen search` 命令提供语义搜索能力
11+
- archive 时自动更新索引,用户无感知
12+
- qmd 加载失败时优雅降级,不影响现有功能
13+
- explore skill 自动利用搜索获取历史上下文
14+
15+
**非目标:**
16+
- 不搜索代码库本身(只搜 archive 里的 markdown)
17+
- 不做跨项目搜索
18+
- 不自建 embedding/向量搜索(复用 qmd)
19+
- 不强制所有平台都能用搜索(native binding 编译失败时降级)
20+
21+
## 决策
22+
23+
### 决策 1:qmd 作为 dependency 而非 optional/peer
24+
25+
qmd 放在 `@marchen-spec/core``dependencies` 中,`npm install` 时一起安装。
26+
27+
**理由**:用户不需要额外安装步骤。native binding 编译失败的情况通过 dynamic import + try/catch 处理,不影响其他功能。
28+
29+
**替代方案**:optionalDependencies 或 peerDependencies——需要用户手动安装,体验差。
30+
31+
### 决策 2:SearchManager 放在 core 包
32+
33+
SearchManager 作为 core 包的新类,和 Workspace/ChangeManager 同级。不新建 `@marchen-spec/search` 包。
34+
35+
**理由**:搜索是 archive 的延伸能力,属于核心业务逻辑。新建包会增加 monorepo 复杂度,且 SearchManager 需要访问 Workspace 的路径信息。
36+
37+
### 决策 3:SQLite 数据库存放在 `marchen/.search/`
38+
39+
qmd 的 index.sqlite 存放在 `marchen/.search/index.sqlite`,加入 `.gitignore`
40+
41+
**理由**:索引是本地生成的衍生数据,不应提交到 git。放在 `marchen/` 下而非全局目录,保持项目自包含。
42+
43+
### 决策 4:tsdown external 处理 native 依赖
44+
45+
CLI 的 tsdown 构建配置中,将 `@tobilu/qmd``node-llama-cpp` 标记为 external。
46+
47+
**理由**:native binding(.node 文件)不能被 bundler 打包。external 后这些依赖在运行时从 node_modules 加载。
48+
49+
### 决策 5:archive 索引失败静默处理
50+
51+
`ChangeManager.archive()` 中的索引更新用 try/catch 包裹,失败时不抛出异常。
52+
53+
**理由**:归档是核心操作,搜索索引是增强功能。索引失败不应阻断归档流程。用户可以通过 `marchen search --rebuild` 手动重建。
54+
55+
### 决策 6:context 在 init 时自动配置
56+
57+
`workspace.initialize()` 时自动为 qmd store 添加 context 描述,帮助搜索理解文档结构。
58+
59+
**理由**:qmd 的 context 功能能显著提升搜索质量(在搜索结果中返回上下文描述)。用户不需要手动配置。
60+
61+
## 风险与权衡
62+
63+
### 风险 1:node-llama-cpp 编译失败
64+
65+
node-llama-cpp 依赖 C++ 编译。某些系统(缺少编译工具链、不支持的 CPU 架构)可能编译失败。
66+
67+
**缓解**:dynamic import + isAvailable() 检测。编译失败时搜索功能不可用,但 CLI 其他功能正常。
68+
69+
### 风险 2:安装体积增大
70+
71+
qmd + node-llama-cpp 会增加 npm install 的体积和时间。
72+
73+
**缓解**:模型不在 install 时下载(懒加载),npm 包本身增加约 50-80MB(主要是 node-llama-cpp 的预编译 binary)。
74+
75+
### 风险 3:首次搜索/归档延迟
76+
77+
首次使用搜索功能时需要下载 embedding 模型(~300MB),首次混合搜索还需要 reranker(~640MB)和 query expansion(~1.1GB)。
78+
79+
**缓解**:模型下载只发生一次,之后缓存在 `~/.cache/qmd/models/`。CLI 可以显示下载进度提示。
80+
81+
### 风险 4:macOS 需要 Homebrew SQLite
82+
83+
qmd 依赖 SQLite 扩展支持,macOS 系统自带的 SQLite 不够。
84+
85+
**缓解**:在安装文档中说明 `brew install sqlite` 前置要求。
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
## 动机
2+
3+
每次新对话开始,AI 对项目历史一无所知。archive 里有完整的设计决策、动机、规格,但 AI 无法高效检索。changelog.md 只有 30 行摘要,grep 只能做关键词匹配——用中文"异常处理"搜不到英文命名的 `error-handling` 变更。
4+
5+
需要语义搜索能力,让 AI 在 explore/propose/apply 阶段能自动关联历史决策。
6+
7+
## 变更内容
8+
9+
集成 [qmd](https://github.com/tobi/qmd) 作为内置依赖,提供本地语义搜索能力:
10+
11+
- 新增 `SearchManager` 类封装 qmd SDK,提供搜索和索引接口
12+
- `marchen archive` 时自动更新搜索索引
13+
- 新增 `marchen search` CLI 命令
14+
- 更新 explore skill 模板,在检查上下文阶段自动调用搜索
15+
- qmd 加载失败时优雅降级,不影响现有功能
16+
17+
qmd 使用 BM25 + 向量搜索 + LLM rerank 三级流水线,模型首次使用时自动下载并缓存到 `~/.cache/qmd/models/`
18+
19+
## 能力
20+
21+
### 新增能力
22+
23+
- `search-manager`: SearchManager 类——封装 qmd SDK,提供 isAvailable/search/index/indexChange 接口,dynamic import + 优雅降级
24+
- `search-command`: marchen search CLI 命令——支持 --json/--limit/--min-score/--rebuild 选项
25+
- `archive-indexing`: archive 时自动索引——archive() 末尾调用 SearchManager 增量更新索引
26+
- `skill-search-integration`: skill 模板搜索集成——explore skill 在检查上下文阶段自动调用 marchen search
27+
28+
### 修改能力
29+
30+
(无)
31+
32+
## 影响范围
33+
34+
- `packages/core` — 新增 SearchManager 类,Workspace 加 searchDbPath,ChangeManager.archive() 加索引 hook
35+
- `apps/cli` — 新增 search 命令,tsdown external 配置
36+
- `packages/config` — explore skill 模板更新
37+
- 根目录 `.gitignore` — 加 `marchen/.search/`
38+
- 依赖 — core 和 cli 加 `@tobilu/qmd`
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
## archive-indexing
2+
3+
archive 操作完成后自动更新搜索索引,保持索引与 archive 内容同步。
4+
5+
### 需求: archive() SHALL 在归档后更新搜索索引
6+
7+
#### 场景: 正常更新索引
8+
9+
- WHEN ChangeManager.archive() 成功完成文件移动和 changelog 写入
10+
- AND SearchManager.isAvailable() 返回 true
11+
- THEN 调用 SearchManager.indexChange() 更新索引
12+
- AND 归档结果正常返回
13+
14+
#### 场景: 索引更新失败不影响归档
15+
16+
- WHEN ChangeManager.archive() 成功完成文件移动和 changelog 写入
17+
- AND SearchManager.indexChange() 抛出异常
18+
- THEN 异常被捕获,不向上传播
19+
- AND 归档结果正常返回
20+
21+
#### 场景: qmd 不可用时跳过索引
22+
23+
- WHEN ChangeManager.archive() 执行
24+
- AND SearchManager.isAvailable() 返回 false
25+
- THEN 跳过索引更新
26+
- AND 归档流程正常完成
27+
28+
### 需求: Workspace SHALL 提供搜索索引路径
29+
30+
#### 场景: searchDbPath 指向 .search 目录
31+
32+
- WHEN Workspace 实例化
33+
- THEN searchDbPath 为 `<specDir>/.search/index.sqlite`
34+
35+
#### 场景: init 时创建 .search 目录
36+
37+
- WHEN workspace.initialize() 执行
38+
- THEN 创建 `<specDir>/.search/` 目录
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
## search-command
2+
3+
新增 `marchen search` CLI 命令,支持语义搜索归档变更历史。
4+
5+
### 需求: CLI SHALL 注册 search 命令
6+
7+
#### 场景: 基本搜索
8+
9+
- WHEN 用户执行 `marchen search "异常处理"`
10+
- THEN 调用 SearchManager.search() 执行语义搜索
11+
- AND 在终端格式化输出结果(路径、分数、摘要片段)
12+
13+
#### 场景: qmd 不可用时提示
14+
15+
- WHEN 用户执行 `marchen search "query"`
16+
- AND SearchManager.isAvailable() 返回 false
17+
- THEN 输出错误提示"搜索功能不可用"
18+
- AND 退出码非零
19+
20+
### 需求: search 命令 SHALL 支持 --json 输出
21+
22+
#### 场景: JSON 格式输出
23+
24+
- WHEN 用户执行 `marchen search "query" --json`
25+
- THEN 输出 JSON 数组,每项包含 path、score、snippet、title
26+
27+
### 需求: search 命令 SHALL 支持结果过滤选项
28+
29+
#### 场景: 限制结果数量
30+
31+
- WHEN 用户执行 `marchen search "query" -n 10`
32+
- THEN 最多返回 10 条结果
33+
34+
#### 场景: 最低分数过滤
35+
36+
- WHEN 用户执行 `marchen search "query" --min-score 0.4`
37+
- THEN 只返回 score >= 0.4 的结果
38+
39+
### 需求: search 命令 SHALL 支持索引重建
40+
41+
#### 场景: 重建索引
42+
43+
- WHEN 用户执行 `marchen search --rebuild "query"`
44+
- THEN 先调用 SearchManager.index() 全量重建索引
45+
- AND 然后执行搜索
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
## search-manager
2+
3+
SearchManager 类封装 qmd SDK,提供语义搜索和索引管理接口。通过 dynamic import 加载 qmd,加载失败时优雅降级。
4+
5+
### 需求: SearchManager SHALL 通过 dynamic import 懒加载 qmd
6+
7+
#### 场景: qmd 可用时正常初始化
8+
9+
- WHEN SearchManager.isAvailable() 被调用
10+
- AND `@tobilu/qmd` 可以被 import
11+
- THEN 返回 `true`
12+
- AND 后续 search/index 调用正常工作
13+
14+
#### 场景: qmd 不可用时优雅降级
15+
16+
- WHEN SearchManager.isAvailable() 被调用
17+
- AND `@tobilu/qmd` import 失败(native binding 编译失败等)
18+
- THEN 返回 `false`
19+
- AND 不抛出异常
20+
21+
### 需求: SearchManager SHALL 提供语义搜索接口
22+
23+
#### 场景: 搜索返回结构化结果
24+
25+
- WHEN search(query, options) 被调用
26+
- THEN 返回结果数组,每项包含 path、score、snippet、title
27+
- AND 结果按 score 降序排列
28+
- AND 默认返回 5 条,可通过 options.limit 调整
29+
- AND 可通过 options.minScore 过滤低分结果
30+
31+
### 需求: SearchManager SHALL 懒初始化 qmd store
32+
33+
#### 场景: store 在首次调用时创建
34+
35+
- WHEN search() 或 index() 首次被调用
36+
- THEN 创建 qmd store,dbPath 为 workspace.searchDbPath
37+
- AND collection 指向 workspace.archiveDir,pattern 为 `**/*.md`
38+
- AND 后续调用复用同一个 store 实例
39+
40+
### 需求: SearchManager SHALL 提供索引管理接口
41+
42+
#### 场景: 全量索引
43+
44+
- WHEN index() 被调用
45+
- THEN 扫描 archive 目录所有 .md 文件
46+
- AND 生成向量 embedding
47+
48+
#### 场景: 增量索引
49+
50+
- WHEN indexChange() 被调用
51+
- THEN 更新索引并为新文档生成 embedding
52+
53+
### 需求: SearchManager SHALL 提供资源释放接口
54+
55+
#### 场景: 关闭 store
56+
57+
- WHEN close() 被调用
58+
- THEN 释放 qmd store 资源
59+
- AND 后续调用需要重新初始化

0 commit comments

Comments
 (0)