b9bf3ec3d5
skills/examples 整條從舊 v3 /blocks /search 改打 KBDB 基本盤 /entries (entry_type 對應)。5 個已上線 MCP 工具原本對死 route 回 404(假綠), 現修正;sync-registry-to-kbdb.py 改打 /entries idempotent upsert。 誠實降級:基本盤無語義 search → LIKE 關鍵字(embed 模組上線再換回語義)。 順手 gitignore scripts/__pycache__/。 對應 kbdb-base tasks 9.4 / llm-interface M3.2/M3.4。mcp + kbdb tsc exit 0。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
/**
|
||
* Skills + Examples lookup MCP tools — LI SDD M3.2 / M3.4
|
||
*
|
||
* 對應 docs/3-specs/llm-interface/ Milestone 3.2 + 3.4。
|
||
*
|
||
* - arcrun_list_skills — 列 KBDB entry_type=agent-skill 全部
|
||
* - arcrun_get_skill — 用 slug 拿 skill markdown 全文
|
||
* - arcrun_list_examples — 列 KBDB entry_type=workflow-example 全部
|
||
* - arcrun_get_example — 用 slug 拿 example yaml + description + tags
|
||
* - arcrun_search_examples — use case 關鍵字 → 命中相關 example
|
||
*
|
||
* Skills / examples 由 arcrun/scripts/sync-registry-to-kbdb.py 從
|
||
* arcrun/registry/{skills,examples} 同步進 KBDB。
|
||
*
|
||
* 直接走 KBDB service binding(既有 pattern),不經 cypher-executor。
|
||
*
|
||
* 2026-06-14 重寫:KBDB 降基本盤後(三表 entries/templates/records,無 v3 blocks 表、
|
||
* 無語義 search),原打 /blocks /search 的舊路徑全失效。改打基本盤 /entries:
|
||
* - entry_type 取代 blocks 的 type 欄(entries 表原生有 entry_type/page_name/tags_json/metadata_json)
|
||
* - GET /blocks?type=X → GET /entries?entry_type=X
|
||
* - GET /blocks?page_name=Y → GET /entries?page_name=Y(base listEntries 加了 page_name 過濾)
|
||
* - POST /search(語義) → GET /entries/search?q=(D1 LIKE 關鍵字,基本盤無語義;
|
||
* 誠實降級:search_examples 現在是「關鍵字」非「語義」。embed 模組(kbdb-base Phase 1)
|
||
* 上線後只換內部、工具簽名不變。
|
||
*/
|
||
|
||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
import { z } from "zod";
|
||
import type { Env } from "../types.js";
|
||
import { kbdbFetch } from "../lib/kbdb-client.js";
|
||
import { errorResponse, successResponse } from "../lib/cypher-client.js";
|
||
|
||
// 基本盤 entries row(與舊 v3 block 欄位 1:1,差別只在 type→entry_type)
|
||
interface KbdbBlock {
|
||
id: string;
|
||
page_name?: string | null;
|
||
content?: string | null;
|
||
entry_type?: string;
|
||
tags_json?: string;
|
||
metadata_json?: string | null;
|
||
source?: string | null;
|
||
updated_at?: number;
|
||
}
|
||
|
||
async function kbdbList(env: Env, entryType: string, limit = 100): Promise<KbdbBlock[]> {
|
||
const resp = await kbdbFetch(env, `/entries?entry_type=${encodeURIComponent(entryType)}&limit=${limit}`);
|
||
if (!resp.ok) throw new Error(`KBDB list entry_type=${entryType} HTTP ${resp.status}`);
|
||
const data = await resp.json<{ entries?: KbdbBlock[] }>();
|
||
return data.entries ?? [];
|
||
}
|
||
|
||
async function kbdbGetByPageName(env: Env, pageName: string): Promise<KbdbBlock | null> {
|
||
const resp = await kbdbFetch(env, `/entries?page_name=${encodeURIComponent(pageName)}&limit=1`);
|
||
if (!resp.ok) return null;
|
||
const data = await resp.json<{ entries?: KbdbBlock[] }>();
|
||
return data.entries?.[0] ?? null;
|
||
}
|
||
|
||
function parseTags(tagsJson?: string): string[] {
|
||
if (!tagsJson) return [];
|
||
try {
|
||
const arr = JSON.parse(tagsJson);
|
||
return Array.isArray(arr) ? arr : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export function registerListSkills(server: McpServer, env: Env) {
|
||
server.tool(
|
||
"arcrun_list_skills",
|
||
"列所有 agent-skill blocks(從 arcrun/registry/skills/ 同步進 KBDB)。每個 skill 是個 markdown playbook,描述 AI 面對 X 問題該怎麼想 + 該用哪個 example。回 [{slug, title, tags}]。call get_skill(slug) 拿完整內文。",
|
||
{
|
||
tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'watcher' / 'debug'"),
|
||
},
|
||
async ({ tag }) => {
|
||
try {
|
||
const blocks = await kbdbList(env, "agent-skill", 100);
|
||
const skills = blocks
|
||
.map((b) => {
|
||
const tags = parseTags(b.tags_json);
|
||
let title = b.page_name?.replace(/^skill-/, "") ?? "(no title)";
|
||
try {
|
||
const meta = b.metadata_json ? JSON.parse(b.metadata_json) : null;
|
||
if (meta?.title) title = meta.title;
|
||
} catch {}
|
||
return {
|
||
slug: b.page_name?.replace(/^skill-/, "") ?? "",
|
||
page_name: b.page_name,
|
||
title,
|
||
tags,
|
||
chars: (b.content ?? "").length,
|
||
};
|
||
})
|
||
.filter((s) => !tag || s.tags.includes(`skill:${tag}`) || s.tags.includes(tag) || s.slug.includes(tag));
|
||
|
||
return successResponse(
|
||
{ count: skills.length, skills },
|
||
[
|
||
skills.length === 0
|
||
? "沒有 skill 命中。試 list_skills() 不帶 tag 看全部"
|
||
: "call arcrun_get_skill(slug) 拿單個 skill 完整 markdown",
|
||
],
|
||
);
|
||
} catch (e) {
|
||
return errorResponse(
|
||
"fetch_failed",
|
||
e instanceof Error ? e.message : String(e),
|
||
["稍後重試", "若持續失敗,告訴 leo"],
|
||
);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
export function registerGetSkill(server: McpServer, env: Env) {
|
||
server.tool(
|
||
"arcrun_get_skill",
|
||
"拿單一 agent-skill 完整 markdown playbook。slug 從 list_skills 取得。",
|
||
{
|
||
slug: z.string().describe("skill slug,例如 'build_watcher_workflow' / 'rag_with_arcrun'"),
|
||
},
|
||
async ({ slug }) => {
|
||
try {
|
||
const pageName = slug.startsWith("skill-") ? slug : `skill-${slug}`;
|
||
const block = await kbdbGetByPageName(env, pageName);
|
||
if (!block) {
|
||
return errorResponse(
|
||
"not_found",
|
||
`skill "${slug}" 不存在`,
|
||
[
|
||
"call arcrun_list_skills() 看可用 slug",
|
||
"確認拼字正確(不需要 'skill-' prefix)",
|
||
],
|
||
);
|
||
}
|
||
return successResponse({
|
||
slug,
|
||
page_name: block.page_name,
|
||
content: block.content,
|
||
tags: parseTags(block.tags_json),
|
||
});
|
||
} catch (e) {
|
||
return errorResponse(
|
||
"fetch_failed",
|
||
e instanceof Error ? e.message : String(e),
|
||
["稍後重試"],
|
||
);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
export function registerListExamples(server: McpServer, env: Env) {
|
||
server.tool(
|
||
"arcrun_list_examples",
|
||
"列所有 workflow-example blocks(從 arcrun/registry/examples/ 同步進 KBDB)。每個 example 是可直接 push 的 workflow YAML 範本 + description。回 [{slug, tags}]。call get_example / search_examples 拿細節。",
|
||
{
|
||
tag: z.string().optional().describe("optional 標籤過濾。如 'rag' / 'cron' / 'llm' / 'webhook'"),
|
||
},
|
||
async ({ tag }) => {
|
||
try {
|
||
const blocks = await kbdbList(env, "workflow-example", 200);
|
||
const examples = blocks
|
||
.map((b) => {
|
||
const tags = parseTags(b.tags_json);
|
||
return {
|
||
slug: b.page_name?.replace(/^example-/, "") ?? "",
|
||
page_name: b.page_name,
|
||
tags,
|
||
chars: (b.content ?? "").length,
|
||
};
|
||
})
|
||
.filter((e) => !tag || e.tags.includes(tag) || e.tags.includes(`example:${tag}`) || e.slug.includes(tag));
|
||
|
||
return successResponse(
|
||
{ count: examples.length, examples },
|
||
[
|
||
examples.length === 0
|
||
? "沒有 example 命中。試 list_examples() 不帶 tag 看全部"
|
||
: "call arcrun_get_example(slug) 拿單個 YAML + description",
|
||
],
|
||
);
|
||
} catch (e) {
|
||
return errorResponse(
|
||
"fetch_failed",
|
||
e instanceof Error ? e.message : String(e),
|
||
["稍後重試"],
|
||
);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
export function registerGetExample(server: McpServer, env: Env) {
|
||
server.tool(
|
||
"arcrun_get_example",
|
||
"拿單一 workflow-example 完整 YAML + description。slug 從 list_examples / search_examples 取得。可直接拿 YAML 改成你自己的 → push。",
|
||
{
|
||
slug: z.string().describe("example slug,例如 'rag-search-answer' / 'cron-watcher'"),
|
||
},
|
||
async ({ slug }) => {
|
||
try {
|
||
const pageName = slug.startsWith("example-") ? slug : `example-${slug}`;
|
||
const block = await kbdbGetByPageName(env, pageName);
|
||
if (!block) {
|
||
return errorResponse(
|
||
"not_found",
|
||
`example "${slug}" 不存在`,
|
||
[
|
||
"call arcrun_list_examples() 看可用 slug",
|
||
"或 arcrun_search_examples(use_case) 用自然語言找",
|
||
],
|
||
);
|
||
}
|
||
|
||
let description_md = "";
|
||
try {
|
||
const meta = block.metadata_json ? JSON.parse(block.metadata_json) : null;
|
||
description_md = meta?.description_md ?? "";
|
||
} catch {}
|
||
|
||
return successResponse({
|
||
slug,
|
||
page_name: block.page_name,
|
||
workflow_yaml: block.content,
|
||
description_md,
|
||
tags: parseTags(block.tags_json),
|
||
}, [
|
||
"拿 workflow_yaml 改成你自己的 → call arcrun_push_workflow",
|
||
"看 description_md 了解設計意圖 / 改造方向",
|
||
]);
|
||
} catch (e) {
|
||
return errorResponse(
|
||
"fetch_failed",
|
||
e instanceof Error ? e.message : String(e),
|
||
["稍後重試"],
|
||
);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
export function registerSearchExamples(server: McpServer, env: Env) {
|
||
server.tool(
|
||
"arcrun_search_examples",
|
||
"用 use case 關鍵字搜 workflow examples,回最相關 N 個。" +
|
||
"注意:基本盤目前是 D1 LIKE 關鍵字搜尋(非語義 embedding;語義是 kbdb-base Phase 1 的 embed 模組,尚未上)。" +
|
||
"→ 用具體詞('email'、'cron'、'rag')比整句自然語言命中率高。也會比對 slug/tag。",
|
||
{
|
||
query: z.string().min(2).describe("use case 關鍵字,例如 'email 摘要' / 'cron 排程' / 'rag'。基本盤是關鍵字非語義,用詞要具體"),
|
||
top_k: z.number().int().min(1).max(20).optional().describe("回幾個結果(預設 5)"),
|
||
},
|
||
async ({ query, top_k }) => {
|
||
try {
|
||
const k = top_k ?? 5;
|
||
const q = query.trim();
|
||
|
||
// 基本盤無語義 search:撈全部 workflow-example,用 query 對 content/slug/tag 做關鍵字比對排序。
|
||
// (examples 只有 ~10 筆,client 端過濾零負擔;embed 模組上線後可改打語義 search)
|
||
const blocks = await kbdbList(env, "workflow-example", 200);
|
||
const ql = q.toLowerCase();
|
||
const terms = ql.split(/\s+/).filter(Boolean);
|
||
|
||
const scored = blocks
|
||
.map((b) => {
|
||
const slug = b.page_name?.replace(/^example-/, "") ?? "";
|
||
const tags = parseTags(b.tags_json);
|
||
const hay = `${slug} ${tags.join(" ")} ${(b.content ?? "")}`.toLowerCase();
|
||
// 每個 term 命中 +1;slug/tag 命中額外加權
|
||
let score = 0;
|
||
for (const t of terms) {
|
||
if (hay.includes(t)) score += 1;
|
||
if (slug.toLowerCase().includes(t)) score += 2;
|
||
if (tags.some((tag) => tag.toLowerCase().includes(t))) score += 2;
|
||
}
|
||
return { b, slug, tags, score };
|
||
})
|
||
.filter((r) => r.score > 0)
|
||
.sort((a, b) => b.score - a.score)
|
||
.slice(0, k);
|
||
|
||
const examples = scored.map((r) => ({
|
||
slug: r.slug,
|
||
page_name: r.b.page_name,
|
||
score: r.score,
|
||
tags: r.tags,
|
||
preview: (r.b.content ?? "").slice(0, 200),
|
||
}));
|
||
|
||
if (examples.length === 0) {
|
||
return successResponse(
|
||
{ count: 0, examples: [], query: q },
|
||
[
|
||
"關鍵字沒命中(基本盤是 LIKE 非語義,換更具體/不同的詞再試)",
|
||
"改用 arcrun_list_examples(tag='...') 走 tag 過濾",
|
||
"或 arcrun_list_examples() 看全部清單自己挑",
|
||
],
|
||
);
|
||
}
|
||
|
||
return successResponse(
|
||
{ count: examples.length, examples, query: q, search_mode: "keyword" },
|
||
[
|
||
"call arcrun_get_example(slug) 拿完整 YAML",
|
||
"score 高 = 關鍵字命中越多(slug/tag 命中加權)",
|
||
"search_mode:keyword — 基本盤無語義,命中靠字面;換具體詞可改善",
|
||
],
|
||
);
|
||
} catch (e) {
|
||
return errorResponse(
|
||
"internal_error",
|
||
e instanceof Error ? e.message : String(e),
|
||
["重試一次"],
|
||
);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
export function registerAllSkillExampleTools(server: McpServer, env: Env) {
|
||
registerListSkills(server, env);
|
||
registerGetSkill(server, env);
|
||
registerListExamples(server, env);
|
||
registerGetExample(server, env);
|
||
registerSearchExamples(server, env);
|
||
}
|