index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. /**
  2. * OpenClaw KnowHub Plugin
  3. *
  4. * Knowledge management integration for OpenClaw agents.
  5. * Provides tools for searching and saving knowledge, with automatic reminders.
  6. */
  7. import { Type } from "@sinclair/typebox";
  8. import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
  9. // ============================================================================
  10. // Types
  11. // ============================================================================
  12. type KnowHubConfig = {
  13. apiUrl: string;
  14. submittedBy: string;
  15. reminderMode: "off" | "minimal" | "normal" | "aggressive";
  16. enableServerExtraction: boolean;
  17. privacyMode: "strict" | "relaxed";
  18. };
  19. type KnowledgeSearchResult = {
  20. id: string;
  21. task: string;
  22. content: string;
  23. types: string[];
  24. eval: {
  25. score: number;
  26. helpful: number;
  27. harmful: number;
  28. confidence: number;
  29. };
  30. quality_score: number;
  31. source?: {
  32. name?: string;
  33. urls?: string[];
  34. };
  35. };
  36. // ============================================================================
  37. // Security
  38. // ============================================================================
  39. const PROMPT_INJECTION_PATTERNS = [
  40. /ignore (all|any|previous|above|prior) instructions/i,
  41. /do not follow (the )?(system|developer)/i,
  42. /system prompt/i,
  43. /<\s*(system|assistant|developer|tool)\b/i,
  44. ];
  45. function looksLikePromptInjection(text: string): boolean {
  46. const normalized = text.replace(/\s+/g, " ").trim();
  47. return PROMPT_INJECTION_PATTERNS.some((pattern) => pattern.test(normalized));
  48. }
  49. export { looksLikePromptInjection };
  50. const PROMPT_ESCAPE_MAP: Record<string, string> = {
  51. "&": "&amp;",
  52. "<": "&lt;",
  53. ">": "&gt;",
  54. '"': "&quot;",
  55. "'": "&#39;",
  56. };
  57. function escapeForPrompt(text: string): string {
  58. return text.replace(/[&<>"']/g, (char) => PROMPT_ESCAPE_MAP[char] ?? char);
  59. }
  60. export { escapeForPrompt };
  61. function sanitizeMessage(msg: unknown, mode: "strict" | "relaxed"): object {
  62. if (!msg || typeof msg !== "object") {
  63. return {};
  64. }
  65. const msgObj = msg as Record<string, unknown>;
  66. const content = msgObj.content;
  67. if (mode === "relaxed") {
  68. return {
  69. role: msgObj.role,
  70. content,
  71. };
  72. }
  73. // strict mode: redact sensitive info
  74. if (typeof content === "string") {
  75. return {
  76. role: msgObj.role,
  77. content: redactSensitiveInfo(content),
  78. };
  79. }
  80. return {
  81. role: msgObj.role,
  82. content,
  83. };
  84. }
  85. function redactSensitiveInfo(text: string): string {
  86. return text
  87. .replace(/\/Users\/[^\/\s]+/g, "/Users/[REDACTED]")
  88. .replace(/\/home\/[^\/\s]+/g, "/home/[REDACTED]")
  89. .replace(/C:\\Users\\[^\\\s]+/g, "C:\\Users\\[REDACTED]")
  90. .replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, "[EMAIL]")
  91. .replace(/\b\d{3}-\d{3}-\d{4}\b/g, "[PHONE]")
  92. .replace(/\+\d{10,}/g, "[PHONE]")
  93. .replace(/sk-[a-zA-Z0-9]{32,}/g, "[API_KEY]")
  94. .replace(/Bearer\s+[a-zA-Z0-9_-]+/g, "Bearer [TOKEN]")
  95. .replace(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, "[IP]");
  96. }
  97. export { redactSensitiveInfo };
  98. // ============================================================================
  99. // Helper Functions
  100. // ============================================================================
  101. function getReminderInterval(mode: string): number {
  102. switch (mode) {
  103. case "minimal":
  104. return 5;
  105. case "normal":
  106. return 3;
  107. case "aggressive":
  108. return 2;
  109. default:
  110. return 3;
  111. }
  112. }
  113. export { getReminderInterval };
  114. function formatKnowledgeResults(results: KnowledgeSearchResult[]): string {
  115. if (results.length === 0) {
  116. return "未找到相关知识";
  117. }
  118. return results
  119. .map((k, idx) => {
  120. // Security: check for prompt injection
  121. if (
  122. looksLikePromptInjection(k.task) ||
  123. looksLikePromptInjection(k.content)
  124. ) {
  125. return null;
  126. }
  127. const typesStr = k.types.join(", ");
  128. const sourceName = k.source?.name ? ` (来源: ${escapeForPrompt(k.source.name)})` : "";
  129. const contentPreview = escapeForPrompt(k.content.substring(0, 150));
  130. 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)})`;
  131. })
  132. .filter(Boolean)
  133. .join("\n\n");
  134. }
  135. // ============================================================================
  136. // Plugin Definition
  137. // ============================================================================
  138. const knowhubPlugin = {
  139. id: "knowhub",
  140. name: "KnowHub",
  141. description: "Knowledge management integration for OpenClaw agents",
  142. kind: "knowledge" as const,
  143. register(api: OpenClawPluginApi) {
  144. const cfg = api.pluginConfig as KnowHubConfig;
  145. // Validate config
  146. if (!cfg.apiUrl) {
  147. throw new Error("knowhub: apiUrl is required");
  148. }
  149. api.logger.info(`knowhub: plugin registered (server: ${cfg.apiUrl})`);
  150. // State for reminder counter
  151. const llmCallCount = new Map<string, number>();
  152. // ========================================================================
  153. // Tools
  154. // ========================================================================
  155. api.registerTool(
  156. {
  157. name: "kb_search",
  158. label: "KnowHub Search",
  159. description:
  160. "搜索 KnowHub 知识库。在遇到复杂任务、不确定用什么工具、多次失败时使用。",
  161. parameters: Type.Object({
  162. query: Type.String({ description: "搜索查询" }),
  163. top_k: Type.Optional(Type.Number({ description: "返回数量 (默认: 5)" })),
  164. min_score: Type.Optional(Type.Number({ description: "最低评分 (默认: 3)" })),
  165. types: Type.Optional(
  166. Type.Array(Type.String(), { description: "知识类型过滤,如 ['tool', 'strategy']" })
  167. ),
  168. }),
  169. async execute(_toolCallId, params) {
  170. const { query, top_k = 5, min_score = 3, types } = params as {
  171. query: string;
  172. top_k?: number;
  173. min_score?: number;
  174. types?: string[];
  175. };
  176. try {
  177. let url = `${cfg.apiUrl}/api/knowledge/search?q=${encodeURIComponent(query)}&top_k=${top_k}&min_score=${min_score}`;
  178. if (types && types.length > 0) {
  179. url += `&types=${types.join(",")}`;
  180. }
  181. const response = await fetch(url);
  182. if (!response.ok) {
  183. return {
  184. content: [
  185. {
  186. type: "text",
  187. text: `搜索失败: ${response.statusText}`,
  188. },
  189. ],
  190. details: { error: response.statusText },
  191. };
  192. }
  193. const data = (await response.json()) as {
  194. results: KnowledgeSearchResult[];
  195. count: number;
  196. };
  197. const formatted = formatKnowledgeResults(data.results);
  198. return {
  199. content: [
  200. {
  201. type: "text",
  202. text: `找到 ${data.count} 条知识:\n\n${formatted}`,
  203. },
  204. ],
  205. details: { count: data.count, results: data.results },
  206. };
  207. } catch (err) {
  208. api.logger.warn(`knowhub: search failed: ${String(err)}`);
  209. return {
  210. content: [
  211. {
  212. type: "text",
  213. text: `搜索失败: ${String(err)}`,
  214. },
  215. ],
  216. details: { error: String(err) },
  217. };
  218. }
  219. },
  220. },
  221. { name: "kb_search" }
  222. );
  223. api.registerTool(
  224. {
  225. name: "kb_save",
  226. label: "KnowHub Save",
  227. description:
  228. "保存知识到 KnowHub。在使用资源后、获得用户反馈后、搜索过程有发现时使用。",
  229. parameters: Type.Object({
  230. task: Type.String({ description: "任务场景描述" }),
  231. content: Type.String({ description: "核心知识内容" }),
  232. types: Type.Array(Type.String(), {
  233. description:
  234. "知识类型,如 ['tool', 'strategy']。可选: user_profile, strategy, tool, usecase, definition, plan",
  235. }),
  236. score: Type.Optional(Type.Number({ description: "评分 1-5 (默认: 3)" })),
  237. source_name: Type.Optional(Type.String({ description: "资源名称" })),
  238. source_urls: Type.Optional(
  239. Type.Array(Type.String(), { description: "参考链接" })
  240. ),
  241. }),
  242. async execute(_toolCallId, params, ctx) {
  243. const {
  244. task,
  245. content,
  246. types,
  247. score = 3,
  248. source_name,
  249. source_urls,
  250. } = params as {
  251. task: string;
  252. content: string;
  253. types: string[];
  254. score?: number;
  255. source_name?: string;
  256. source_urls?: string[];
  257. };
  258. // Validate
  259. if (!task || !content || !types || types.length === 0) {
  260. return {
  261. content: [
  262. {
  263. type: "text",
  264. text: "缺少必需参数: task, content, types",
  265. },
  266. ],
  267. details: { error: "missing_params" },
  268. };
  269. }
  270. if (score < 1 || score > 5) {
  271. return {
  272. content: [
  273. {
  274. type: "text",
  275. text: "评分必须在 1-5 之间",
  276. },
  277. ],
  278. details: { error: "invalid_score" },
  279. };
  280. }
  281. try {
  282. const body = {
  283. task,
  284. content,
  285. types,
  286. score,
  287. scopes: ["org:openclaw"],
  288. owner: `agent:${ctx?.agentId || "unknown"}`,
  289. source: {
  290. name: source_name || "",
  291. category: "exp",
  292. urls: source_urls || [],
  293. agent_id: ctx?.agentId || "unknown",
  294. submitted_by: cfg.submittedBy,
  295. message_id: "",
  296. },
  297. };
  298. const response = await fetch(`${cfg.apiUrl}/api/knowledge`, {
  299. method: "POST",
  300. headers: { "Content-Type": "application/json" },
  301. body: JSON.stringify(body),
  302. });
  303. if (!response.ok) {
  304. return {
  305. content: [
  306. {
  307. type: "text",
  308. text: `保存失败: ${response.statusText}`,
  309. },
  310. ],
  311. details: { error: response.statusText },
  312. };
  313. }
  314. const data = (await response.json()) as { id: string };
  315. return {
  316. content: [
  317. {
  318. type: "text",
  319. text: `✅ 知识已保存 (ID: ${data.id})`,
  320. },
  321. ],
  322. details: { id: data.id },
  323. };
  324. } catch (err) {
  325. api.logger.warn(`knowhub: save failed: ${String(err)}`);
  326. return {
  327. content: [
  328. {
  329. type: "text",
  330. text: `保存失败: ${String(err)}`,
  331. },
  332. ],
  333. details: { error: String(err) },
  334. };
  335. }
  336. },
  337. },
  338. { name: "kb_save" }
  339. );
  340. api.registerTool(
  341. {
  342. name: "kb_update",
  343. label: "KnowHub Update",
  344. description: "更新知识的有效性反馈。使用知识后提供反馈。",
  345. parameters: Type.Object({
  346. knowledge_id: Type.String({ description: "知识 ID" }),
  347. is_helpful: Type.Boolean({ description: "是否有用" }),
  348. feedback: Type.Optional(Type.String({ description: "反馈说明" })),
  349. }),
  350. async execute(_toolCallId, params) {
  351. const { knowledge_id, is_helpful, feedback } = params as {
  352. knowledge_id: string;
  353. is_helpful: boolean;
  354. feedback?: string;
  355. };
  356. try {
  357. const body = is_helpful
  358. ? {
  359. add_helpful_case: {
  360. task: feedback || "使用成功",
  361. outcome: "有效",
  362. timestamp: new Date().toISOString(),
  363. },
  364. }
  365. : {
  366. add_harmful_case: {
  367. task: feedback || "使用失败",
  368. outcome: "无效",
  369. reason: feedback || "未说明",
  370. timestamp: new Date().toISOString(),
  371. },
  372. };
  373. const response = await fetch(
  374. `${cfg.apiUrl}/api/knowledge/${knowledge_id}`,
  375. {
  376. method: "PUT",
  377. headers: { "Content-Type": "application/json" },
  378. body: JSON.stringify(body),
  379. }
  380. );
  381. if (!response.ok) {
  382. return {
  383. content: [
  384. {
  385. type: "text",
  386. text: `更新失败: ${response.statusText}`,
  387. },
  388. ],
  389. details: { error: response.statusText },
  390. };
  391. }
  392. return {
  393. content: [
  394. {
  395. type: "text",
  396. text: "✅ 反馈已提交",
  397. },
  398. ],
  399. details: { success: true },
  400. };
  401. } catch (err) {
  402. api.logger.warn(`knowhub: update failed: ${String(err)}`);
  403. return {
  404. content: [
  405. {
  406. type: "text",
  407. text: `更新失败: ${String(err)}`,
  408. },
  409. ],
  410. details: { error: String(err) },
  411. };
  412. }
  413. },
  414. },
  415. { name: "kb_update" }
  416. );
  417. // ========================================================================
  418. // Lifecycle Hooks
  419. // ========================================================================
  420. // Initial reminder
  421. if (cfg.reminderMode !== "off") {
  422. api.on("before_agent_start", async () => {
  423. return {
  424. prependContext: `
  425. 💡 KnowHub 知识库已启用
  426. 可用工具:
  427. - kb_search: 搜索知识(遇到复杂任务、不确定用什么工具、多次失败时)
  428. - kb_save: 保存知识(使用资源后、获得用户反馈后、搜索过程有发现时)
  429. - kb_update: 反馈知识有效性(使用知识后)
  430. 建议:开始任务前先搜索相关知识,完成后及时保存新发现。
  431. `.trim(),
  432. };
  433. });
  434. }
  435. // Periodic reminder
  436. if (cfg.reminderMode !== "off") {
  437. api.on("before_prompt_build", async (_event, ctx) => {
  438. const sessionKey = ctx.sessionKey ?? "default";
  439. // Increment counter
  440. const count = (llmCallCount.get(sessionKey) ?? 0) + 1;
  441. llmCallCount.set(sessionKey, count);
  442. // Check interval
  443. const interval = getReminderInterval(cfg.reminderMode);
  444. if (count % interval !== 0) {
  445. return;
  446. }
  447. return {
  448. prependContext:
  449. "💡 提醒:如果遇到复杂问题、使用了工具/资源、或获得了用户反馈,记得用 kb_save 保存知识到 KnowHub。",
  450. };
  451. });
  452. }
  453. // Cleanup and optional server extraction
  454. api.on("agent_end", async (event, ctx) => {
  455. const sessionKey = ctx.sessionKey ?? "default";
  456. // Cleanup counter
  457. llmCallCount.delete(sessionKey);
  458. // Optional: server extraction
  459. if (cfg.enableServerExtraction && event.messages && event.messages.length > 0) {
  460. // Async submission, don't block
  461. submitForExtraction(event.messages, ctx, cfg, api).catch((err) => {
  462. api.logger.warn(`knowhub: extraction failed: ${String(err)}`);
  463. });
  464. }
  465. });
  466. // ========================================================================
  467. // Service
  468. // ========================================================================
  469. api.registerService({
  470. id: "knowhub",
  471. start: () => {
  472. api.logger.info(
  473. `knowhub: initialized (server: ${cfg.apiUrl}, reminder: ${cfg.reminderMode})`
  474. );
  475. },
  476. stop: () => {
  477. api.logger.info("knowhub: stopped");
  478. },
  479. });
  480. },
  481. };
  482. // ============================================================================
  483. // Server Extraction
  484. // ============================================================================
  485. async function submitForExtraction(
  486. messages: unknown[],
  487. ctx: { agentId?: string; sessionKey?: string },
  488. cfg: KnowHubConfig,
  489. api: OpenClawPluginApi
  490. ): Promise<void> {
  491. const sanitized = messages.map((msg) => sanitizeMessage(msg, cfg.privacyMode));
  492. const response = await fetch(`${cfg.apiUrl}/api/extract`, {
  493. method: "POST",
  494. headers: { "Content-Type": "application/json" },
  495. body: JSON.stringify({
  496. messages: sanitized,
  497. agent_id: ctx.agentId,
  498. submitted_by: cfg.submittedBy,
  499. session_key: ctx.sessionKey,
  500. }),
  501. });
  502. if (!response.ok) {
  503. throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  504. }
  505. const result = (await response.json()) as { extracted_count: number };
  506. api.logger.info?.(`knowhub: extracted ${result.extracted_count} knowledge items`);
  507. }
  508. export default knowhubPlugin;