/** * pi-kb — 通用知识库扩展(渐进式披露)。 * * 复用 DSH 的 dsh-kb 知识库(默认 ~/.agents/kb): * - 目录即分类:磁盘目录树是分类的唯一事实来源(路径即分类 id,可多级), * categories.yml 仅为可选元数据叠加层(title/description/order/enabled); * - 每个知识点 = 一个带 front-matter 的 markdown 文件(name/summary/tags + * 可选 related/status/updated),多标签 + 关键词检索(含正文与分类描述); * - 生效开关:categories.yml 中 enabled: false 停用该分类及整棵子树 * (检索/列表默认忽略、拒绝写入、数据零删除,恢复 enabled: true 自动生效); * - 写入门控:kb_create/kb_update/kb_categories 默认需 user_approved: true, * autoApproveCategories 白名单(逗号分隔分类路径,前缀匹配)内免确认; * - 待补池:kb_search/kb_get 未命中自动留痕到进程内存(按会话隔离、 * 不写任何文件、重启即清空)。 * * 8 个工具:kb_categories / kb_list / kb_search / kb_get / kb_validate / * kb_pending / kb_create / kb_update。与 DSH dsh-kb 插件语义逐一对齐。 * * 引导方式:Pi 无 DSH 的 system-prompt section 瀑布,改用每个工具的 * promptSnippet + promptGuidelines 注入静态引导(「第一步先 list」等硬要求)。 * * 数据目录可用环境变量 PI_KB_DATA_DIR 覆盖(默认 ~/.agents/kb); * 自动记录白名单用 PI_KB_AUTO_APPROVE_CATEGORIES(逗号分隔分类路径)。 */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Type } from "typebox"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; const DEFAULT_DATA_DIR = path.join(os.homedir(), ".agents", "kb"); // ── 知识库解析(与 DSH dsh-kb 插件同格式)────────────────────────────── function listVal(v: unknown): string[] { if (v === undefined || v === null || v === "") return []; return String(v).split(/\s*,\s*/).filter(Boolean); } function parseFM(text: string): { meta: Record; body: string } { const lines = text.split("\n"); if (lines[0]?.trim() !== "---") return { meta: {}, body: text }; const end = lines.indexOf("---", 1); if (end < 0) return { meta: {}, body: text }; const meta: Record = {}; for (let i = 1; i < end; i++) { const m = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(lines[i] ?? ""); if (m) meta[m[1]] = m[2].trim(); } return { meta, body: lines.slice(end + 1).join("\n") }; } /** 序列化 front-matter(与知识库既有风格一致:逗号后带空格;name/summary 固定在前)。 */ function serializeFM(meta: Record, body: string): string { const norm = (v: string) => String(v).replace(/\s*,\s*/g, ", ").trim(); const order = ["name", "summary", "tags", "related", "status", "updated"]; const lines = ["---"]; for (const f of order) { const v = meta[f]; if (v !== undefined && v !== "") lines.push(`${f}: ${norm(v)}`); } for (const k of Object.keys(meta)) { const v = meta[k]; if (v !== undefined && v !== "" && !order.includes(k)) lines.push(`${k}: ${String(v).trim()}`); } lines.push("---", ""); return lines.join("\n") + String(body || "").trim() + "\n"; } /** categories.yml 元数据叠加层解析(兼容 DSH 侧写入的固定 2 空格缩进格式)。 */ function parseOverlay(text: string): Record { const cats: Record = {}; let cur: string | null = null; for (const line of String(text).split("\n")) { const t = line.trimEnd(); if (!t.trim() || /^\s*#/.test(t)) continue; const cm = /^ ([^:#]+):\s*$/.exec(t); if (cm) { cur = cm[1]!.trim(); cats[cur] = cats[cur] || {}; continue; } if (!cur) continue; const km = /^ ([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(t); if (!km) continue; const key = km[1]!; const raw = km[2]!.trim(); if (key === "order" && /^\d+$/.test(raw)) cats[cur]!.order = Number(raw); else if (key === "enabled" && (raw === "true" || raw === "false")) cats[cur]!.enabled = raw === "true"; else (cats[cur] as Record)[key] = raw; } return cats; } function serializeOverlay(overlay: Record): string { const lines = [ "# 分类元数据叠加层(可选):目录相对路径 → {title, description, order, enabled}", "# 目录树是分类唯一事实来源;此处仅覆盖展示与生效设置。enabled: false = 停用该分类(含子树)。", "categories:", ]; const paths = Object.keys(overlay || {}).sort((a, b) => a.localeCompare(b)); if (!paths.length) return lines.join("\n") + "\n"; for (const p of paths) { const o = overlay[p] || {}; lines.push(` ${p}:`); if (o.title != null && String(o.title) !== "") lines.push(` title: ${o.title}`); if (o.description != null && String(o.description) !== "") lines.push(` description: ${String(o.description).replace(/\n/g, " ")}`); if (o.order != null) lines.push(` order: ${o.order}`); if (o.enabled === false) lines.push(" enabled: false"); } return lines.join("\n") + "\n"; } function loadOverlay(dataDir: string): Record { try { return parseOverlay(fs.readFileSync(path.join(dataDir, "categories.yml"), "utf8")); } catch { return {}; } } function saveOverlay(dataDir: string, overlay: Record): void { fs.writeFileSync(path.join(dataDir, "categories.yml"), serializeOverlay(overlay), "utf8"); } interface Item { name: string; fmName: string | undefined; meta: Record; body: string; file: string; categoryPath: string } interface Category { path: string; title: string; description: string; order: number | undefined; enabled: boolean; directCount: number; totalCount: number; hasOverlay: boolean } interface Registry { categories: Category[]; catMap: Map; items: Item[]; overlay: Record; rootItems: Item[]; } function scanTreeSync(dataDir: string): { cats: Map; rootItems: Item[] } { const cats = new Map(); const rootItems: Item[] = []; function walk(abs: string, rel: string): void { let entries: fs.Dirent[]; try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; } const items: Item[] = []; for (const e of entries) { if (e.name.startsWith(".")) continue; const full = path.join(abs, e.name); if (e.isDirectory()) { walk(full, rel ? `${rel}/${e.name}` : e.name); } else if (e.isFile() && e.name.endsWith(".md")) { if (rel === "" && e.name === "README.md") continue; let text: string; try { text = fs.readFileSync(full, "utf8"); } catch { continue; } const { meta, body } = parseFM(text); const nm = meta.name && String(meta.name).trim() ? String(meta.name).trim() : e.name.replace(/\.md$/, ""); items.push({ name: nm, fmName: meta.name, meta, body, file: rel ? `${rel}/${e.name}` : e.name, categoryPath: "" }); } } if (rel === "") rootItems.push(...items); else cats.set(rel, { abs, items }); } walk(dataDir, ""); return { cats, rootItems }; } /** 组装注册表:目录 + 元数据叠加层 → 分类列表(排序)与条目列表。 */ function buildRegistry(dataDir: string): Registry { ensureDir(dataDir); const overlay = loadOverlay(dataDir); const { cats, rootItems } = scanTreeSync(dataDir); const catMap = new Map(); for (const [p, c] of cats) { const o = overlay[p] || {}; let total = c.items.length; for (const [pp, cc] of cats) if (p !== pp && pp.startsWith(p + "/")) total += cc.items.length; catMap.set(p, { path: p, title: o.title || path.basename(p), description: o.description || "", order: o.order, enabled: o.enabled !== false, directCount: c.items.length, totalCount: total, hasOverlay: Boolean(overlay[p]), }); } const categories = [...catMap.values()].sort( (a, b) => (a.order ?? 1e9) - (b.order ?? 1e9) || a.path.localeCompare(b.path), ); const items: Item[] = []; for (const [p, c] of cats) for (const it of c.items) items.push({ ...it, categoryPath: p }); for (const it of rootItems) items.push({ ...it, categoryPath: "" }); return { categories, catMap, items, overlay, rootItems }; } function subtreeOf(catPath: string, p: string): boolean { return p === catPath || p.startsWith(catPath + "/"); } /** 分类是否已停用(自身或任一祖先 enabled:false)。 */ function isCategoryDisabled(catMap: Map, catPath: string): boolean { if (!catPath) return false; let cur = catPath; while (cur) { const c = catMap.get(cur); if (c && c.enabled === false) return true; const i = cur.lastIndexOf("/"); cur = i === -1 ? "" : cur.slice(0, i); } return false; } /** 待补池(进程内存,绝不落盘;按会话隔离,重启即清空)——与 dsh-kb 一致。 */ const pendingPools = new Map>(); /** 会话隔离 key:优先按 sessionId,回退按知识库目录(不同知识库的待补池互不干扰)。 */ function sessionKey(ctx: { sessionManager?: { getSessionId?: () => string | null | undefined } } | undefined, dataDir: string): string { try { const id = ctx?.sessionManager?.getSessionId?.(); if (id) return `session:${dataDir}:${id}`; } catch { /* ignore */ } return `global:${dataDir}`; } function poolFor(key: string): Map { let pool = pendingPools.get(key); if (!pool) { pool = new Map(); pendingPools.set(key, pool); } return pool; } function addPending(pool: Map, kw: string, kind = "unknown", source = "manual", note = ""): { ok: boolean; id?: string; deduped?: boolean; error?: string } { const k = String(kw || "").trim(); if (!k) return { ok: false, error: "keyword 为空" }; const dedupKey = k + "\u0000" + kind; const existing = pool.get(dedupKey); const now = Date.now(); if (existing) { existing.ts = now; if (note) existing.note = note; return { ok: true, deduped: true, id: existing.id }; } const id = `${pool.size + 1}_${kind}`; pool.set(dedupKey, { id, keyword: k, kind, source, note: String(note || ""), ts: now }); return { ok: true, id }; } function listPending(pool: Map) { return [...pool.values()].sort((a, b) => a.ts - b.ts); } function removePending(pool: Map, id: string): { ok: boolean; error?: string } { for (const [key, v] of pool) { if (v.id === id) { pool.delete(key); return { ok: true }; } } return { ok: false, error: `待补条目不存在: ${id}` }; } /** 知识目录骨架:确保目录存在。 */ function ensureDir(dataDir: string): void { try { fs.mkdirSync(dataDir, { recursive: true }); } catch { /* 已存在 */ } } const QTY_SUFFIX = ["总额", "金额", "总量", "数量", "次数", "个数", "条数", "额度", "金额数", "数"]; function searchStrength(raw: string, hay: string): number { const q = String(raw || "").trim().toLowerCase(); if (!q) return 0; const h = String(hay || "").toLowerCase(); const tokens = q.split(/\s+/).filter(Boolean); if (h.includes(q)) return 2; if (tokens.some((t) => t && h.includes(t))) return 2; let weak = 0; for (const t of tokens) { for (const suf of QTY_SUFFIX) { if (t.length > suf.length && t.endsWith(suf)) { const stem = t.slice(0, t.length - suf.length); if (stem && h.includes(stem)) weak = 1; } } } return weak; } function itemHay(it: Item, catMap: Map): string { const m = it.meta; const cat = catMap.get(it.categoryPath); const catMeta = cat ? [cat.path, cat.title, cat.description].join(" ") : it.categoryPath; return [m.name, m.summary, ...listVal(m.tags), m.related, catMeta].join(" ") + "\n" + String(it.body || ""); } const ITEM_NAME_RE = /^[\w\u4e00-\u9fa5.\- ]+$/ const SEG_RE = /^[\w\u4e00-\u9fa5-]+$/ function checkPathArg(p: string): string | null { const s = String(p || "").trim(); if (!s) return "分类路径不能为空"; for (const seg of s.split("/")) { if (!SEG_RE.test(seg) || seg === "." || seg === "..") { return `分类路径段「${seg}」非法(仅允许中文/字母/数字/下划线/连字符,且不含点与空格)`; } } return null; } function itemLine(it: Item, off = false): string { const m = it.meta; const cat = it.categoryPath ? `|${it.categoryPath}` : ""; return `- ${m.name}${cat}|${m.summary || ""}|tags:${listVal(m.tags).join(",")}|file:${it.file}${off ? "|⛔已停用" : ""}`; } /** 渲染分类树(缩进)。includeDisabled=false 时只列已启用分类。 */ function renderTree(categories: Category[], includeDisabled = false): string[] { const lines: string[] = []; const visible = categories.filter((c) => includeDisabled || c.enabled); for (const c of visible.filter((cc) => !cc.path.includes("/"))) appendNode(c, 0); function appendNode(c: Category, depth: number): void { const indent = " ".repeat(depth); const dis = c.enabled ? "" : "|⛔已停用"; const agg = c.totalCount !== c.directCount ? `(直接 ${c.directCount})` : ""; lines.push(`${indent}- ${c.title} (${c.path})|${c.description || ""}|${c.totalCount} 条${agg}${dis}`); for (const child of visible.filter((cc) => cc.path.startsWith(c.path + "/") && !cc.path.slice(c.path.length + 1).includes("/"))) { appendNode(child, depth + 1); } } return lines; } // ── 工具注册───────────────────────────────────────────────────────── function registerTools(pi: ExtensionAPI, dataDir: string, autoList: string[]): void { const ok = (text: string) => ({ content: [{ type: "text" as const, text }], details: {} as Record }); const maxGetChars = 40000; const reg = () => buildRegistry(dataDir); const isAuto = (catPath: string) => autoList.some((p) => p && (catPath === p || catPath.startsWith(p + "/"))); const needApproval = (catPath: string): string | null => { if (isAuto(catPath)) return null; return "拒绝执行:该分类不在自动记录白名单(autoApproveCategories / PI_KB_AUTO_APPROVE_CATEGORIES)中,创建/更新前必须先向用户展示拟改动内容并获得明确同意,同意后以 user_approved: true 重新调用。"; }; const KB_GUIDELINES: string[] = [ "通用知识库(~/.agents/kb,可用 PI_KB_DATA_DIR 覆盖)是分类/知识点/踩坑经验的权威来源:目录即分类(多级、可启用/停用),每个知识点一个 .md 文件(front-matter:name/summary/tags + 正文)。", "涉及知识检索/写入的第一步:先 kb_categories list / kb_list 获取分类树与条目索引,再决定下一步——不要凭印象直接搜或写。", "标准链路:kb_list(分类树/子树条目)→ kb_search(关键词,支持分类子树/标签过滤,匹配正文)→ kb_get(读单个条目完整正文)。", "kb_search/kb_get 未命中会自动留痕到本会话待补池(kb_pending list 查看);任务收尾经用户同意后 kb_create 补录。", "写入默认需 user_approved: true(白名单分类免确认);停用分类(categories.yml enabled: false)拒绝写入;生效开关用 kb_categories update。", ]; // kb_list const ListParams = Type.Object({ category: Type.Optional(Type.String({ description: "分类路径(如 data/biz-caliber);省略=列出全部分类树" })), include_disabled: Type.Optional(Type.Boolean({ description: "是否包含已停用分类/条目(默认 false)" })), }); pi.registerTool({ name: "kb_list", label: "Knowledge Base List", description: "列出通用知识库的分类树或某分类子树下的全部条目(紧凑描述行)。无 category:分类树(title(path):description | 条目数,含子树聚合;空目录标注;停用分类默认隐藏,可用 include_disabled 查看并标注「已停用」)。有 category:该分类子树下全部条目(name/summary/tags/file)。【硬要求】做任何知识相关工作的第一步,必须先调用 kb_categories list / kb_list 获取分类树与索引,再决定下一步。", promptSnippet: "知识库列表:知识工作第一步先 kb_list 看分类树与条目索引", promptGuidelines: KB_GUIDELINES, parameters: ListParams, execute(_id, params, _signal, _onUpdate, ctx) { const r = reg(); const includeDisabled = params.include_disabled === true; if (params.category) { const err = checkPathArg(params.category); if (err) return ok(err); const cat = r.catMap.get(params.category); if (!cat) return ok(`分类「${params.category}」不存在。可用 kb_categories list 查看现有分类树;或 kb_categories add 新建。`); if (!cat.enabled && !includeDisabled) { return ok(`分类「${params.category}」已停用(enabled: false),默认不列出。可用 kb_categories update(enabled: true)恢复,或 include_disabled: true 强制查看。`); } const subs = r.items.filter((it) => it.categoryPath && subtreeOf(params.category!, it.categoryPath)); const lines = [`分类 ${cat.title} (${cat.path})【${subs.length} 条】${cat.enabled ? "" : " ⛔已停用"}:`, ""]; for (const it of subs.sort((a, b) => a.categoryPath.localeCompare(b.categoryPath) || a.name.localeCompare(b.name))) { const off = includeDisabled && isCategoryDisabled(r.catMap, it.categoryPath); lines.push(itemLine(it, off)); } if (subs.length === 0) lines.push("(空分类:目录存在但无条目,可放置 .md 或将子目录并入)"); return ok(lines.join("\n")); } const lines = [ `分类树(共 ${r.categories.length} 个分类${r.rootItems.length ? `,另 ${r.rootItems.length} 条未分类根条目` : ",全部条目已分类"}):`, "", ...renderTree(r.categories, includeDisabled), ]; if (!includeDisabled) { const dc = r.categories.filter((c) => !c.enabled); if (dc.length) lines.push("", `另有 ${dc.length} 个已停用分类未显示(kb_categories list 或 include_disabled: true 可查看)。`); } return ok(lines.join("\n")); }, }); // kb_search const SearchParams = Type.Object({ query: Type.String({ description: "关键词(业务词/条目名/标签/正文片段等,多个词用空格分隔,任一词命中即命中)" }), category: Type.Optional(Type.String({ description: "限定分类子树(如 data 则含 data/biz-caliber 等子分类)" })), tag: Type.Optional(Type.String({ description: "限定标签(条目 tags 精确包含,如 踩坑)" })), include_disabled: Type.Optional(Type.Boolean({ description: "是否包含已停用分类下的条目(默认 false)" })), }); pi.registerTool({ name: "kb_search", label: "Knowledge Base Search", description: "搜索通用知识库:匹配条目的名称/摘要/标签/相关条目/所属分类(含分类标题与描述)以及**正文**;支持词元拆分与量词后缀兜底;强匹配标 ★ 排前。支持 category(分类子树过滤)与 tag(标签过滤)。命中自动附 related 摘要;未命中自动留痕到待补池并附分类树索引。【使用前应先 kb_categories list / kb_list 获取分类树与索引,再用本工具缩小范围】", promptSnippet: "知识库检索:已 kb_list 后,用关键词/子树/标签缩小范围", promptGuidelines: KB_GUIDELINES, parameters: SearchParams, execute(_id, params, _signal, _onUpdate, ctx) { const r = reg(); const raw = String(params.query || "").trim(); if (!raw) return ok("query 不能为空"); let catErr: string | null = null; if (params.category) { catErr = checkPathArg(params.category); if (!catErr && !r.catMap.has(params.category)) catErr = `分类「${params.category}」不存在`; } if (catErr) return ok(catErr); const includeDisabled = params.include_disabled === true; const tagFilter = String(params.tag || "").trim().toLowerCase(); const out: string[] = []; const hits: { it: Item; s: number }[] = []; for (const it of r.items) { if (!it.categoryPath) continue; if (!includeDisabled && isCategoryDisabled(r.catMap, it.categoryPath)) continue; if (params.category && !subtreeOf(params.category, it.categoryPath)) continue; if (tagFilter && !listVal(it.meta.tags).some((t) => t.toLowerCase() === tagFilter)) continue; const s = searchStrength(raw, itemHay(it, r.catMap)); if (s > 0) hits.push({ it, s }); } hits.sort((a, b) => (b.s - a.s) || a.it.categoryPath.localeCompare(b.it.categoryPath) || a.it.name.localeCompare(b.it.name)); const strong = hits.filter((x) => x.s === 2).length; if (hits.length) { out.push(`命中 ${hits.length} 条${strong ? `,其中 ${strong} 个强匹配` : ""}:`); for (const { it, s } of hits) out.push((s === 2 ? "★ " : "· ") + itemLine(it)); } const relatedHits: { from: string; to: Item }[] = []; const hitNames = new Set(hits.map((x) => x.it.name)); for (const { it } of hits) { for (const rn of listVal(it.meta.related)) { if (hitNames.has(rn)) continue; const relItem = r.items.find((x) => x.categoryPath && x.name === rn && (includeDisabled || !isCategoryDisabled(r.catMap, x.categoryPath))); if (relItem) relatedHits.push({ from: it.name, to: relItem }); } } if (relatedHits.length) { out.push("", `相关条目(related,${relatedHits.length}):`); for (const { from, to } of relatedHits) out.push(`- ${to.name}(来自 ${from} 的 related)|${to.meta.summary || ""}`); } if (!out.length) { addPending(poolFor(sessionKey(ctx, dataDir)), raw, "unknown", "search", params.category ? `in ${params.category}` : ""); const lines = [`未命中「${raw}」。已留痕到本会话待补池(kb_pending list 查看)。以下为分类树索引,请按分类浏览(kb_list category=...)找到最可能相关的条目,再 kb_get 读明细:`, ""]; lines.push(...renderTree(r.categories.filter((c) => c.enabled))); if (r.rootItems.length) lines.push("", `未分类根条目 ${r.rootItems.length} 条(kb_list 不带 category 时可见)。`); return ok(lines.join("\n")); } return ok(out.join("\n")); }, }); // kb_get const GetParams = Type.Object({ name: Type.String({ description: "条目名(与文件名一致)" }) }); pi.registerTool({ name: "kb_get", label: "Knowledge Base Get", description: "读取知识库中单个条目的完整正文(按 name 精确匹配,全库唯一)。命中时自动附上 related 相关条目摘要。未找到自动留痕到待补池。条目位于已停用分类时默认视为不可见(提示并可恢复)。", promptSnippet: "知识库明细:按条目名读取单个完整正文", promptGuidelines: KB_GUIDELINES, parameters: GetParams, execute(_id, params, _signal, _onUpdate, ctx) { const name = String(params.name || "").trim(); if (!name) return ok("name 不能为空"); const r = reg(); const item = r.items.find((x) => x.name === name); if (!item) { addPending(poolFor(sessionKey(ctx, dataDir)), name, "unknown", "get"); return ok(`未找到「${name}」。已自动留痕到本会话待补池(kb_pending list 查看),确认后可用 kb_create 补录。`); } if (item.categoryPath && isCategoryDisabled(r.catMap, item.categoryPath)) { return ok(`「${name}」位于已停用分类(${item.categoryPath},enabled: false),默认不可见。可用 kb_categories update(path=${item.categoryPath}, enabled: true)恢复后再读取。`); } let content = String(item.body || "").trim(); const relNames = listVal(item.meta.related); if (relNames.length) { const relItems = relNames .map((rn) => r.items.find((x) => x.categoryPath && x.name === rn && !isCategoryDisabled(r.catMap, x.categoryPath))) .filter((x): x is Item => Boolean(x)); if (relItems.length) { content += "\n\n## 相关条目(related)\n" + relItems.map((x) => `- ${x.name}|${x.meta.summary || ""}(kb_get name=${x.name} 读明细)`).join("\n"); } } if (content.length > maxGetChars) content = content.slice(0, maxGetChars) + "\n…(内容超长截断)"; return ok(`【${item.name}】分类:${item.categoryPath || "(未分类)"}|summary:${item.meta.summary || ""}\n\n${content}`); }, }); // kb_categories const CategoriesParams = Type.Object({ action: Type.Optional(Type.Union([Type.Literal("list"), Type.Literal("add"), Type.Literal("update"), Type.Literal("remove")])), path: Type.Optional(Type.String({ description: "分类路径,如 logs/knowledge-log" })), title: Type.Optional(Type.String({ description: "展示名(缺省=目录名)" })), description: Type.Optional(Type.String({ description: "分类描述(参与检索)" })), order: Type.Optional(Type.Number({ description: "可选排序值(同类层级按 order 升序)" })), enabled: Type.Optional(Type.Boolean({ description: "生效开关:false=停用该分类(含子树),恢复置 true" })), new_path: Type.Optional(Type.String({ description: "action=update 时可选:重命名/移动到的目标分类路径" })), user_approved: Type.Optional(Type.Boolean({ description: "add/update/remove 必填 true" })), }); pi.registerTool({ name: "kb_categories", label: "Knowledge Categories", description: "管理知识库分类(目录即分类,多级)。action=list 免确认;add/update/remove 需 user_approved: true。add:按 path 新建分类目录(父级缺失自动创建,任一级目录即分类);title/description/order 可选(会写入 categories.yml 元数据)。update:改 title/description/order/enabled(生效开关,enabled: false 停用该分类及整棵子树);或 new_path 重命名/移动整棵子树(目录递归移动,冲突拒绝)。remove:仅可删除空分类(无条目且无子分类),非空返回提示。list:完整分类树,含已停用分类(标注「已停用」)与 categories.yml 残影提示。", promptSnippet: "知识库分类管理:list 免确认;增删改需 user_approved", promptGuidelines: KB_GUIDELINES, parameters: CategoriesParams, execute(_id, params, _signal, _onUpdate, _ctx) { const action = params.action || "list"; const r = reg(); if (action === "list") { const lines = [ `分类树(共 ${r.categories.length} 个分类;已停用 ${r.categories.filter((c) => !c.enabled).length} 个):`, "", ...renderTree(r.categories, true), ]; const orphans = Object.keys(r.overlay).filter((p) => !r.catMap.has(p)); if (orphans.length) lines.push("", "categories.yml 残影(目录已不存在):" + orphans.map((p) => ` ${p}`).join("")); const unregistered = r.categories.filter((c) => !c.hasOverlay); if (unregistered.length) lines.push("", `未登记元数据的分类(title 用目录名):${unregistered.map((c) => c.path).join(", ")}`); return ok(lines.join("\n")); } if (params.user_approved !== true) { return ok("拒绝执行:分类增删改属结构性变更,必须先向用户确认,同意后以 user_approved: true 重新调用。"); } const p = String(params.path || "").trim(); const perr = checkPathArg(p); if (perr) return ok(perr); const overlay = r.overlay; if (action === "add") { fs.mkdirSync(path.join(dataDir, p), { recursive: true }); const o = overlay[p] || {}; if (params.title != null) o.title = String(params.title).trim(); if (params.description != null) o.description = String(params.description).trim(); if (params.order != null) o.order = Number(params.order); if (params.enabled != null) o.enabled = params.enabled; if (Object.keys(o).length) overlay[p] = o; saveOverlay(dataDir, overlay); return ok(`已创建分类目录:${path.join(dataDir, p)}(父级缺失已自动创建)${Object.keys(o).length ? ";元数据已写入 categories.yml" : ";未提供元数据,title 缺省=目录名"}`); } if (!r.catMap.has(p)) return ok(`分类「${p}」不存在,无法 update/remove。可先 kb_categories add 创建。`); if (action === "update") { if (params.new_path) { const nerr = checkPathArg(params.new_path); if (nerr) return ok(nerr); if (r.catMap.has(params.new_path)) return ok(`目标分类「${params.new_path}」已存在,重命名/移动被拒绝。`); const newParent = params.new_path.slice(0, params.new_path.lastIndexOf("/")); if (newParent) { if (!r.catMap.has(newParent)) return ok(`目标分类的父级「${newParent}」不存在。请先创建父级分类。`); fs.mkdirSync(path.join(dataDir, newParent), { recursive: true }); } try { fs.renameSync(path.join(dataDir, p), path.join(dataDir, params.new_path)); } catch (e) { return ok(`重命名/移动失败:${(e as Error).message}(原目录保留)。`); } if (overlay[p]) { overlay[params.new_path] = overlay[p]; delete overlay[p]; } saveOverlay(dataDir, overlay); return ok(`已重命名/移动分类:${p} → ${params.new_path}(整棵子树已迁移;元数据同步)。`); } const changed: string[] = []; const o = overlay[p] || {}; if (params.title != null) { o.title = String(params.title).trim(); changed.push("title"); } if (params.description != null) { o.description = String(params.description).trim(); changed.push("description"); } if (params.order != null) { o.order = Number(params.order); changed.push("order"); } if (params.enabled != null) { o.enabled = params.enabled; changed.push(`enabled=${params.enabled}`); } if (!changed.length) return ok("未提供任何要更新的字段(title/description/order/enabled/new_path)。"); overlay[p] = o; saveOverlay(dataDir, overlay); return ok(`已更新分类 ${p}:${changed.join(", ")}。${params.enabled === false ? "该分类(含子树)已停用:检索/列表默认忽略;恢复用 enabled: true。" : ""}`); } if (action === "remove") { const cat = r.catMap.get(p)!; const children = r.categories.filter((c) => c.path.startsWith(p + "/")); if (cat.directCount > 0 || children.length) { const parts: string[] = []; if (cat.directCount > 0) parts.push(`${cat.directCount} 个条目`); if (children.length) parts.push(`${children.length} 个子分类`); return ok(`分类「${p}」非空(${parts.join("、")}),拒绝删除。请先把条目移动/删除、子分类删除后再试(kb_update 支持 category 变更移动条目)。`); } try { fs.rmdirSync(path.join(dataDir, p)); } catch (e) { return ok(`删除失败:${(e as Error).message}(该目录可能含有隐藏文件等)。`); } delete overlay[p]; saveOverlay(dataDir, overlay); return ok(`已删除空分类:${p}。`); } return ok(`未知 action:${action}`); }, }); // kb_validate pi.registerTool({ name: "kb_validate", label: "Knowledge Validate", description: "按编写规范校验整个知识目录:分类树与 categories.yml 元数据一致性(残影/未登记)、条目 front-matter 完整性(name/summary/tags)、命名一致性、全库唯一、空正文、未分类根条目。新增/修改条目或分类后调用,确认符合规范(支持持续维护知识库)。", promptSnippet: "知识库校验:新增/修改后检查目录规范", promptGuidelines: KB_GUIDELINES, parameters: Type.Object({}), execute() { return ok(runValidate(dataDir)); }, }); // kb_pending const PendingParams = Type.Object({ action: Type.Optional(Type.Union([Type.Literal("list"), Type.Literal("add"), Type.Literal("remove")])), keyword: Type.Optional(Type.String({ description: "action=add 时的关键词/条目名" })), note: Type.Optional(Type.String({ description: "action=add 时的备注/上下文" })), id: Type.Optional(Type.String({ description: "action=remove 时条目 id" })), }); pi.registerTool({ name: "kb_pending", label: "Knowledge Pending", description: "管理「待补池」(kb_search/kb_get 未命中自动留痕的条目队列,进程内存、按会话隔离、重启即清空)。action=list(默认)列出全部待补条目;action=add 手动记录一条;action=remove 删除指定条目。补录流程:向用户展示待补条目,征得同意后调用 kb_create(user_approved: true,或白名单分类免确认)写入知识,成功后自动删除对应待补条目。", promptSnippet: "知识库待补池:查看/手动记录未命中的检索", promptGuidelines: KB_GUIDELINES, parameters: PendingParams, execute(_id, params, _signal, _onUpdate, ctx) { const action = params.action || "list"; const pool = poolFor(sessionKey(ctx, dataDir)); if (action === "add") { const res = addPending(pool, String(params.keyword || ""), "unknown", "manual", String(params.note || "")); if (!res.ok) return ok(res.error!); return ok(res.deduped ? `待补池已存在相同条目,已刷新时间戳:${res.id}` : `已记入待补池:${res.id}`); } if (action === "remove") { const res = removePending(pool, String(params.id || "")); return ok(res.ok ? `已删除待补条目:${params.id}` : res.error!); } const pending = listPending(pool); if (!pending.length) return ok("待补池为空。kb_search/kb_get 未命中时会自动留痕(内存、按会话隔离、不写文件);也可用 action=add 手动记录。"); const lines = [`待补池(本会话 ${pending.length} 条,进程内存、重启即清空):`, ""]; for (const p of pending) { const note = p.note ? `|备注:${String(p.note).replace(/\s+/g, " ").slice(0, 120)}` : ""; lines.push(`- ${p.id}|${p.keyword}|${new Date(p.ts).toISOString()}|source:${p.source}${note}`); } lines.push("", "提示:向用户确认后,用 kb_create 补录(user_approved: true,或白名单分类免确认);不需要的用 action=remove 清理。"); return ok(lines.join("\n")); }, }); // kb_create const CreateParams = Type.Object({ name: Type.String({ description: "条目名(与文件名一致,全库唯一)" }), category: Type.String({ description: "目标分类路径(必须已存在,如 logs/knowledge-log)" }), summary: Type.String({ description: "一句话摘要(必填,列表/检索展示)" }), tags: Type.String({ description: "多标签,逗号分隔(必填)" }), body: Type.Optional(Type.String({ description: "文件正文(缺省为占位文本)" })), related: Type.Optional(Type.String({ description: "可选:相关条目名,逗号分隔" })), status: Type.Optional(Type.String({ description: "可选:草稿/已审核/已废弃" })), updated: Type.Optional(Type.String({ description: "可选:更新日期 YYYY-MM-DD" })), from_pending: Type.Optional(Type.String({ description: "可选:待补条目 id,成功后自动删除" })), user_approved: Type.Optional(Type.Boolean({ description: "非白名单分类必填 true" })), }); pi.registerTool({ name: "kb_create", label: "Knowledge Create", description: "新增知识条目:写入 //.md(front-matter + 正文)并自动校验规范。必填:name(与文件名一致、全库唯一)、category(分类路径,必须已存在)、summary(一句话摘要)、tags(多标签);可选:body(正文,缺省占位)、related/status/updated。写入门控:默认需 user_approved: true;分类在 autoApproveCategories 白名单(含前缀匹配)内可免确认自动记录;停用分类拒绝写入。from_pending 可选:传入待补条目 id,成功后自动删除。", promptSnippet: "知识库新增:经用户同意后写入新条目;白名单分类免确认", promptGuidelines: KB_GUIDELINES, parameters: CreateParams, execute(_id, params, _signal, _onUpdate, ctx) { const r = reg(); const name = String(params.name || "").trim(); if (!ITEM_NAME_RE.test(name)) return ok(`条目名「${name}」含非法字符(仅允许中文/字母/数字/空格/._-)。`); if (r.items.some((it) => it.name === name)) return ok(`条目「${name}」已存在,无法重复创建(更新请用 kb_update)。`); const catPath = String(params.category || "").trim(); const cerr = checkPathArg(catPath); if (cerr) return ok(cerr); if (!r.catMap.has(catPath)) return ok(`分类「${catPath}」不存在。请先 kb_categories add 创建该分类,或在文件系统创建目录。`); if (isCategoryDisabled(r.catMap, catPath)) return ok(`分类「${catPath}」已停用(enabled: false),拒绝写入。恢复用 kb_categories update(enabled: true)。`); const gate = needApproval(catPath); if (gate && params.user_approved !== true) return ok(gate); const summary = String(params.summary || "").trim(); const tags = String(params.tags || "").trim(); if (!summary) return ok("缺少必填字段 summary(一句话摘要)。"); if (!tags) return ok("缺少必填字段 tags(多标签,逗号分隔)。"); const meta: Record = { name, summary, tags }; for (const k of ["related", "status", "updated"]) if (params[k] !== undefined && String(params[k]).trim() !== "") meta[k] = String(params[k]).trim(); const body = String(params.body || "").trim() || "(待补充正文)"; const file = path.join(dataDir, catPath, `${name}.md`); fs.writeFileSync(file, serializeFM(meta, body), "utf8"); const check = runValidate(dataDir); let removed = ""; if (params.from_pending) { const rr = removePending(poolFor(sessionKey(ctx, dataDir)), params.from_pending); if (rr.ok) removed = `\n已从本会话待补池删除:${params.from_pending}`; } return ok(`已写入 ${file}${removed}\n\n${check}`); }, }); // kb_update const UpdateParams = Type.Object({ name: Type.String({ description: "待更新条目名(精确)" }), category: Type.Optional(Type.String({ description: "可选:移动到目标分类路径" })), summary: Type.Optional(Type.String({ description: "替换摘要" })), tags: Type.Optional(Type.String({ description: "替换标签(逗号分隔)" })), body: Type.Optional(Type.String({ description: "替换正文" })), related: Type.Optional(Type.String({ description: "替换相关条目(传空字符串可清空)" })), status: Type.Optional(Type.String({ description: "替换状态" })), updated: Type.Optional(Type.String({ description: "替换更新日期" })), user_approved: Type.Optional(Type.Boolean({ description: "非白名单分类必填 true" })), }); pi.registerTool({ name: "kb_update", label: "Knowledge Update", description: "更新已有知识条目(按 name 精确匹配)。只更新传入字段,省略则保留原值。category 变更 = 把条目移动/复制到目标分类目录(目标分类必须已存在且未停用)。写入门控:默认需 user_approved: true;白名单分类可免确认;删减到缺必填字段会被拦。条目不存在时请改用 kb_create。", promptSnippet: "知识库更新:经用户同意后修改已有字段/正文/分类", promptGuidelines: KB_GUIDELINES, parameters: UpdateParams, execute(_id, params, _signal, _onUpdate, _ctx) { const r = reg(); const name = String(params.name || "").trim(); const item = r.items.find((x) => x.name === name); if (!item) return ok(`未找到「${name}」,无法更新。请用 kb_create 新增。`); let targetCat = item.categoryPath; if (params.category !== undefined) { const catPath = String(params.category).trim(); const cerr = checkPathArg(catPath); if (cerr) return ok(cerr); if (!r.catMap.has(catPath)) return ok(`目标分类「${catPath}」不存在。请先 kb_categories add 创建。`); if (isCategoryDisabled(r.catMap, catPath)) return ok(`目标分类「${catPath}」已停用(enabled: false),拒绝写入。`); targetCat = catPath; } const gate = needApproval(targetCat); if (gate && params.user_approved !== true) return ok(gate); const meta = { ...item.meta }; for (const k of ["summary", "tags", "related", "status", "updated"]) { if (params[k] !== undefined) { const v = String(params[k]).trim(); if (v === "") delete meta[k]; else meta[k] = v; } } for (const key of ["summary", "tags"]) { if (meta[key] === undefined || String(meta[key]).trim() === "") return ok(`更新后缺少必填字段 ${key}。请一并提供或勿删减。`); } const oldFile = item.categoryPath ? path.join(dataDir, item.categoryPath, `${name}.md`) : path.join(dataDir, `${name}.md`); const file = path.join(dataDir, targetCat, `${name}.md`); const body = params.body !== undefined ? String(params.body).trim() : item.body; fs.writeFileSync(file, serializeFM(meta, body), "utf8"); if (file !== oldFile) { try { fs.unlinkSync(oldFile); } catch { /* 旧文件可能不存在 */ } } const moved = file !== oldFile ? ` → 已移动到 ${targetCat}/` : ""; const check = runValidate(dataDir); return ok(`已更新 ${item.file}${moved}\n\n${check}`); }, }); function runValidate(dataDir: string): string { const r = reg(); const issues: string[] = []; const hints: string[] = []; const seen = new Set(); for (const c of r.categories) { if (!c.hasOverlay) hints.push(`目录「${c.path}」未在 categories.yml 登记(title 缺省=目录名,可选补登记)`); if (c.totalCount === 0) hints.push(`目录「${c.path}」为空(在建分类?可放 .md 或并入子目录)`); } for (const p of Object.keys(r.overlay)) { if (!r.catMap.has(p)) issues.push(`categories.yml 残影:分类「${p}」目录不存在)`); } for (const it of r.items) { const fileName = String(it.file).split("/").pop()!.replace(/\.md$/, ""); if (it.name !== fileName) issues.push(`${it.file}: front-matter name「${it.name}」与文件名「${fileName}」不一致`); if (seen.has(it.name)) issues.push(`${it.file}: name「${it.name}」重复`); seen.add(it.name); for (const key of ["name", "summary", "tags"]) { if (it.meta[key] === undefined || String(it.meta[key]).trim() === "") issues.push(`${it.file}: 缺少必填字段 ${key}`); } if (!String(it.body || "").trim()) issues.push(`${it.file}: 正文为空`); if (!it.categoryPath) hints.push(`根目录散落条目「${it.file}」未归入分类(建议移动进分类目录)`); } if (!r.categories.length && !r.items.length) return `知识库为空:${dataDir}`; let text: string; if (issues.length) { text = `校验发现 ${issues.length} 个问题:\n` + issues.map((s) => `- ${s}`).join("\n"); if (hints.length) text += "\n\n提示(不阻塞):\n" + hints.map((s) => `- ${s}`).join("\n"); } else { text = `校验通过:${r.categories.length} 个分类(${r.categories.filter((c) => !c.enabled).length} 个已停用)、${r.items.filter((i) => i.categoryPath).length} 条已分类知识 + ${r.items.filter((i) => !i.categoryPath).length} 条未分类,全部符合规范`; if (hints.length) text += "\n\n提示(不阻塞):\n" + hints.map((s) => `- ${s}`).join("\n"); } return text; } } /** * Pi 扩展默认导出:注册 8 个 kb_* 工具 + prompt 引导。 * 数据目录可用环境变量 PI_KB_DATA_DIR 覆盖(默认 ~/.agents/kb); * 自动记录白名单 PI_KB_AUTO_APPROVE_CATEGORIES(逗号分隔分类路径,前缀匹配)。 */ export default function (pi: ExtensionAPI, _ctx?: ExtensionContext): void { const dataDir = process.env.PI_KB_DATA_DIR || DEFAULT_DATA_DIR; const autoList = listVal(process.env.PI_KB_AUTO_APPROVE_CATEGORIES); registerTools(pi, dataDir, autoList); }