handler.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. import fs from "fs";
  2. import os from "os";
  3. import path from "path";
  4. // ---------------------------------------------------------------------------
  5. // Config — openclaw does NOT inject cfg into hook events.
  6. // Read from env vars; fall back to ~/.openclaw/mem0.json for convenience.
  7. // ---------------------------------------------------------------------------
  8. function loadPluginCfg() {
  9. const cfgPath = path.join(os.homedir(), ".openclaw", "mem0.json");
  10. let fileCfg: Record<string, unknown> = {};
  11. try {
  12. if (fs.existsSync(cfgPath)) {
  13. fileCfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
  14. }
  15. } catch {
  16. // ignore malformed file
  17. }
  18. return {
  19. baseUrl:
  20. process.env.MEM0_BASE_URL ||
  21. (fileCfg.baseUrl as string) ||
  22. "http://192.168.0.200:8420",
  23. userId:
  24. process.env.MEM0_USER_ID || (fileCfg.userId as string) || undefined,
  25. recallLimit: Number(
  26. process.env.MEM0_RECALL_LIMIT || fileCfg.recallLimit || 5
  27. ),
  28. captureTrigger: (
  29. process.env.MEM0_CAPTURE_TRIGGER ||
  30. (fileCfg.captureTrigger as string) ||
  31. "always"
  32. ) as "always" | "phrase" | "explicit",
  33. triggerPhrase:
  34. process.env.MEM0_TRIGGER_PHRASE ||
  35. (fileCfg.triggerPhrase as string) ||
  36. "please remember",
  37. autoCapture:
  38. (process.env.MEM0_AUTO_CAPTURE ||
  39. String(fileCfg.autoCapture ?? "true")) !== "false",
  40. autoRecall:
  41. (process.env.MEM0_AUTO_RECALL ||
  42. String(fileCfg.autoRecall ?? "true")) !== "false",
  43. recentKeep: Number(
  44. process.env.MEM0_RECENT_KEEP || fileCfg.recentKeep || 5
  45. ),
  46. // Knowledge-base settings
  47. knowledgeUserId:
  48. process.env.MEM0_KNOWLEDGE_USER_ID ||
  49. (fileCfg.knowledgeUserId as string) ||
  50. "knowledge_base",
  51. rerankThreshold: Number(
  52. process.env.MEM0_RERANK_THRESHOLD || fileCfg.rerankThreshold || 0.002
  53. ),
  54. knowledgeLimit: Number(
  55. process.env.MEM0_KNOWLEDGE_LIMIT || fileCfg.knowledgeLimit || 5
  56. ),
  57. };
  58. }
  59. // ---------------------------------------------------------------------------
  60. // Types
  61. // ---------------------------------------------------------------------------
  62. type HookEvent = {
  63. type: string;
  64. action: string;
  65. sessionKey: string;
  66. timestamp: Date;
  67. messages: string[] | any[];
  68. context: any;
  69. };
  70. interface MemoryResult {
  71. id: string;
  72. memory: string;
  73. score: number;
  74. rerank_score: number;
  75. metadata?: Record<string, any>;
  76. }
  77. interface SearchResponse {
  78. results: MemoryResult[];
  79. }
  80. // ---------------------------------------------------------------------------
  81. // In-memory state — capped to avoid unbounded growth
  82. // ---------------------------------------------------------------------------
  83. const MAX_SESSIONS = 500;
  84. const MAX_TRANSCRIPTS = 1000;
  85. class LRUMap<K, V> extends Map<K, V> {
  86. private readonly maxSize: number;
  87. constructor(maxSize: number) {
  88. super();
  89. this.maxSize = maxSize;
  90. }
  91. set(key: K, value: V): this {
  92. if (this.size >= this.maxSize && !this.has(key)) {
  93. this.delete(this.keys().next().value!);
  94. }
  95. return super.set(key, value);
  96. }
  97. }
  98. const recentBySession = new LRUMap<string, string[]>(MAX_SESSIONS);
  99. const transcriptByMessageId = new LRUMap<string, string>(MAX_TRANSCRIPTS);
  100. // ---------------------------------------------------------------------------
  101. // Helpers
  102. // ---------------------------------------------------------------------------
  103. const LOCAL_STT_URL = "http://192.168.0.200:5005/transcribe";
  104. function getAgentIdFromSessionKey(sessionKey?: string): string | undefined {
  105. if (!sessionKey) return undefined;
  106. const parts = sessionKey.split(":");
  107. if (parts.length >= 2 && parts[0] === "agent") return parts[1];
  108. console.warn("[mem0] unexpected sessionKey format:", sessionKey);
  109. return undefined;
  110. }
  111. function pushRecent(
  112. sessionKey: string,
  113. text: string,
  114. keep: number
  115. ): string[] {
  116. const list = recentBySession.get(sessionKey) || [];
  117. list.push(text);
  118. while (list.length > keep) list.shift();
  119. recentBySession.set(sessionKey, list);
  120. return list;
  121. }
  122. function isMediaPlaceholder(text: string): boolean {
  123. return /<media:[^>]+>/i.test(text);
  124. }
  125. function extractMessageText(context: any): string {
  126. const transcript =
  127. typeof context?.transcript === "string" ? context.transcript.trim() : "";
  128. if (transcript) return transcript;
  129. const candidates = [context?.bodyForAgent, context?.content, context?.body];
  130. for (const candidate of candidates) {
  131. if (typeof candidate === "string") {
  132. const trimmed = candidate.trim();
  133. if (trimmed && !isMediaPlaceholder(trimmed)) return trimmed;
  134. }
  135. }
  136. return "";
  137. }
  138. function getAudioPath(context: any): string | undefined {
  139. if (typeof context?.mediaPath === "string") return context.mediaPath;
  140. if (
  141. Array.isArray(context?.mediaPaths) &&
  142. typeof context.mediaPaths[0] === "string"
  143. ) {
  144. return context.mediaPaths[0];
  145. }
  146. if (typeof context?.media?.path === "string") return context.media.path;
  147. return undefined;
  148. }
  149. async function transcribeAudio(localPath: string): Promise<string> {
  150. const buffer = fs.readFileSync(localPath);
  151. const blob = new Blob([buffer]);
  152. const form = new FormData();
  153. form.append("file", blob, "audio.ogg");
  154. const res = await fetch(LOCAL_STT_URL, { method: "POST", body: form });
  155. if (!res.ok) throw new Error(`STT failed: ${res.status}`);
  156. const data = await res.json();
  157. const text =
  158. typeof data?.text === "string" ? data.text.trim() : "";
  159. if (!text) throw new Error("STT returned empty transcript");
  160. return text;
  161. }
  162. // ---------------------------------------------------------------------------
  163. // mem0 search helpers — each returns null on failure so callers can degrade
  164. // gracefully when one endpoint is down.
  165. // ---------------------------------------------------------------------------
  166. async function mem0SearchMemories(
  167. baseUrl: string,
  168. userId: string,
  169. query: string,
  170. limit: number
  171. ): Promise<MemoryResult[] | null> {
  172. try {
  173. const res = await fetch(`${baseUrl}/memories/search`, {
  174. method: "POST",
  175. headers: { "Content-Type": "application/json" },
  176. body: JSON.stringify({ query, userId }),
  177. });
  178. if (!res.ok) {
  179. console.error(`[mem0-recall] /memories/search returned ${res.status}`);
  180. return null;
  181. }
  182. const data: SearchResponse = await res.json();
  183. return Array.isArray(data?.results) ? data.results.slice(0, limit) : [];
  184. } catch (err) {
  185. console.error("[mem0-recall] /memories/search failed:", err);
  186. return null;
  187. }
  188. }
  189. async function mem0SearchKnowledge(
  190. baseUrl: string,
  191. knowledgeUserId: string,
  192. query: string,
  193. limit: number
  194. ): Promise<MemoryResult[] | null> {
  195. try {
  196. const res = await fetch(`${baseUrl}/knowledge/search`, {
  197. method: "POST",
  198. headers: { "Content-Type": "application/json" },
  199. body: JSON.stringify({ query, userId: knowledgeUserId }),
  200. });
  201. if (!res.ok) {
  202. console.error(`[mem0-recall] /knowledge/search returned ${res.status}`);
  203. return null;
  204. }
  205. const data: SearchResponse = await res.json();
  206. return Array.isArray(data?.results) ? data.results.slice(0, limit) : [];
  207. } catch (err) {
  208. console.error("[mem0-recall] /knowledge/search failed:", err);
  209. return null;
  210. }
  211. }
  212. // ---------------------------------------------------------------------------
  213. // Result filtering & formatting
  214. // ---------------------------------------------------------------------------
  215. function filterByRerank(
  216. results: MemoryResult[],
  217. threshold: number
  218. ): MemoryResult[] {
  219. return results.filter(
  220. (r) => typeof r.rerank_score === "number" && r.rerank_score >= threshold
  221. );
  222. }
  223. function formatKnowledgeCitation(meta: Record<string, any> = {}): string {
  224. const parts: string[] = [];
  225. if (meta.source_file) parts.push(meta.source_file);
  226. if (meta.chapter != null) parts.push(`ch.${meta.chapter}`);
  227. if (meta.page_start != null && meta.page_end != null) {
  228. parts.push(`pp.${meta.page_start}-${meta.page_end}`);
  229. } else if (meta.page_start != null) {
  230. parts.push(`p.${meta.page_start}`);
  231. }
  232. return parts.length > 0 ? `(from: ${parts.join(", ")})` : "";
  233. }
  234. function buildInjectionBlock(
  235. personalResults: MemoryResult[] | null,
  236. knowledgeResults: MemoryResult[] | null,
  237. threshold: number
  238. ): string {
  239. const sections: string[] = [];
  240. // Personal memories
  241. const personal = personalResults
  242. ? filterByRerank(personalResults, threshold)
  243. : null;
  244. if (personal === null) {
  245. // endpoint was down — omit the section entirely, already logged
  246. } else if (personal.length > 0) {
  247. const lines = personal
  248. .map((r) => `- ${r.memory}`)
  249. .join("\n");
  250. sections.push(`[MEMORY - Personal]\n${lines}`);
  251. }
  252. // Knowledge base — sort by rerank_score descending
  253. const knowledge = knowledgeResults
  254. ? filterByRerank(knowledgeResults, threshold).sort(
  255. (a, b) => b.rerank_score - a.rerank_score
  256. )
  257. : null;
  258. if (knowledge === null) {
  259. // endpoint was down — omit the section entirely, already logged
  260. } else if (knowledge.length > 0) {
  261. const lines = knowledge
  262. .map((r) => {
  263. const citation = formatKnowledgeCitation(r.metadata || {});
  264. return citation ? `- ${citation} ${r.memory}` : `- ${r.memory}`;
  265. })
  266. .join("\n");
  267. sections.push(`[MEMORY - Knowledge Base]\n${lines}`);
  268. }
  269. return sections.join("\n\n");
  270. }
  271. // Build a quick hash to deduplicate captures
  272. function simpleHash(text: string): string {
  273. let h = 0;
  274. for (let i = 0; i < text.length; i++) {
  275. h = (Math.imul(31, h) + text.charCodeAt(i)) | 0;
  276. }
  277. return h.toString(36);
  278. }
  279. const recentlyCaptured = new LRUMap<string, number>(200);
  280. const CAPTURE_DEDUP_MS = 60_000;
  281. // ---------------------------------------------------------------------------
  282. // Main handler
  283. // ---------------------------------------------------------------------------
  284. export default async function handler(event: HookEvent) {
  285. console.log(
  286. "[mem0-FIRE]",
  287. JSON.stringify(
  288. {
  289. type: event.type,
  290. action: event.action,
  291. sessionKey: event.sessionKey,
  292. contextKeys: Object.keys(event.context || {}),
  293. content: event.context?.content?.slice(0, 80),
  294. messagesLen: event.messages?.length,
  295. },
  296. null,
  297. 2
  298. )
  299. );
  300. if (event.type !== "message") {
  301. console.log("[mem0-FIRE] bailed: type is not message, got:", event.type);
  302. return;
  303. }
  304. const pluginCfg = loadPluginCfg();
  305. const userId =
  306. getAgentIdFromSessionKey(event.sessionKey) ||
  307. pluginCfg.userId ||
  308. "default";
  309. // ── Audio transcription (runs once per messageId) ────────────────────────
  310. const messageId =
  311. event.context?.messageId || event.context?.metadata?.messageId;
  312. const audioPath = getAudioPath(event.context);
  313. const hasTranscript =
  314. typeof event.context?.transcript === "string" &&
  315. event.context.transcript.trim().length > 0;
  316. if (!hasTranscript && audioPath && fs.existsSync(audioPath)) {
  317. if (messageId && transcriptByMessageId.has(messageId)) {
  318. event.context.transcript = transcriptByMessageId.get(messageId);
  319. } else {
  320. try {
  321. const text = await transcribeAudio(audioPath);
  322. event.context.transcript = text;
  323. if (messageId) transcriptByMessageId.set(messageId, text);
  324. } catch (err) {
  325. console.error("[mem0-stt] failed:", err);
  326. if (
  327. isMediaPlaceholder(
  328. event.context?.bodyForAgent ||
  329. event.context?.content ||
  330. ""
  331. )
  332. ) {
  333. return;
  334. }
  335. }
  336. }
  337. }
  338. // Patch bodyForAgent with transcript for audio messages
  339. const transcriptNow =
  340. typeof event.context?.transcript === "string"
  341. ? event.context.transcript.trim()
  342. : "";
  343. if (transcriptNow && isMediaPlaceholder(event.context?.bodyForAgent || "")) {
  344. event.context.bodyForAgent = transcriptNow;
  345. }
  346. // ── Extract text (shared by both branches) ───────────────────────────────
  347. function resolveText(): string {
  348. let text = extractMessageText(event.context);
  349. if (!text && Array.isArray(event.messages) && event.messages.length > 0) {
  350. const m = event.messages[0];
  351. if (typeof m === "string") text = m.trim();
  352. else if (typeof m?.text === "string") text = m.text.trim();
  353. else if (typeof m?.body === "string") text = m.body.trim();
  354. }
  355. return text;
  356. }
  357. // ── Auto-recall: queries both endpoints in parallel ───────────────────────
  358. const runAutoRecall = async (text: string) => {
  359. if (!pluginCfg.autoRecall) return;
  360. const {
  361. baseUrl,
  362. knowledgeUserId,
  363. recallLimit,
  364. knowledgeLimit,
  365. rerankThreshold,
  366. } = pluginCfg;
  367. console.log("[mem0-auto-recall] query:", text.slice(0, 120));
  368. // Fire both searches in parallel — neither blocks the other
  369. const [personalResults, knowledgeResults] = await Promise.all([
  370. mem0SearchMemories(baseUrl, userId, text, recallLimit),
  371. mem0SearchKnowledge(baseUrl, knowledgeUserId, text, knowledgeLimit),
  372. ]);
  373. console.log("[mem0-auto-recall]", {
  374. userId,
  375. personalCount: personalResults?.length ?? "error",
  376. knowledgeCount: knowledgeResults?.length ?? "error",
  377. threshold: rerankThreshold,
  378. });
  379. const injectionBlock = buildInjectionBlock(
  380. personalResults,
  381. knowledgeResults,
  382. rerankThreshold
  383. );
  384. if (!injectionBlock) return; // nothing passed the threshold from either endpoint
  385. event.context.bodyForAgent = `${text}\n\n${injectionBlock}`;
  386. console.log("[mem0-injected-prompt]\n" + event.context.bodyForAgent);
  387. };
  388. // ── received: capture ────────────────────────────────────────────────────
  389. if (event.action === "received") {
  390. if (!pluginCfg.autoCapture) return;
  391. const text = resolveText();
  392. if (!text) return;
  393. if (isMediaPlaceholder(text) && !transcriptNow) return;
  394. const recent = pushRecent(
  395. event.sessionKey || "global",
  396. text,
  397. pluginCfg.recentKeep
  398. );
  399. const { captureTrigger, triggerPhrase } = pluginCfg;
  400. let shouldCapture = false;
  401. if (captureTrigger === "always") {
  402. shouldCapture = true;
  403. } else if (captureTrigger === "phrase") {
  404. shouldCapture = new RegExp(triggerPhrase, "i").test(text);
  405. } else {
  406. shouldCapture = /please\s+remember/i.test(text);
  407. }
  408. if (!shouldCapture) return;
  409. const captureText = recent.join("\n");
  410. const hash = simpleHash(captureText);
  411. const lastCapture = recentlyCaptured.get(hash);
  412. if (lastCapture && Date.now() - lastCapture < CAPTURE_DEDUP_MS) {
  413. console.log("[mem0-auto-capture] skipped duplicate");
  414. return;
  415. }
  416. recentlyCaptured.set(hash, Date.now());
  417. try {
  418. console.log("[mem0-auto-capture]", {
  419. userId,
  420. captureTrigger,
  421. text: captureText.slice(0, 160),
  422. });
  423. await fetch(`${pluginCfg.baseUrl}/memories`, {
  424. method: "POST",
  425. headers: { "Content-Type": "application/json" },
  426. body: JSON.stringify({ text: captureText, userId }),
  427. });
  428. } catch (err) {
  429. console.error("[mem0-auto-capture] write failed:", err);
  430. }
  431. return;
  432. }
  433. // ── preprocessed: recall ─────────────────────────────────────────────────
  434. if (event.action === "preprocessed") {
  435. const text = resolveText();
  436. if (!text) return;
  437. if (!transcriptNow && isMediaPlaceholder(text)) return;
  438. await runAutoRecall(text);
  439. return;
  440. }
  441. // ── transcribed: recall ──────────────────────────────────────────────────
  442. if (event.action === "transcribed") {
  443. const text = extractMessageText(event.context);
  444. if (!text) return;
  445. await runAutoRecall(text);
  446. }
  447. }