handler.ts 22 KB

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