handler.ts 22 KB

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