handler.ts 22 KB

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