index.ts 20 KB

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