|
|
@@ -0,0 +1,579 @@
|
|
|
+/**
|
|
|
+ * OpenClaw KnowHub Plugin
|
|
|
+ *
|
|
|
+ * Knowledge management integration for OpenClaw agents.
|
|
|
+ * Provides tools for searching and saving knowledge, with automatic reminders.
|
|
|
+ */
|
|
|
+
|
|
|
+import { Type } from "@sinclair/typebox";
|
|
|
+import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// Types
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+type KnowHubConfig = {
|
|
|
+ apiUrl: string;
|
|
|
+ submittedBy: string;
|
|
|
+ reminderMode: "off" | "minimal" | "normal" | "aggressive";
|
|
|
+ enableServerExtraction: boolean;
|
|
|
+ privacyMode: "strict" | "relaxed";
|
|
|
+};
|
|
|
+
|
|
|
+type KnowledgeSearchResult = {
|
|
|
+ id: string;
|
|
|
+ task: string;
|
|
|
+ content: string;
|
|
|
+ types: string[];
|
|
|
+ eval: {
|
|
|
+ score: number;
|
|
|
+ helpful: number;
|
|
|
+ harmful: number;
|
|
|
+ confidence: number;
|
|
|
+ };
|
|
|
+ quality_score: number;
|
|
|
+ source?: {
|
|
|
+ name?: string;
|
|
|
+ urls?: string[];
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// Security
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+const PROMPT_INJECTION_PATTERNS = [
|
|
|
+ /ignore (all|any|previous|above|prior) instructions/i,
|
|
|
+ /do not follow (the )?(system|developer)/i,
|
|
|
+ /system prompt/i,
|
|
|
+ /<\s*(system|assistant|developer|tool)\b/i,
|
|
|
+];
|
|
|
+
|
|
|
+function looksLikePromptInjection(text: string): boolean {
|
|
|
+ const normalized = text.replace(/\s+/g, " ").trim();
|
|
|
+ return PROMPT_INJECTION_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
|
+}
|
|
|
+
|
|
|
+export { looksLikePromptInjection };
|
|
|
+
|
|
|
+const PROMPT_ESCAPE_MAP: Record<string, string> = {
|
|
|
+ "&": "&",
|
|
|
+ "<": "<",
|
|
|
+ ">": ">",
|
|
|
+ '"': """,
|
|
|
+ "'": "'",
|
|
|
+};
|
|
|
+
|
|
|
+function escapeForPrompt(text: string): string {
|
|
|
+ return text.replace(/[&<>"']/g, (char) => PROMPT_ESCAPE_MAP[char] ?? char);
|
|
|
+}
|
|
|
+
|
|
|
+export { escapeForPrompt };
|
|
|
+
|
|
|
+function sanitizeMessage(msg: unknown, mode: "strict" | "relaxed"): object {
|
|
|
+ if (!msg || typeof msg !== "object") {
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+
|
|
|
+ const msgObj = msg as Record<string, unknown>;
|
|
|
+ const content = msgObj.content;
|
|
|
+
|
|
|
+ if (mode === "relaxed") {
|
|
|
+ return {
|
|
|
+ role: msgObj.role,
|
|
|
+ content,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // strict mode: redact sensitive info
|
|
|
+ if (typeof content === "string") {
|
|
|
+ return {
|
|
|
+ role: msgObj.role,
|
|
|
+ content: redactSensitiveInfo(content),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ role: msgObj.role,
|
|
|
+ content,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function redactSensitiveInfo(text: string): string {
|
|
|
+ return text
|
|
|
+ .replace(/\/Users\/[^\/\s]+/g, "/Users/[REDACTED]")
|
|
|
+ .replace(/\/home\/[^\/\s]+/g, "/home/[REDACTED]")
|
|
|
+ .replace(/C:\\Users\\[^\\\s]+/g, "C:\\Users\\[REDACTED]")
|
|
|
+ .replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, "[EMAIL]")
|
|
|
+ .replace(/\b\d{3}-\d{3}-\d{4}\b/g, "[PHONE]")
|
|
|
+ .replace(/\+\d{10,}/g, "[PHONE]")
|
|
|
+ .replace(/sk-[a-zA-Z0-9]{32,}/g, "[API_KEY]")
|
|
|
+ .replace(/Bearer\s+[a-zA-Z0-9_-]+/g, "Bearer [TOKEN]")
|
|
|
+ .replace(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, "[IP]");
|
|
|
+}
|
|
|
+
|
|
|
+export { redactSensitiveInfo };
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// Helper Functions
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+function getReminderInterval(mode: string): number {
|
|
|
+ switch (mode) {
|
|
|
+ case "minimal":
|
|
|
+ return 5;
|
|
|
+ case "normal":
|
|
|
+ return 3;
|
|
|
+ case "aggressive":
|
|
|
+ return 2;
|
|
|
+ default:
|
|
|
+ return 3;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export { getReminderInterval };
|
|
|
+
|
|
|
+function formatKnowledgeResults(results: KnowledgeSearchResult[]): string {
|
|
|
+ if (results.length === 0) {
|
|
|
+ return "未找到相关知识";
|
|
|
+ }
|
|
|
+
|
|
|
+ return results
|
|
|
+ .map((k, idx) => {
|
|
|
+ // Security: check for prompt injection
|
|
|
+ if (
|
|
|
+ looksLikePromptInjection(k.task) ||
|
|
|
+ looksLikePromptInjection(k.content)
|
|
|
+ ) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ const typesStr = k.types.join(", ");
|
|
|
+ const sourceName = k.source?.name ? ` (来源: ${escapeForPrompt(k.source.name)})` : "";
|
|
|
+ const contentPreview = escapeForPrompt(k.content.substring(0, 150));
|
|
|
+
|
|
|
+ return `${idx + 1}. [${escapeForPrompt(k.task)}]${sourceName}\n 类型: ${typesStr}\n 内容: ${contentPreview}${k.content.length > 150 ? "..." : ""}\n 评分: ${k.eval.score}/5 (质量分: ${k.quality_score.toFixed(1)})`;
|
|
|
+ })
|
|
|
+ .filter(Boolean)
|
|
|
+ .join("\n\n");
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// Plugin Definition
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+const knowhubPlugin = {
|
|
|
+ id: "knowhub",
|
|
|
+ name: "KnowHub",
|
|
|
+ description: "Knowledge management integration for OpenClaw agents",
|
|
|
+ kind: "knowledge" as const,
|
|
|
+
|
|
|
+ register(api: OpenClawPluginApi) {
|
|
|
+ const cfg = api.pluginConfig as KnowHubConfig;
|
|
|
+
|
|
|
+ // Validate config
|
|
|
+ if (!cfg.apiUrl) {
|
|
|
+ throw new Error("knowhub: apiUrl is required");
|
|
|
+ }
|
|
|
+
|
|
|
+ api.logger.info(`knowhub: plugin registered (server: ${cfg.apiUrl})`);
|
|
|
+
|
|
|
+ // State for reminder counter
|
|
|
+ const llmCallCount = new Map<string, number>();
|
|
|
+
|
|
|
+ // ========================================================================
|
|
|
+ // Tools
|
|
|
+ // ========================================================================
|
|
|
+
|
|
|
+ api.registerTool(
|
|
|
+ {
|
|
|
+ name: "kb_search",
|
|
|
+ label: "KnowHub Search",
|
|
|
+ description:
|
|
|
+ "搜索 KnowHub 知识库。在遇到复杂任务、不确定用什么工具、多次失败时使用。",
|
|
|
+ parameters: Type.Object({
|
|
|
+ query: Type.String({ description: "搜索查询" }),
|
|
|
+ top_k: Type.Optional(Type.Number({ description: "返回数量 (默认: 5)" })),
|
|
|
+ min_score: Type.Optional(Type.Number({ description: "最低评分 (默认: 3)" })),
|
|
|
+ types: Type.Optional(
|
|
|
+ Type.Array(Type.String(), { description: "知识类型过滤,如 ['tool', 'strategy']" })
|
|
|
+ ),
|
|
|
+ }),
|
|
|
+ async execute(_toolCallId, params) {
|
|
|
+ const { query, top_k = 5, min_score = 3, types } = params as {
|
|
|
+ query: string;
|
|
|
+ top_k?: number;
|
|
|
+ min_score?: number;
|
|
|
+ types?: string[];
|
|
|
+ };
|
|
|
+
|
|
|
+ try {
|
|
|
+ let url = `${cfg.apiUrl}/api/knowledge/search?q=${encodeURIComponent(query)}&top_k=${top_k}&min_score=${min_score}`;
|
|
|
+ if (types && types.length > 0) {
|
|
|
+ url += `&types=${types.join(",")}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ const response = await fetch(url);
|
|
|
+
|
|
|
+ if (!response.ok) {
|
|
|
+ return {
|
|
|
+ content: [
|
|
|
+ {
|
|
|
+ type: "text",
|
|
|
+ text: `搜索失败: ${response.statusText}`,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ details: { error: response.statusText },
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const data = (await response.json()) as {
|
|
|
+ results: KnowledgeSearchResult[];
|
|
|
+ count: number;
|
|
|
+ };
|
|
|
+
|
|
|
+ const formatted = formatKnowledgeResults(data.results);
|
|
|
+
|
|
|
+ return {
|
|
|
+ content: [
|
|
|
+ {
|
|
|
+ type: "text",
|
|
|
+ text: `找到 ${data.count} 条知识:\n\n${formatted}`,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ details: { count: data.count, results: data.results },
|
|
|
+ };
|
|
|
+ } catch (err) {
|
|
|
+ api.logger.warn(`knowhub: search failed: ${String(err)}`);
|
|
|
+ return {
|
|
|
+ content: [
|
|
|
+ {
|
|
|
+ type: "text",
|
|
|
+ text: `搜索失败: ${String(err)}`,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ details: { error: String(err) },
|
|
|
+ };
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ { name: "kb_search" }
|
|
|
+ );
|
|
|
+
|
|
|
+ api.registerTool(
|
|
|
+ {
|
|
|
+ name: "kb_save",
|
|
|
+ label: "KnowHub Save",
|
|
|
+ description:
|
|
|
+ "保存知识到 KnowHub。在使用资源后、获得用户反馈后、搜索过程有发现时使用。",
|
|
|
+ parameters: Type.Object({
|
|
|
+ task: Type.String({ description: "任务场景描述" }),
|
|
|
+ content: Type.String({ description: "核心知识内容" }),
|
|
|
+ types: Type.Array(Type.String(), {
|
|
|
+ description:
|
|
|
+ "知识类型,如 ['tool', 'strategy']。可选: user_profile, strategy, tool, usecase, definition, plan",
|
|
|
+ }),
|
|
|
+ score: Type.Optional(Type.Number({ description: "评分 1-5 (默认: 3)" })),
|
|
|
+ source_name: Type.Optional(Type.String({ description: "资源名称" })),
|
|
|
+ source_urls: Type.Optional(
|
|
|
+ Type.Array(Type.String(), { description: "参考链接" })
|
|
|
+ ),
|
|
|
+ }),
|
|
|
+ async execute(_toolCallId, params, ctx) {
|
|
|
+ const {
|
|
|
+ task,
|
|
|
+ content,
|
|
|
+ types,
|
|
|
+ score = 3,
|
|
|
+ source_name,
|
|
|
+ source_urls,
|
|
|
+ } = params as {
|
|
|
+ task: string;
|
|
|
+ content: string;
|
|
|
+ types: string[];
|
|
|
+ score?: number;
|
|
|
+ source_name?: string;
|
|
|
+ source_urls?: string[];
|
|
|
+ };
|
|
|
+
|
|
|
+ // Validate
|
|
|
+ if (!task || !content || !types || types.length === 0) {
|
|
|
+ return {
|
|
|
+ content: [
|
|
|
+ {
|
|
|
+ type: "text",
|
|
|
+ text: "缺少必需参数: task, content, types",
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ details: { error: "missing_params" },
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (score < 1 || score > 5) {
|
|
|
+ return {
|
|
|
+ content: [
|
|
|
+ {
|
|
|
+ type: "text",
|
|
|
+ text: "评分必须在 1-5 之间",
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ details: { error: "invalid_score" },
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const body = {
|
|
|
+ task,
|
|
|
+ content,
|
|
|
+ types,
|
|
|
+ score,
|
|
|
+ scopes: ["org:openclaw"],
|
|
|
+ owner: `agent:${ctx?.agentId || "unknown"}`,
|
|
|
+ source: {
|
|
|
+ name: source_name || "",
|
|
|
+ category: "exp",
|
|
|
+ urls: source_urls || [],
|
|
|
+ agent_id: ctx?.agentId || "unknown",
|
|
|
+ submitted_by: cfg.submittedBy,
|
|
|
+ message_id: "",
|
|
|
+ },
|
|
|
+ };
|
|
|
+
|
|
|
+ const response = await fetch(`${cfg.apiUrl}/api/knowledge`, {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify(body),
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!response.ok) {
|
|
|
+ return {
|
|
|
+ content: [
|
|
|
+ {
|
|
|
+ type: "text",
|
|
|
+ text: `保存失败: ${response.statusText}`,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ details: { error: response.statusText },
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const data = (await response.json()) as { id: string };
|
|
|
+
|
|
|
+ return {
|
|
|
+ content: [
|
|
|
+ {
|
|
|
+ type: "text",
|
|
|
+ text: `✅ 知识已保存 (ID: ${data.id})`,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ details: { id: data.id },
|
|
|
+ };
|
|
|
+ } catch (err) {
|
|
|
+ api.logger.warn(`knowhub: save failed: ${String(err)}`);
|
|
|
+ return {
|
|
|
+ content: [
|
|
|
+ {
|
|
|
+ type: "text",
|
|
|
+ text: `保存失败: ${String(err)}`,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ details: { error: String(err) },
|
|
|
+ };
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ { name: "kb_save" }
|
|
|
+ );
|
|
|
+
|
|
|
+ api.registerTool(
|
|
|
+ {
|
|
|
+ name: "kb_update",
|
|
|
+ label: "KnowHub Update",
|
|
|
+ description: "更新知识的有效性反馈。使用知识后提供反馈。",
|
|
|
+ parameters: Type.Object({
|
|
|
+ knowledge_id: Type.String({ description: "知识 ID" }),
|
|
|
+ is_helpful: Type.Boolean({ description: "是否有用" }),
|
|
|
+ feedback: Type.Optional(Type.String({ description: "反馈说明" })),
|
|
|
+ }),
|
|
|
+ async execute(_toolCallId, params) {
|
|
|
+ const { knowledge_id, is_helpful, feedback } = params as {
|
|
|
+ knowledge_id: string;
|
|
|
+ is_helpful: boolean;
|
|
|
+ feedback?: string;
|
|
|
+ };
|
|
|
+
|
|
|
+ try {
|
|
|
+ const body = is_helpful
|
|
|
+ ? {
|
|
|
+ add_helpful_case: {
|
|
|
+ task: feedback || "使用成功",
|
|
|
+ outcome: "有效",
|
|
|
+ timestamp: new Date().toISOString(),
|
|
|
+ },
|
|
|
+ }
|
|
|
+ : {
|
|
|
+ add_harmful_case: {
|
|
|
+ task: feedback || "使用失败",
|
|
|
+ outcome: "无效",
|
|
|
+ reason: feedback || "未说明",
|
|
|
+ timestamp: new Date().toISOString(),
|
|
|
+ },
|
|
|
+ };
|
|
|
+
|
|
|
+ const response = await fetch(
|
|
|
+ `${cfg.apiUrl}/api/knowledge/${knowledge_id}`,
|
|
|
+ {
|
|
|
+ method: "PUT",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify(body),
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ if (!response.ok) {
|
|
|
+ return {
|
|
|
+ content: [
|
|
|
+ {
|
|
|
+ type: "text",
|
|
|
+ text: `更新失败: ${response.statusText}`,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ details: { error: response.statusText },
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ content: [
|
|
|
+ {
|
|
|
+ type: "text",
|
|
|
+ text: "✅ 反馈已提交",
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ details: { success: true },
|
|
|
+ };
|
|
|
+ } catch (err) {
|
|
|
+ api.logger.warn(`knowhub: update failed: ${String(err)}`);
|
|
|
+ return {
|
|
|
+ content: [
|
|
|
+ {
|
|
|
+ type: "text",
|
|
|
+ text: `更新失败: ${String(err)}`,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ details: { error: String(err) },
|
|
|
+ };
|
|
|
+ }
|
|
|
+ },
|
|
|
+ },
|
|
|
+ { name: "kb_update" }
|
|
|
+ );
|
|
|
+
|
|
|
+ // ========================================================================
|
|
|
+ // Lifecycle Hooks
|
|
|
+ // ========================================================================
|
|
|
+
|
|
|
+ // Initial reminder
|
|
|
+ if (cfg.reminderMode !== "off") {
|
|
|
+ api.on("before_agent_start", async () => {
|
|
|
+ return {
|
|
|
+ prependContext: `
|
|
|
+💡 KnowHub 知识库已启用
|
|
|
+
|
|
|
+可用工具:
|
|
|
+- kb_search: 搜索知识(遇到复杂任务、不确定用什么工具、多次失败时)
|
|
|
+- kb_save: 保存知识(使用资源后、获得用户反馈后、搜索过程有发现时)
|
|
|
+- kb_update: 反馈知识有效性(使用知识后)
|
|
|
+
|
|
|
+建议:开始任务前先搜索相关知识,完成后及时保存新发现。
|
|
|
+`.trim(),
|
|
|
+ };
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Periodic reminder
|
|
|
+ if (cfg.reminderMode !== "off") {
|
|
|
+ api.on("before_prompt_build", async (_event, ctx) => {
|
|
|
+ const sessionKey = ctx.sessionKey ?? "default";
|
|
|
+
|
|
|
+ // Increment counter
|
|
|
+ const count = (llmCallCount.get(sessionKey) ?? 0) + 1;
|
|
|
+ llmCallCount.set(sessionKey, count);
|
|
|
+
|
|
|
+ // Check interval
|
|
|
+ const interval = getReminderInterval(cfg.reminderMode);
|
|
|
+ if (count % interval !== 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ prependContext:
|
|
|
+ "💡 提醒:如果遇到复杂问题、使用了工具/资源、或获得了用户反馈,记得用 kb_save 保存知识到 KnowHub。",
|
|
|
+ };
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Cleanup and optional server extraction
|
|
|
+ api.on("agent_end", async (event, ctx) => {
|
|
|
+ const sessionKey = ctx.sessionKey ?? "default";
|
|
|
+
|
|
|
+ // Cleanup counter
|
|
|
+ llmCallCount.delete(sessionKey);
|
|
|
+
|
|
|
+ // Optional: server extraction
|
|
|
+ if (cfg.enableServerExtraction && event.messages && event.messages.length > 0) {
|
|
|
+ // Async submission, don't block
|
|
|
+ submitForExtraction(event.messages, ctx, cfg, api).catch((err) => {
|
|
|
+ api.logger.warn(`knowhub: extraction failed: ${String(err)}`);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // ========================================================================
|
|
|
+ // Service
|
|
|
+ // ========================================================================
|
|
|
+
|
|
|
+ api.registerService({
|
|
|
+ id: "knowhub",
|
|
|
+ start: () => {
|
|
|
+ api.logger.info(
|
|
|
+ `knowhub: initialized (server: ${cfg.apiUrl}, reminder: ${cfg.reminderMode})`
|
|
|
+ );
|
|
|
+ },
|
|
|
+ stop: () => {
|
|
|
+ api.logger.info("knowhub: stopped");
|
|
|
+ },
|
|
|
+ });
|
|
|
+ },
|
|
|
+};
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// Server Extraction
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+async function submitForExtraction(
|
|
|
+ messages: unknown[],
|
|
|
+ ctx: { agentId?: string; sessionKey?: string },
|
|
|
+ cfg: KnowHubConfig,
|
|
|
+ api: OpenClawPluginApi
|
|
|
+): Promise<void> {
|
|
|
+ const sanitized = messages.map((msg) => sanitizeMessage(msg, cfg.privacyMode));
|
|
|
+
|
|
|
+ const response = await fetch(`${cfg.apiUrl}/api/extract`, {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify({
|
|
|
+ messages: sanitized,
|
|
|
+ agent_id: ctx.agentId,
|
|
|
+ submitted_by: cfg.submittedBy,
|
|
|
+ session_key: ctx.sessionKey,
|
|
|
+ }),
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!response.ok) {
|
|
|
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ const result = (await response.json()) as { extracted_count: number };
|
|
|
+ api.logger.info?.(`knowhub: extracted ${result.extracted_count} knowledge items`);
|
|
|
+}
|
|
|
+
|
|
|
+export default knowhubPlugin;
|