handler.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. import fs from "fs";
  2. import os from "os";
  3. import path from "path";
  4. import { fileURLToPath } from "url";
  5. const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
  6. const COMMAND_LIST_PATH = path.join(MODULE_DIR, "command-list.json");
  7. const OPENCLAW_CONFIG_PATH = path.join(os.homedir(), ".openclaw", "openclaw.json");
  8. const DEFAULT_COMMANDS = [
  9. "start",
  10. "stop",
  11. "restart",
  12. "status",
  13. "logs",
  14. "log",
  15. "model",
  16. "models",
  17. "agent",
  18. "agents",
  19. "session",
  20. "sessions",
  21. "memory",
  22. "memories",
  23. "hook",
  24. "hooks",
  25. "gateway",
  26. "cron",
  27. "config",
  28. "commands",
  29. "help",
  30. "version",
  31. "update",
  32. "exec",
  33. "run",
  34. "send",
  35. "message",
  36. "search",
  37. "fetch",
  38. "schedule",
  39. "install",
  40. "uninstall",
  41. "refresh",
  42. "save",
  43. "list",
  44. "deploy",
  45. "upload"
  46. ].map((cmd) => cmd.toLowerCase());
  47. const STATIC_COMMANDS = loadCommandList();
  48. const COMMAND_BLACKLIST = new Set(STATIC_COMMANDS);
  49. const ALIAS_BLACKLIST = buildAliasBlacklist();
  50. // ---------------------------------------------------------------------------
  51. // Config — openclaw does NOT inject cfg into hook events.
  52. // Read from env vars; fall back to ~/.openclaw/mem0.json for convenience.
  53. // ---------------------------------------------------------------------------
  54. function loadPluginCfg() {
  55. const cfgPath = path.join(os.homedir(), ".openclaw", "mem0.json");
  56. let fileCfg: Record<string, unknown> = {};
  57. try {
  58. if (fs.existsSync(cfgPath)) {
  59. fileCfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
  60. }
  61. } catch {
  62. // ignore malformed file
  63. }
  64. return {
  65. baseUrl:
  66. process.env.MEM0_BASE_URL ||
  67. (fileCfg.baseUrl as string) ||
  68. "http://192.168.0.200:8420",
  69. userId:
  70. process.env.MEM0_USER_ID || (fileCfg.userId as string) || undefined,
  71. recallLimit: Number(
  72. process.env.MEM0_RECALL_LIMIT || fileCfg.recallLimit || 5
  73. ),
  74. captureTrigger: (
  75. process.env.MEM0_CAPTURE_TRIGGER ||
  76. (fileCfg.captureTrigger as string) ||
  77. "always"
  78. ) as "always" | "phrase" | "explicit",
  79. triggerPhrase:
  80. process.env.MEM0_TRIGGER_PHRASE ||
  81. (fileCfg.triggerPhrase as string) ||
  82. "please remember",
  83. autoCapture:
  84. (process.env.MEM0_AUTO_CAPTURE ||
  85. String(fileCfg.autoCapture ?? "true")) !== "false",
  86. autoRecall:
  87. (process.env.MEM0_AUTO_RECALL ||
  88. String(fileCfg.autoRecall ?? "true")) !== "false",
  89. recentKeep: Number(
  90. process.env.MEM0_RECENT_KEEP || fileCfg.recentKeep || 5
  91. ),
  92. // Knowledge-base settings
  93. knowledgeUserId:
  94. process.env.MEM0_KNOWLEDGE_USER_ID ||
  95. (fileCfg.knowledgeUserId as string) ||
  96. "knowledge_base",
  97. rerankThreshold: Number(
  98. process.env.MEM0_RERANK_THRESHOLD || fileCfg.rerankThreshold || 0.002
  99. ),
  100. knowledgeLimit: Number(
  101. process.env.MEM0_KNOWLEDGE_LIMIT || fileCfg.knowledgeLimit || 5
  102. ),
  103. };
  104. }
  105. function loadCommandList(): string[] {
  106. try {
  107. const raw = fs.readFileSync(COMMAND_LIST_PATH, "utf8");
  108. const parsed = JSON.parse(raw);
  109. if (Array.isArray(parsed)) {
  110. return parsed
  111. .map((item) => String(item || "").trim().toLowerCase())
  112. .filter(Boolean);
  113. }
  114. } catch (err) {
  115. console.warn("[mem0] failed to load command list, using defaults:", err?.message || err);
  116. }
  117. return DEFAULT_COMMANDS;
  118. }
  119. function buildAliasBlacklist(): Set<string> {
  120. const aliases = new Set<string>();
  121. try {
  122. const raw = fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf8");
  123. const config = JSON.parse(raw);
  124. const addAlias = (alias: unknown) => {
  125. if (typeof alias === "string" && alias.trim()) {
  126. aliases.add(alias.trim().toLowerCase());
  127. }
  128. };
  129. const addModelAliases = (models: Record<string, any> | undefined) => {
  130. if (!models) return;
  131. for (const modelConfig of Object.values(models)) {
  132. addAlias(modelConfig?.alias);
  133. }
  134. };
  135. addModelAliases(config?.agents?.defaults?.models);
  136. if (Array.isArray(config?.agents?.list)) {
  137. for (const agent of config.agents.list) {
  138. addModelAliases(agent?.models);
  139. }
  140. }
  141. } catch (err) {
  142. console.warn("[mem0] could not build alias blacklist:", err?.message || err);
  143. }
  144. return aliases;
  145. }
  146. function extractSlashToken(text: string): string | null {
  147. const trimmed = text.trim();
  148. if (!trimmed.startsWith("/")) return null;
  149. const token = trimmed.slice(1).split(/\s+/)[0];
  150. if (!token) return null;
  151. return token.toLowerCase();
  152. }
  153. function isSlashCommand(text: string): boolean {
  154. const token = extractSlashToken(text);
  155. if (!token) return false;
  156. return COMMAND_BLACKLIST.has(token) || ALIAS_BLACKLIST.has(token);
  157. }
  158. // ---------------------------------------------------------------------------
  159. // Types
  160. // ---------------------------------------------------------------------------
  161. type HookEvent = {
  162. type: string;
  163. action: string;
  164. sessionKey: string;
  165. timestamp: Date;
  166. messages: string[] | any[];
  167. context: any;
  168. };
  169. interface MemoryResult {
  170. id: string;
  171. memory: string;
  172. score: number;
  173. rerank_score: number;
  174. metadata?: Record<string, any>;
  175. created_at?: string;
  176. }
  177. interface SearchResponse {
  178. results: MemoryResult[];
  179. }
  180. // ---------------------------------------------------------------------------
  181. // In-memory state — capped to avoid unbounded growth
  182. // ---------------------------------------------------------------------------
  183. const MAX_SESSIONS = 500;
  184. const MAX_TRANSCRIPTS = 1000;
  185. class LRUMap<K, V> extends Map<K, V> {
  186. private readonly maxSize: number;
  187. constructor(maxSize: number) {
  188. super();
  189. this.maxSize = maxSize;
  190. }
  191. set(key: K, value: V): this {
  192. if (this.size >= this.maxSize && !this.has(key)) {
  193. this.delete(this.keys().next().value!);
  194. }
  195. return super.set(key, value);
  196. }
  197. }
  198. const recentBySession = new LRUMap<string, string[]>(MAX_SESSIONS);
  199. const transcriptByMessageId = new LRUMap<string, string>(MAX_TRANSCRIPTS);
  200. // ---------------------------------------------------------------------------
  201. // Helpers
  202. // ---------------------------------------------------------------------------
  203. const LOCAL_STT_URL = "http://192.168.0.200:5005/transcribe";
  204. function getAgentIdFromSessionKey(sessionKey?: string): string | undefined {
  205. if (!sessionKey) return undefined;
  206. const parts = sessionKey.split(":");
  207. if (parts.length >= 2 && parts[0] === "agent") return parts[1];
  208. console.warn("[mem0] unexpected sessionKey format:", sessionKey);
  209. return undefined;
  210. }
  211. function pushRecent(
  212. sessionKey: string,
  213. text: string,
  214. keep: number
  215. ): string[] {
  216. const list = recentBySession.get(sessionKey) || [];
  217. list.push(text);
  218. while (list.length > keep) list.shift();
  219. recentBySession.set(sessionKey, list);
  220. return list;
  221. }
  222. function isMediaPlaceholder(text: string): boolean {
  223. return /<media:[^>]+>/i.test(text);
  224. }
  225. function extractMessageText(context: any): string {
  226. const transcript =
  227. typeof context?.transcript === "string" ? context.transcript.trim() : "";
  228. if (transcript) return transcript;
  229. const candidates = [context?.bodyForAgent, context?.content, context?.body];
  230. for (const candidate of candidates) {
  231. if (typeof candidate === "string") {
  232. const trimmed = candidate.trim();
  233. if (trimmed && !isMediaPlaceholder(trimmed)) return trimmed;
  234. }
  235. }
  236. return "";
  237. }
  238. function getAudioPath(context: any): string | undefined {
  239. if (typeof context?.mediaPath === "string") return context.mediaPath;
  240. if (
  241. Array.isArray(context?.mediaPaths) &&
  242. typeof context.mediaPaths[0] === "string"
  243. ) {
  244. return context.mediaPaths[0];
  245. }
  246. if (typeof context?.media?.path === "string") return context.media.path;
  247. return undefined;
  248. }
  249. async function transcribeAudio(localPath: string): Promise<string> {
  250. const buffer = fs.readFileSync(localPath);
  251. const blob = new Blob([buffer]);
  252. const form = new FormData();
  253. form.append("file", blob, "audio.ogg");
  254. const res = await fetch(LOCAL_STT_URL, { method: "POST", body: form });
  255. if (!res.ok) throw new Error(`STT failed: ${res.status}`);
  256. const data = await res.json();
  257. const text =
  258. typeof data?.text === "string" ? data.text.trim() : "";
  259. if (!text) throw new Error("STT returned empty transcript");
  260. return text;
  261. }
  262. // ---------------------------------------------------------------------------
  263. // mem0 search helpers — each returns null on failure so callers can degrade
  264. // gracefully when one endpoint is down.
  265. // ---------------------------------------------------------------------------
  266. async function mem0SearchMemories(
  267. baseUrl: string,
  268. userId: string,
  269. query: string,
  270. limit: number
  271. ): Promise<MemoryResult[] | null> {
  272. try {
  273. const res = await fetch(`${baseUrl}/memories/search`, {
  274. method: "POST",
  275. headers: { "Content-Type": "application/json" },
  276. body: JSON.stringify({ query, userId }),
  277. });
  278. if (!res.ok) {
  279. console.error(`[mem0-recall] /memories/search returned ${res.status}`);
  280. return null;
  281. }
  282. const data: SearchResponse = await res.json();
  283. return Array.isArray(data?.results) ? data.results.slice(0, limit) : [];
  284. } catch (err) {
  285. console.error("[mem0-recall] /memories/search failed:", err);
  286. return null;
  287. }
  288. }
  289. async function mem0SearchKnowledge(
  290. baseUrl: string,
  291. knowledgeUserId: string,
  292. query: string,
  293. limit: number
  294. ): Promise<MemoryResult[] | null> {
  295. try {
  296. const res = await fetch(`${baseUrl}/knowledge/search`, {
  297. method: "POST",
  298. headers: { "Content-Type": "application/json" },
  299. body: JSON.stringify({ query, userId: knowledgeUserId }),
  300. });
  301. if (!res.ok) {
  302. console.error(`[mem0-recall] /knowledge/search returned ${res.status}`);
  303. return null;
  304. }
  305. const data: SearchResponse = await res.json();
  306. return Array.isArray(data?.results) ? data.results.slice(0, limit) : [];
  307. } catch (err) {
  308. console.error("[mem0-recall] /knowledge/search failed:", err);
  309. return null;
  310. }
  311. }
  312. // ---------------------------------------------------------------------------
  313. // Result filtering & formatting
  314. // ---------------------------------------------------------------------------
  315. function filterByRerank(
  316. results: MemoryResult[],
  317. threshold: number
  318. ): MemoryResult[] {
  319. return results.filter(
  320. (r) => typeof r.rerank_score === "number" && r.rerank_score >= threshold
  321. );
  322. }
  323. function formatKnowledgeCitation(meta: Record<string, any> = {}): string {
  324. const parts: string[] = [];
  325. if (meta.source_file) parts.push(meta.source_file);
  326. if (meta.chapter != null) parts.push(`ch.${meta.chapter}`);
  327. if (meta.page_start != null && meta.page_end != null) {
  328. parts.push(`pp.${meta.page_start}-${meta.page_end}`);
  329. } else if (meta.page_start != null) {
  330. parts.push(`p.${meta.page_start}`);
  331. }
  332. return parts.length > 0 ? `(from: ${parts.join(", ")})` : "";
  333. }
  334. function formatMemoryAge(createdAt?: string): string | null {
  335. if (!createdAt) return null;
  336. const epoch = Date.parse(createdAt);
  337. if (Number.isNaN(epoch)) return null;
  338. const deltaMs = Date.now() - epoch;
  339. if (deltaMs < 0) return null;
  340. const minutes = Math.floor(deltaMs / 60_000);
  341. if (minutes < 1) return "[just now]";
  342. if (minutes < 60) return `[${minutes}m ago]`;
  343. const hours = Math.floor(minutes / 60);
  344. if (hours < 24) return `[${hours}h ago]`;
  345. const days = Math.floor(hours / 24);
  346. if (days < 7) return `[${days}d ago]`;
  347. const weeks = Math.floor(days / 7);
  348. if (weeks < 4) return `[${weeks}w ago]`;
  349. const months = Math.floor(days / 30);
  350. if (months < 12) return `[${months}mo ago]`;
  351. const years = Math.floor(days / 365);
  352. return `[${years}y ago]`;
  353. }
  354. function buildInjectionBlock(
  355. personalResults: MemoryResult[] | null,
  356. knowledgeResults: MemoryResult[] | null,
  357. threshold: number
  358. ): string {
  359. const sections: string[] = [];
  360. // Personal memories
  361. const personal = personalResults
  362. ? filterByRerank(personalResults, threshold)
  363. : null;
  364. if (personal === null) {
  365. // endpoint was down — omit the section entirely, already logged
  366. } else if (personal.length > 0) {
  367. const lines = personal
  368. .map((r) => {
  369. const age = formatMemoryAge(r.created_at);
  370. const prefix = age ? `${age} ` : "";
  371. return `- ${prefix}${r.memory}`;
  372. })
  373. .join("\n");
  374. sections.push(`[MEMORY - Personal]\n${lines}`);
  375. }
  376. // Knowledge base — sort by rerank_score descending
  377. const knowledge = knowledgeResults
  378. ? filterByRerank(knowledgeResults, threshold).sort(
  379. (a, b) => b.rerank_score - a.rerank_score
  380. )
  381. : null;
  382. if (knowledge === null) {
  383. // endpoint was down — omit the section entirely, already logged
  384. } else if (knowledge.length > 0) {
  385. const lines = knowledge
  386. .map((r) => {
  387. const citation = formatKnowledgeCitation(r.metadata || {});
  388. const age = formatMemoryAge(r.created_at);
  389. const prefix = age ? `${age} ` : "";
  390. const body = citation ? `${citation} ${r.memory}` : r.memory;
  391. return `- ${prefix}${body}`;
  392. })
  393. .join("\n");
  394. sections.push(`[MEMORY - Knowledge Base]\n${lines}`);
  395. }
  396. return sections.join("\n\n");
  397. }
  398. // Build a quick hash to deduplicate captures
  399. function simpleHash(text: string): string {
  400. let h = 0;
  401. for (let i = 0; i < text.length; i++) {
  402. h = (Math.imul(31, h) + text.charCodeAt(i)) | 0;
  403. }
  404. return h.toString(36);
  405. }
  406. const recentlyCaptured = new LRUMap<string, number>(200);
  407. const CAPTURE_DEDUP_MS = 60_000;
  408. // ---------------------------------------------------------------------------
  409. // Main handler
  410. // ---------------------------------------------------------------------------
  411. export default async function handler(event: HookEvent) {
  412. console.log(
  413. "[mem0-FIRE]",
  414. JSON.stringify(
  415. {
  416. type: event.type,
  417. action: event.action,
  418. sessionKey: event.sessionKey,
  419. contextKeys: Object.keys(event.context || {}),
  420. content: event.context?.content?.slice(0, 80),
  421. messagesLen: event.messages?.length,
  422. },
  423. null,
  424. 2
  425. )
  426. );
  427. if (event.type !== "message") {
  428. console.log("[mem0-FIRE] bailed: type is not message, got:", event.type);
  429. return;
  430. }
  431. const pluginCfg = loadPluginCfg();
  432. const userId =
  433. getAgentIdFromSessionKey(event.sessionKey) ||
  434. pluginCfg.userId ||
  435. "default";
  436. // ── Audio transcription (runs once per messageId) ────────────────────────
  437. const messageId =
  438. event.context?.messageId || event.context?.metadata?.messageId;
  439. const audioPath = getAudioPath(event.context);
  440. const hasTranscript =
  441. typeof event.context?.transcript === "string" &&
  442. event.context.transcript.trim().length > 0;
  443. if (!hasTranscript && audioPath && fs.existsSync(audioPath)) {
  444. if (messageId && transcriptByMessageId.has(messageId)) {
  445. event.context.transcript = transcriptByMessageId.get(messageId);
  446. } else {
  447. try {
  448. const text = await transcribeAudio(audioPath);
  449. event.context.transcript = text;
  450. if (messageId) transcriptByMessageId.set(messageId, text);
  451. } catch (err) {
  452. console.error("[mem0-stt] failed:", err);
  453. if (
  454. isMediaPlaceholder(
  455. event.context?.bodyForAgent ||
  456. event.context?.content ||
  457. ""
  458. )
  459. ) {
  460. return;
  461. }
  462. }
  463. }
  464. }
  465. // Patch bodyForAgent with transcript for audio messages
  466. const transcriptNow =
  467. typeof event.context?.transcript === "string"
  468. ? event.context.transcript.trim()
  469. : "";
  470. if (transcriptNow && isMediaPlaceholder(event.context?.bodyForAgent || "")) {
  471. event.context.bodyForAgent = transcriptNow;
  472. }
  473. // ── Extract text (shared by both branches) ───────────────────────────────
  474. function resolveText(): string {
  475. let text = extractMessageText(event.context);
  476. if (!text && Array.isArray(event.messages) && event.messages.length > 0) {
  477. const m = event.messages[0];
  478. if (typeof m === "string") text = m.trim();
  479. else if (typeof m?.text === "string") text = m.text.trim();
  480. else if (typeof m?.body === "string") text = m.body.trim();
  481. }
  482. return text.trim();
  483. }
  484. // ── Auto-recall: queries both endpoints in parallel ───────────────────────
  485. const runAutoRecall = async (text: string) => {
  486. if (!pluginCfg.autoRecall) return;
  487. if (!text || isSlashCommand(text)) return;
  488. const {
  489. baseUrl,
  490. knowledgeUserId,
  491. recallLimit,
  492. knowledgeLimit,
  493. rerankThreshold,
  494. } = pluginCfg;
  495. console.log("[mem0-auto-recall] query:", text.slice(0, 120));
  496. // Fire both searches in parallel — neither blocks the other
  497. const [personalResults, knowledgeResults] = await Promise.all([
  498. mem0SearchMemories(baseUrl, userId, text, recallLimit),
  499. mem0SearchKnowledge(baseUrl, knowledgeUserId, text, knowledgeLimit),
  500. ]);
  501. console.log("[mem0-auto-recall]", {
  502. userId,
  503. personalCount: personalResults?.length ?? "error",
  504. knowledgeCount: knowledgeResults?.length ?? "error",
  505. threshold: rerankThreshold,
  506. });
  507. const injectionBlock = buildInjectionBlock(
  508. personalResults,
  509. knowledgeResults,
  510. rerankThreshold
  511. );
  512. if (!injectionBlock) return; // nothing passed the threshold from either endpoint
  513. event.context.bodyForAgent = `${text}\n\n${injectionBlock}`;
  514. console.log("[mem0-injected-prompt]\n" + event.context.bodyForAgent);
  515. };
  516. // ── received: capture ────────────────────────────────────────────────────
  517. if (event.action === "received") {
  518. if (!pluginCfg.autoCapture) return;
  519. const text = resolveText();
  520. if (!text || isSlashCommand(text)) return;
  521. if (isMediaPlaceholder(text) && !transcriptNow) return;
  522. const recent = pushRecent(
  523. event.sessionKey || "global",
  524. text,
  525. pluginCfg.recentKeep
  526. );
  527. const { captureTrigger, triggerPhrase } = pluginCfg;
  528. let shouldCapture = false;
  529. if (captureTrigger === "always") {
  530. shouldCapture = true;
  531. } else if (captureTrigger === "phrase") {
  532. shouldCapture = new RegExp(triggerPhrase, "i").test(text);
  533. } else {
  534. shouldCapture = /please\s+remember/i.test(text);
  535. }
  536. if (!shouldCapture) return;
  537. const captureText = recent.join("\n");
  538. if (!captureText.trim()) return;
  539. const hash = simpleHash(captureText);
  540. const lastCapture = recentlyCaptured.get(hash);
  541. if (lastCapture && Date.now() - lastCapture < CAPTURE_DEDUP_MS) {
  542. console.log("[mem0-auto-capture] skipped duplicate");
  543. return;
  544. }
  545. recentlyCaptured.set(hash, Date.now());
  546. try {
  547. console.log("[mem0-auto-capture]", {
  548. userId,
  549. captureTrigger,
  550. text: captureText.slice(0, 160),
  551. });
  552. const payload = {
  553. user_id: userId,
  554. messages: recent.map((entry) => ({ role: "user", content: entry })),
  555. metadata: {
  556. source: "mem0_hook",
  557. session: event.sessionKey || "unknown",
  558. },
  559. };
  560. await fetch(`${pluginCfg.baseUrl}/memories`, {
  561. method: "POST",
  562. headers: { "Content-Type": "application/json" },
  563. body: JSON.stringify(payload),
  564. });
  565. } catch (err) {
  566. console.error("[mem0-auto-capture] write failed:", err);
  567. }
  568. return;
  569. }
  570. // ── preprocessed: recall ─────────────────────────────────────────────────
  571. if (event.action === "preprocessed") {
  572. const text = resolveText();
  573. if (!text || isSlashCommand(text)) return;
  574. if (!transcriptNow && isMediaPlaceholder(text)) return;
  575. await runAutoRecall(text);
  576. return;
  577. }
  578. // ── transcribed: recall ──────────────────────────────────────────────────
  579. if (event.action === "transcribed") {
  580. const text = extractMessageText(event.context).trim();
  581. if (!text || isSlashCommand(text)) return;
  582. await runAutoRecall(text);
  583. }
  584. }