| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638 |
- import fs from "fs";
- import os from "os";
- import path from "path";
- import { fileURLToPath } from "url";
- const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
- const COMMAND_LIST_PATH = path.join(MODULE_DIR, "command-list.json");
- const OPENCLAW_CONFIG_PATH = path.join(os.homedir(), ".openclaw", "openclaw.json");
- const DEFAULT_COMMANDS = [
- "start",
- "stop",
- "restart",
- "status",
- "logs",
- "log",
- "model",
- "models",
- "agent",
- "agents",
- "session",
- "sessions",
- "memory",
- "memories",
- "hook",
- "hooks",
- "gateway",
- "cron",
- "config",
- "commands",
- "help",
- "version",
- "update",
- "exec",
- "run",
- "send",
- "message",
- "search",
- "fetch",
- "schedule",
- "install",
- "uninstall",
- "refresh",
- "save",
- "list",
- "deploy",
- "upload"
- ].map((cmd) => cmd.toLowerCase());
- const STATIC_COMMANDS = loadCommandList();
- const COMMAND_BLACKLIST = new Set(STATIC_COMMANDS);
- const ALIAS_BLACKLIST = buildAliasBlacklist();
- // ---------------------------------------------------------------------------
- // Config — openclaw does NOT inject cfg into hook events.
- // Read from env vars; fall back to ~/.openclaw/mem0.json for convenience.
- // ---------------------------------------------------------------------------
- function loadPluginCfg() {
- const cfgPath = path.join(os.homedir(), ".openclaw", "mem0.json");
- let fileCfg: Record<string, unknown> = {};
- try {
- if (fs.existsSync(cfgPath)) {
- fileCfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
- }
- } catch {
- // ignore malformed file
- }
- return {
- baseUrl:
- process.env.MEM0_BASE_URL ||
- (fileCfg.baseUrl as string) ||
- "http://192.168.0.200:8420",
- userId:
- process.env.MEM0_USER_ID || (fileCfg.userId as string) || undefined,
- recallLimit: Number(
- process.env.MEM0_RECALL_LIMIT || fileCfg.recallLimit || 5
- ),
- captureTrigger: (
- process.env.MEM0_CAPTURE_TRIGGER ||
- (fileCfg.captureTrigger as string) ||
- "always"
- ) as "always" | "phrase" | "explicit",
- triggerPhrase:
- process.env.MEM0_TRIGGER_PHRASE ||
- (fileCfg.triggerPhrase as string) ||
- "please remember",
- autoCapture:
- (process.env.MEM0_AUTO_CAPTURE ||
- String(fileCfg.autoCapture ?? "true")) !== "false",
- autoRecall:
- (process.env.MEM0_AUTO_RECALL ||
- String(fileCfg.autoRecall ?? "true")) !== "false",
- recentKeep: Number(
- process.env.MEM0_RECENT_KEEP || fileCfg.recentKeep || 5
- ),
- // Knowledge-base settings
- knowledgeUserId:
- process.env.MEM0_KNOWLEDGE_USER_ID ||
- (fileCfg.knowledgeUserId as string) ||
- "knowledge_base",
- rerankThreshold: Number(
- process.env.MEM0_RERANK_THRESHOLD || fileCfg.rerankThreshold || 0.002
- ),
- knowledgeLimit: Number(
- process.env.MEM0_KNOWLEDGE_LIMIT || fileCfg.knowledgeLimit || 5
- ),
- };
- }
- function loadCommandList(): string[] {
- try {
- const raw = fs.readFileSync(COMMAND_LIST_PATH, "utf8");
- const parsed = JSON.parse(raw);
- if (Array.isArray(parsed)) {
- return parsed
- .map((item) => String(item || "").trim().toLowerCase())
- .filter(Boolean);
- }
- } catch (err) {
- console.warn("[mem0] failed to load command list, using defaults:", err?.message || err);
- }
- return DEFAULT_COMMANDS;
- }
- function buildAliasBlacklist(): Set<string> {
- const aliases = new Set<string>();
- try {
- const raw = fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf8");
- const config = JSON.parse(raw);
- const addAlias = (alias: unknown) => {
- if (typeof alias === "string" && alias.trim()) {
- aliases.add(alias.trim().toLowerCase());
- }
- };
- const addModelAliases = (models: Record<string, any> | undefined) => {
- if (!models) return;
- for (const modelConfig of Object.values(models)) {
- addAlias(modelConfig?.alias);
- }
- };
- addModelAliases(config?.agents?.defaults?.models);
- if (Array.isArray(config?.agents?.list)) {
- for (const agent of config.agents.list) {
- addModelAliases(agent?.models);
- }
- }
- } catch (err) {
- console.warn("[mem0] could not build alias blacklist:", err?.message || err);
- }
- return aliases;
- }
- function extractSlashToken(text: string): string | null {
- const trimmed = text.trim();
- if (!trimmed.startsWith("/")) return null;
- const token = trimmed.slice(1).split(/\s+/)[0];
- if (!token) return null;
- return token.toLowerCase();
- }
- function isSlashCommand(text: string): boolean {
- const token = extractSlashToken(text);
- if (!token) return false;
- return COMMAND_BLACKLIST.has(token) || ALIAS_BLACKLIST.has(token);
- }
- // ---------------------------------------------------------------------------
- // Types
- // ---------------------------------------------------------------------------
- type HookEvent = {
- type: string;
- action: string;
- sessionKey: string;
- timestamp: Date;
- messages: string[] | any[];
- context: any;
- };
- interface MemoryResult {
- id: string;
- memory: string;
- score: number;
- rerank_score: number;
- metadata?: Record<string, any>;
- created_at?: string;
- }
- interface SearchResponse {
- results: MemoryResult[];
- }
- // ---------------------------------------------------------------------------
- // In-memory state — capped to avoid unbounded growth
- // ---------------------------------------------------------------------------
- const MAX_SESSIONS = 500;
- const MAX_TRANSCRIPTS = 1000;
- class LRUMap<K, V> extends Map<K, V> {
- private readonly maxSize: number;
- constructor(maxSize: number) {
- super();
- this.maxSize = maxSize;
- }
- set(key: K, value: V): this {
- if (this.size >= this.maxSize && !this.has(key)) {
- this.delete(this.keys().next().value!);
- }
- return super.set(key, value);
- }
- }
- const recentBySession = new LRUMap<string, string[]>(MAX_SESSIONS);
- const transcriptByMessageId = new LRUMap<string, string>(MAX_TRANSCRIPTS);
- // ---------------------------------------------------------------------------
- // Helpers
- // ---------------------------------------------------------------------------
- const LOCAL_STT_URL = "http://192.168.0.200:5005/transcribe";
- function getAgentIdFromSessionKey(sessionKey?: string): string | undefined {
- if (!sessionKey) return undefined;
- const parts = sessionKey.split(":");
- if (parts.length >= 2 && parts[0] === "agent") return parts[1];
- console.warn("[mem0] unexpected sessionKey format:", sessionKey);
- return undefined;
- }
- function pushRecent(
- sessionKey: string,
- text: string,
- keep: number
- ): string[] {
- const list = recentBySession.get(sessionKey) || [];
- list.push(text);
- while (list.length > keep) list.shift();
- recentBySession.set(sessionKey, list);
- return list;
- }
- function isMediaPlaceholder(text: string): boolean {
- return /<media:[^>]+>/i.test(text);
- }
- function extractMessageText(context: any): string {
- const transcript =
- typeof context?.transcript === "string" ? context.transcript.trim() : "";
- if (transcript) return transcript;
- const candidates = [context?.bodyForAgent, context?.content, context?.body];
- for (const candidate of candidates) {
- if (typeof candidate === "string") {
- const trimmed = candidate.trim();
- if (trimmed && !isMediaPlaceholder(trimmed)) return trimmed;
- }
- }
- return "";
- }
- function getAudioPath(context: any): string | undefined {
- if (typeof context?.mediaPath === "string") return context.mediaPath;
- if (
- Array.isArray(context?.mediaPaths) &&
- typeof context.mediaPaths[0] === "string"
- ) {
- return context.mediaPaths[0];
- }
- if (typeof context?.media?.path === "string") return context.media.path;
- return undefined;
- }
- async function transcribeAudio(localPath: string): Promise<string> {
- const buffer = fs.readFileSync(localPath);
- const blob = new Blob([buffer]);
- const form = new FormData();
- form.append("file", blob, "audio.ogg");
- const res = await fetch(LOCAL_STT_URL, { method: "POST", body: form });
- if (!res.ok) throw new Error(`STT failed: ${res.status}`);
- const data = await res.json();
- const text =
- typeof data?.text === "string" ? data.text.trim() : "";
- if (!text) throw new Error("STT returned empty transcript");
- return text;
- }
- // ---------------------------------------------------------------------------
- // mem0 search helpers — each returns null on failure so callers can degrade
- // gracefully when one endpoint is down.
- // ---------------------------------------------------------------------------
- async function mem0SearchMemories(
- baseUrl: string,
- userId: string,
- query: string,
- limit: number
- ): Promise<MemoryResult[] | null> {
- try {
- const res = await fetch(`${baseUrl}/memories/search`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ query, userId }),
- });
- if (!res.ok) {
- console.error(`[mem0-recall] /memories/search returned ${res.status}`);
- return null;
- }
- const data: SearchResponse = await res.json();
- return Array.isArray(data?.results) ? data.results.slice(0, limit) : [];
- } catch (err) {
- console.error("[mem0-recall] /memories/search failed:", err);
- return null;
- }
- }
- async function mem0SearchKnowledge(
- baseUrl: string,
- knowledgeUserId: string,
- query: string,
- limit: number
- ): Promise<MemoryResult[] | null> {
- try {
- const res = await fetch(`${baseUrl}/knowledge/search`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ query, userId: knowledgeUserId }),
- });
- if (!res.ok) {
- console.error(`[mem0-recall] /knowledge/search returned ${res.status}`);
- return null;
- }
- const data: SearchResponse = await res.json();
- return Array.isArray(data?.results) ? data.results.slice(0, limit) : [];
- } catch (err) {
- console.error("[mem0-recall] /knowledge/search failed:", err);
- return null;
- }
- }
- // ---------------------------------------------------------------------------
- // Result filtering & formatting
- // ---------------------------------------------------------------------------
- function filterByRerank(
- results: MemoryResult[],
- threshold: number
- ): MemoryResult[] {
- return results.filter(
- (r) => typeof r.rerank_score === "number" && r.rerank_score >= threshold
- );
- }
- function formatKnowledgeCitation(meta: Record<string, any> = {}): string {
- const parts: string[] = [];
- if (meta.source_file) parts.push(meta.source_file);
- if (meta.chapter != null) parts.push(`ch.${meta.chapter}`);
- if (meta.page_start != null && meta.page_end != null) {
- parts.push(`pp.${meta.page_start}-${meta.page_end}`);
- } else if (meta.page_start != null) {
- parts.push(`p.${meta.page_start}`);
- }
- return parts.length > 0 ? `(from: ${parts.join(", ")})` : "";
- }
- function formatMemoryAge(createdAt?: string): string | null {
- if (!createdAt) return null;
- const epoch = Date.parse(createdAt);
- if (Number.isNaN(epoch)) return null;
- const deltaMs = Date.now() - epoch;
- if (deltaMs < 0) return null;
- const minutes = Math.floor(deltaMs / 60_000);
- if (minutes < 1) return "[just now]";
- if (minutes < 60) return `[${minutes}m ago]`;
- const hours = Math.floor(minutes / 60);
- if (hours < 24) return `[${hours}h ago]`;
- const days = Math.floor(hours / 24);
- if (days < 7) return `[${days}d ago]`;
- const weeks = Math.floor(days / 7);
- if (weeks < 4) return `[${weeks}w ago]`;
- const months = Math.floor(days / 30);
- if (months < 12) return `[${months}mo ago]`;
- const years = Math.floor(days / 365);
- return `[${years}y ago]`;
- }
- function buildInjectionBlock(
- personalResults: MemoryResult[] | null,
- knowledgeResults: MemoryResult[] | null,
- threshold: number
- ): string {
- const sections: string[] = [];
- // Personal memories
- const personal = personalResults
- ? filterByRerank(personalResults, threshold)
- : null;
- if (personal === null) {
- // endpoint was down — omit the section entirely, already logged
- } else if (personal.length > 0) {
- const lines = personal
- .map((r) => {
- const age = formatMemoryAge(r.created_at);
- const prefix = age ? `${age} ` : "";
- return `- ${prefix}${r.memory}`;
- })
- .join("\n");
- sections.push(`[MEMORY - Personal]\n${lines}`);
- }
- // Knowledge base — sort by rerank_score descending
- const knowledge = knowledgeResults
- ? filterByRerank(knowledgeResults, threshold).sort(
- (a, b) => b.rerank_score - a.rerank_score
- )
- : null;
- if (knowledge === null) {
- // endpoint was down — omit the section entirely, already logged
- } else if (knowledge.length > 0) {
- const lines = knowledge
- .map((r) => {
- const citation = formatKnowledgeCitation(r.metadata || {});
- const age = formatMemoryAge(r.created_at);
- const prefix = age ? `${age} ` : "";
- const body = citation ? `${citation} ${r.memory}` : r.memory;
- return `- ${prefix}${body}`;
- })
- .join("\n");
- sections.push(`[MEMORY - Knowledge Base]\n${lines}`);
- }
- return sections.join("\n\n");
- }
- // Build a quick hash to deduplicate captures
- function simpleHash(text: string): string {
- let h = 0;
- for (let i = 0; i < text.length; i++) {
- h = (Math.imul(31, h) + text.charCodeAt(i)) | 0;
- }
- return h.toString(36);
- }
- const recentlyCaptured = new LRUMap<string, number>(200);
- const CAPTURE_DEDUP_MS = 60_000;
- // ---------------------------------------------------------------------------
- // Main handler
- // ---------------------------------------------------------------------------
- export default async function handler(event: HookEvent) {
- console.log(
- "[mem0-FIRE]",
- JSON.stringify(
- {
- type: event.type,
- action: event.action,
- sessionKey: event.sessionKey,
- contextKeys: Object.keys(event.context || {}),
- content: event.context?.content?.slice(0, 80),
- messagesLen: event.messages?.length,
- },
- null,
- 2
- )
- );
- if (event.type !== "message") {
- console.log("[mem0-FIRE] bailed: type is not message, got:", event.type);
- return;
- }
- const pluginCfg = loadPluginCfg();
- const userId =
- getAgentIdFromSessionKey(event.sessionKey) ||
- pluginCfg.userId ||
- "default";
- // ── Audio transcription (runs once per messageId) ────────────────────────
- const messageId =
- event.context?.messageId || event.context?.metadata?.messageId;
- const audioPath = getAudioPath(event.context);
- const hasTranscript =
- typeof event.context?.transcript === "string" &&
- event.context.transcript.trim().length > 0;
- if (!hasTranscript && audioPath && fs.existsSync(audioPath)) {
- if (messageId && transcriptByMessageId.has(messageId)) {
- event.context.transcript = transcriptByMessageId.get(messageId);
- } else {
- try {
- const text = await transcribeAudio(audioPath);
- event.context.transcript = text;
- if (messageId) transcriptByMessageId.set(messageId, text);
- } catch (err) {
- console.error("[mem0-stt] failed:", err);
- if (
- isMediaPlaceholder(
- event.context?.bodyForAgent ||
- event.context?.content ||
- ""
- )
- ) {
- return;
- }
- }
- }
- }
- // Patch bodyForAgent with transcript for audio messages
- const transcriptNow =
- typeof event.context?.transcript === "string"
- ? event.context.transcript.trim()
- : "";
- if (transcriptNow && isMediaPlaceholder(event.context?.bodyForAgent || "")) {
- event.context.bodyForAgent = transcriptNow;
- }
- // ── Extract text (shared by both branches) ───────────────────────────────
- function resolveText(): string {
- let text = extractMessageText(event.context);
- if (!text && Array.isArray(event.messages) && event.messages.length > 0) {
- const m = event.messages[0];
- if (typeof m === "string") text = m.trim();
- else if (typeof m?.text === "string") text = m.text.trim();
- else if (typeof m?.body === "string") text = m.body.trim();
- }
- return text.trim();
- }
- // ── Auto-recall: queries both endpoints in parallel ───────────────────────
- const runAutoRecall = async (text: string) => {
- if (!pluginCfg.autoRecall) return;
- if (!text || isSlashCommand(text)) return;
- const {
- baseUrl,
- knowledgeUserId,
- recallLimit,
- knowledgeLimit,
- rerankThreshold,
- } = pluginCfg;
- console.log("[mem0-auto-recall] query:", text.slice(0, 120));
- // Fire both searches in parallel — neither blocks the other
- const [personalResults, knowledgeResults] = await Promise.all([
- mem0SearchMemories(baseUrl, userId, text, recallLimit),
- mem0SearchKnowledge(baseUrl, knowledgeUserId, text, knowledgeLimit),
- ]);
- console.log("[mem0-auto-recall]", {
- userId,
- personalCount: personalResults?.length ?? "error",
- knowledgeCount: knowledgeResults?.length ?? "error",
- threshold: rerankThreshold,
- });
- const injectionBlock = buildInjectionBlock(
- personalResults,
- knowledgeResults,
- rerankThreshold
- );
- if (!injectionBlock) return; // nothing passed the threshold from either endpoint
- event.context.bodyForAgent = `${text}\n\n${injectionBlock}`;
- console.log("[mem0-injected-prompt]\n" + event.context.bodyForAgent);
- };
- // ── received: capture ────────────────────────────────────────────────────
- if (event.action === "received") {
- if (!pluginCfg.autoCapture) return;
- const text = resolveText();
- if (!text || isSlashCommand(text)) return;
- if (isMediaPlaceholder(text) && !transcriptNow) return;
- const recent = pushRecent(
- event.sessionKey || "global",
- text,
- pluginCfg.recentKeep
- );
- const { captureTrigger, triggerPhrase } = pluginCfg;
- let shouldCapture = false;
- if (captureTrigger === "always") {
- shouldCapture = true;
- } else if (captureTrigger === "phrase") {
- shouldCapture = new RegExp(triggerPhrase, "i").test(text);
- } else {
- shouldCapture = /please\s+remember/i.test(text);
- }
- if (!shouldCapture) return;
- const captureText = recent.join("\n");
- if (!captureText.trim()) return;
- const hash = simpleHash(captureText);
- const lastCapture = recentlyCaptured.get(hash);
- if (lastCapture && Date.now() - lastCapture < CAPTURE_DEDUP_MS) {
- console.log("[mem0-auto-capture] skipped duplicate");
- return;
- }
- recentlyCaptured.set(hash, Date.now());
- try {
- console.log("[mem0-auto-capture]", {
- userId,
- captureTrigger,
- text: captureText.slice(0, 160),
- });
- const payload = {
- user_id: userId,
- messages: recent.map((entry) => ({ role: "user", content: entry })),
- metadata: {
- source: "mem0_hook",
- session: event.sessionKey || "unknown",
- },
- };
- await fetch(`${pluginCfg.baseUrl}/memories`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
- } catch (err) {
- console.error("[mem0-auto-capture] write failed:", err);
- }
- return;
- }
- // ── preprocessed: recall ─────────────────────────────────────────────────
- if (event.action === "preprocessed") {
- const text = resolveText();
- if (!text || isSlashCommand(text)) return;
- if (!transcriptNow && isMediaPlaceholder(text)) return;
- await runAutoRecall(text);
- return;
- }
- // ── transcribed: recall ──────────────────────────────────────────────────
- if (event.action === "transcribed") {
- const text = extractMessageText(event.context).trim();
- if (!text || isSlashCommand(text)) return;
- await runAutoRecall(text);
- }
- }
|