handler.ts 19 KB

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