diff --git a/CHANGELOG.md b/CHANGELOG.md index d6caad1..3e26eca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +- Add Qoder client compatibility: `chilon-recall qoder ` generates `.qoder/mcp.json`, project-level skills, and a retrieval rule file from the bundled definitions. +- Document the Qoder client setup in both READMEs; the generated files stay credential-free. + ## [0.1.3] - 2026-09-05 - Add `rag_sync`: a staged, content-hash incremental synchronization of the knowledge index. Unchanged files reuse their existing vectors; added, modified, and deleted files are reconciled. diff --git a/README.md b/README.md index f356981..76b7458 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,9 @@ It is an independent retrieval companion in the [Chilon Knowledge Work Harness]( npx -y chilon-recall@0.1.3 doctor ``` -3. **Connect one client / 连接一个客户端。** Start with [Codex](#codex--codex-配置) or [Claude Desktop](#claude-desktop--claude-desktop-配置). The client starts the local server for you; you do not need to keep a separate terminal open. +3. **Connect one client / 连接一个客户端。** Start with [Codex](#codex--codex-配置), [Claude Desktop](#claude-desktop--claude-desktop-配置), or [Qoder](#qoder--qoder-配置). The client starts the local server for you; you do not need to keep a separate terminal open. - **从 [Codex](#codex--codex-配置) 或 [Claude Desktop](#claude-desktop--claude-desktop-配置) 开始即可。** 客户端会替你启动本地 server,无需另开终端长期运行。 + **从 [Codex](#codex--codex-配置)、[Claude Desktop](#claude-desktop--claude-desktop-配置) 或 [Qoder](#qoder--qoder-配置) 开始即可。** 客户端会替你启动本地 server,无需另开终端长期运行。 ## Why Chilon Recall? / 为什么使用 Chilon Recall? @@ -58,8 +58,8 @@ It is an independent retrieval companion in the [Chilon Knowledge Work Harness]( - **本地优先控制**——文档和 FAISS 索引保留在你的设备上;只有发送给自选 embedding/reranking provider 的文本会离开设备。 - **Safe operations** — builds happen in staging; clear and restore actions use previews, short-lived confirmation tokens, and recoverable backups. - **安全操作**——建库在 staging 目录中完成;清理和恢复使用预览、短期确认 token 与可恢复备份。 -- **MCP portability** — one `stdio` server works with Codex, Claude Desktop, and other MCP-compatible local clients. -- **MCP 可移植性**——同一个 `stdio` server 可用于 Codex、Claude Desktop 及其他兼容的本地客户端。 +- **MCP portability** — one `stdio` server works with Codex, Claude Desktop, Qoder, and other MCP-compatible local clients. +- **MCP 可移植性**——同一个 `stdio` server 可用于 Codex、Claude Desktop、Qoder 及其他兼容的本地客户端。 ## Built for learning and knowledge work / 为学习与知识工作而设计 @@ -269,6 +269,45 @@ Set `RAG_API_KEY` in the environment inherited by Claude Desktop, or add it only 应在 Claude Desktop 可继承的系统环境中设置 `RAG_API_KEY`;若操作系统无法提供,只能把它加入本机私有 client 配置。Claude Desktop 会将 `env` 值存入本地 JSON,因此应限制文件权限,且绝不能提交该文件。Windows 用户应指向虚拟环境中的 `python.exe`。 +### Qoder / Qoder 配置 + +The Qoder client loads MCP servers from its own settings, and project-level skills and rules from the `.qoder/` directory. Generate all three from a checkout or an npm install: + +Qoder 客户端从自身设置中加载 MCP server,并从项目内的 `.qoder/` 目录加载项目级 skills 与 rules。可用一条命令生成这三部分: + +```powershell +npx -y chilon-recall@0.1.3 qoder C:\path\to\your\project +``` + +This writes `.qoder/mcp.json`, `.qoder/skills//SKILL.md` for every bundled skill, and `.qoder/rules/chilon-recall.md`. Add `--force` to regenerate over existing files. + +该命令会写入 `.qoder/mcp.json`、每个内置 skill 对应的 `.qoder/skills//SKILL.md`,以及 `.qoder/rules/chilon-recall.md`。若要覆盖已有文件,请加 `--force`。 + +Qoder does not read `.qoder/mcp.json` automatically; it is a shareable snippet. Open **Qoder client Settings → MCP → My Servers → + Add**, paste its contents, and replace the `RAG_MANAGER_CONFIG` placeholder with your private configuration path: + +Qoder 不会自动读取 `.qoder/mcp.json`,它只是一份可共享的配置片段。请打开 **Qoder 客户端 Settings → MCP → My Servers → + Add**,粘贴其内容,并把 `RAG_MANAGER_CONFIG` 占位符替换为你的私有配置路径: + +```json +{ + "mcpServers": { + "chilon-recall": { + "command": "node", + "args": [ + "/absolute/path/to/chilon-recall/scripts/cli.mjs", + "mcp" + ], + "env": { + "RAG_MANAGER_CONFIG": "" + } + } + } +} +``` + +Set `RAG_API_KEY` (and `RAG_RERANK_API_KEY` when reranking is enabled) in the environment Qoder inherits. The generated files are safe to commit; credentials never belong in them. Restart the Qoder client so the generated skills and rules load, then confirm the tools under **My Servers**. + +请在 Qoder 可继承的系统环境中设置 `RAG_API_KEY`(启用 reranking 时还需 `RAG_RERANK_API_KEY`)。生成的文件可以提交到版本库,其中绝不应写入凭据。重启 Qoder 客户端以加载生成的 skills 与 rules,并在 **My Servers** 中确认工具已出现。 + ## How it works / 工作原理 ```text diff --git a/README.zh-CN.md b/README.zh-CN.md index b4e8fb8..8e53feb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -28,7 +28,7 @@ Chilon Recall 可将你自己的文本资料转换为私有、来源可追溯的 npx -y chilon-recall@0.1.3 doctor ``` -3. **连接一个客户端。** 从 [Codex](#codex) 或 [Claude Desktop](#claude-desktop) 开始即可。客户端会替你启动本地 server,无需另开终端长期运行。 +3. **连接一个客户端。** 从 [Codex](#codex)、[Claude Desktop](#claude-desktop) 或 [Qoder](#qoder) 开始即可。客户端会替你启动本地 server,无需另开终端长期运行。 ## 为什么使用 Chilon Recall? @@ -36,7 +36,7 @@ Chilon Recall 可将你自己的文本资料转换为私有、来源可追溯的 - **答案可追溯**:每条结果包含相对路径、标题层级、近似行号和检索分数。 - **本地优先控制**:文档和 FAISS 索引留在本机;只有发送给自选 embedding/reranker 服务的文本会离开设备。 - **安全索引操作**:新索引先在 staging 完成;清理和恢复需要预览、短期确认 token,并保留可恢复备份。 -- **跨 MCP 客户端**:同一 `stdio` MCP server 可用于 Codex、Claude Desktop 及其他兼容客户端。 +- **跨 MCP 客户端**:同一 `stdio` MCP server 可用于 Codex、Claude Desktop、Qoder 及其他兼容客户端。 ## 面向学习与知识工作的能力 @@ -196,6 +196,37 @@ bundle 会在 `CHILON_RECALL_ROOT` 中运行 `node scripts/cli.mjs mcp`。如果 应在 Claude Desktop 能继承的系统环境中设置 `RAG_API_KEY`;若操作系统无法提供,只能把它加入你本机的私有客户端配置。Claude Desktop 会把 `env` 值保存在本地 JSON 中,因此请限制文件权限,且绝不能提交该配置。Windows 用户应指向虚拟环境中的 `python.exe`。 +### Qoder + +Qoder 客户端从自身设置中加载 MCP server,并从项目内的 `.qoder/` 目录加载项目级 skills 与 rules。可用一条命令生成这三部分: + +```powershell +npx -y chilon-recall@0.1.3 qoder C:\path\to\your\project +``` + +该命令会写入 `.qoder/mcp.json`、每个内置 skill 对应的 `.qoder/skills//SKILL.md`,以及 `.qoder/rules/chilon-recall.md`。若要覆盖已有文件,请加 `--force`。 + +Qoder 不会自动读取 `.qoder/mcp.json`,它只是一份可共享的配置片段。请打开 **Qoder 客户端 Settings → MCP → My Servers → + Add**,粘贴其内容,并把 `RAG_MANAGER_CONFIG` 占位符替换为你的私有配置路径: + +```json +{ + "mcpServers": { + "chilon-recall": { + "command": "node", + "args": [ + "/absolute/path/to/chilon-recall/scripts/cli.mjs", + "mcp" + ], + "env": { + "RAG_MANAGER_CONFIG": "" + } + } + } +} +``` + +请在 Qoder 可继承的系统环境中设置 `RAG_API_KEY`(启用 reranking 时还需 `RAG_RERANK_API_KEY`)。生成的文件可以提交到版本库,其中绝不应写入凭据。重启 Qoder 客户端以加载生成的 skills 与 rules,并在 **My Servers** 中确认工具已出现。 + ## 工作原理 ```text diff --git a/scripts/cli.mjs b/scripts/cli.mjs index 4e6aed7..50ee2dc 100644 --- a/scripts/cli.mjs +++ b/scripts/cli.mjs @@ -15,6 +15,7 @@ import { setupEngine, venvPython } from "../src/runtime.mjs"; +import { installQoder } from "../src/qoder.mjs"; import { startStdioServer } from "../src/server.mjs"; const help = `Chilon Recall — local-first MCP knowledge retrieval @@ -24,6 +25,8 @@ Usage: Create a private config and install the Python engine. chilon-recall init [--force] Create a private config in a document directory. + chilon-recall qoder [--force] + Generate the Qoder client surface (.qoder/mcp.json, skills, rules). chilon-recall setup Create or update the isolated Python engine. chilon-recall doctor Check Node, Python engine, and private configuration. chilon-recall mcp Start the stdio MCP server (the default command). @@ -173,6 +176,16 @@ export async function main(argv = process.argv.slice(2)) { writeJson({ config: configPath, next: "Set RAG_MANAGER_CONFIG to this path, then configure your provider environment variables." }); return 0; } + if (command === "qoder") { + const args = argv.slice(1); + const force = args.includes("--force"); + const positional = args.filter((arg) => arg !== "--force"); + if (positional.length > 1) { + throw new Error("`qoder` accepts at most one project directory."); + } + writeJson(await installQoder(positional[0], { force })); + return 0; + } if (command === "doctor") return doctor(); if (command === "mcp") { process.env.CHILON_RECALL_PYTHON = await resolveEnginePython(); diff --git a/src/qoder.mjs b/src/qoder.mjs new file mode 100644 index 0000000..bce2907 --- /dev/null +++ b/src/qoder.mjs @@ -0,0 +1,129 @@ +// Qoder client compatibility layer. +// +// Qoder reads MCP servers from its own client settings (Settings -> MCP), and reads +// project-level skills from `.qoder/skills//SKILL.md` and project rules from +// `.qoder/rules/`. This module renders those artifacts for a target project from the +// same stdio server and skill definitions the other clients use, so the retrieval +// engine is never duplicated and credentials are never written into shared files. + +import { promises as fs } from "node:fs"; +import path from "node:path"; + +import { packageRoot } from "./runtime.mjs"; + +export const QODER_SERVER_NAME = "chilon-recall"; + +// Only names are listed here. Values stay in the operating system environment. +export const QODER_ENV_VARS = [ + "RAG_MANAGER_CONFIG", + "RAG_API_KEY", + "RAG_RERANK_API_KEY", + "CHILON_RECALL_HOME", + "CHILON_RECALL_PYTHON" +]; + +const RULE_FILE = "chilon-recall.md"; + +const RULE_BODY = `# Chilon Recall retrieval rules + +Apply when a request depends on the local Chilon Recall knowledge base. + +1. Call \`rag_status\` before answering from sources when index readiness or source scope is unknown. +2. Use \`textbook_qa\`, \`concept_compare\`, \`chapter_summary\`, or \`review_outline\` instead of answering + document questions from general knowledge. +3. Cite the relative source paths and headings returned by the tools. +4. Keep retrieved source claims separate from your own explanation or outside knowledge. +5. State what the evidence does not establish. A high retrieval score is not proof of completeness. +6. To refresh the index after documents change, call \`rag_sync\`, which reuses vectors for unchanged + files. Use \`rag_build\` only for a deliberate full rebuild. +7. \`rag_build\`, \`rag_sync\`, \`rag_clear_index\`, and \`rag_restore_index\` require \`action: "preview"\` first. + Show the preview to the user, then pass its token once with \`action: "execute"\`. +8. Never pass API keys as tool arguments. Credentials come from the environment only. +`; + +/** + * Render the MCP server entry to paste into Qoder client Settings -> MCP. + * `env` carries placeholders only; real secrets belong in the OS environment. + */ +export function qoderMcpConfig({ node = process.execPath, root = packageRoot } = {}) { + return { + mcpServers: { + [QODER_SERVER_NAME]: { + command: node, + args: [path.join(root, "scripts", "cli.mjs"), "mcp"], + env: { + RAG_MANAGER_CONFIG: "" + } + } + } + }; +} + +async function skillNames(root) { + const directory = path.join(root, "skills"); + const entries = await fs.readdir(directory, { withFileTypes: true }); + const names = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + try { + await fs.access(path.join(directory, entry.name, "SKILL.md")); + names.push(entry.name); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + return names.sort(); +} + +async function writeFile(target, content, force) { + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, content, { encoding: "utf8", flag: force ? "w" : "wx" }); + return target; +} + +/** + * Install the Qoder project surface into `directory`: + * `.qoder/mcp.json`, `.qoder/skills//SKILL.md`, and `.qoder/rules/chilon-recall.md`. + */ +export async function installQoder(directory, { force = false, root = packageRoot, node = process.execPath } = {}) { + const targetDirectory = path.resolve(directory || process.cwd()); + const qoderDirectory = path.join(targetDirectory, ".qoder"); + const written = []; + + try { + written.push( + await writeFile( + path.join(qoderDirectory, "mcp.json"), + `${JSON.stringify(qoderMcpConfig({ node, root }), null, 2)}\n`, + force + ) + ); + + const names = await skillNames(root); + for (const name of names) { + const content = await fs.readFile(path.join(root, "skills", name, "SKILL.md"), "utf8"); + written.push(await writeFile(path.join(qoderDirectory, "skills", name, "SKILL.md"), content, force)); + } + + written.push(await writeFile(path.join(qoderDirectory, "rules", RULE_FILE), RULE_BODY, force)); + } catch (error) { + if (error.code === "EEXIST") { + throw new Error( + `${error.path} already exists. Re-run with --force only if you intend to replace the generated Qoder files.` + ); + } + throw error; + } + + return { + directory: qoderDirectory, + written, + mcp_server: QODER_SERVER_NAME, + forwarded_env: QODER_ENV_VARS, + next: [ + "Open Qoder client Settings -> MCP -> My Servers -> + Add and paste .qoder/mcp.json, replacing the RAG_MANAGER_CONFIG placeholder.", + "Provide RAG_API_KEY (and RAG_RERANK_API_KEY when reranking is enabled) through the environment Qoder inherits; never commit them.", + "Restart the Qoder client so the generated skills and rules are loaded." + ] + }; +} diff --git a/tests/node/qoder.test.mjs b/tests/node/qoder.test.mjs new file mode 100644 index 0000000..bf1c56a --- /dev/null +++ b/tests/node/qoder.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { installQoder, qoderMcpConfig, QODER_SERVER_NAME } from "../../src/qoder.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +async function temporaryDirectory() { + return fs.mkdtemp(path.join(os.tmpdir(), "chilon-qoder-")); +} + +test("renders a stdio server entry Qoder can load", () => { + const config = qoderMcpConfig({ node: "node", root: "/opt/chilon-recall" }); + const server = config.mcpServers[QODER_SERVER_NAME]; + + assert.equal(server.command, "node"); + assert.deepEqual(server.args, [path.join("/opt/chilon-recall", "scripts", "cli.mjs"), "mcp"]); + assert.match(server.env.RAG_MANAGER_CONFIG, /^ { + const directory = await temporaryDirectory(); + try { + const result = await installQoder(directory, { node: "node" }); + const bundled = (await fs.readdir(path.join(root, "skills"), { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + const installed = (await fs.readdir(path.join(directory, ".qoder", "skills"))).sort(); + + assert.deepEqual(installed, bundled); + for (const name of bundled) { + const source = await fs.readFile(path.join(root, "skills", name, "SKILL.md"), "utf8"); + const copied = await fs.readFile(path.join(directory, ".qoder", "skills", name, "SKILL.md"), "utf8"); + assert.equal(copied, source); + } + + const rule = await fs.readFile(path.join(directory, ".qoder", "rules", "chilon-recall.md"), "utf8"); + assert.match(rule, /rag_status/); + + for (const file of result.written) { + const content = await fs.readFile(file, "utf8"); + assert.doesNotMatch(content, /\b(?:sk|sf)-[A-Za-z0-9_-]{20,}\b/); + } + assert.ok(result.written.length >= bundled.length + 2); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } +}); + +test("refuses to overwrite generated files without --force", async () => { + const directory = await temporaryDirectory(); + try { + await installQoder(directory, { node: "node" }); + await assert.rejects(() => installQoder(directory, { node: "node" }), /already exists/); + await installQoder(directory, { node: "node", force: true }); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } +});