Parcourir la source

messages array with assistant msg.

Lukas Goldschmidt il y a 1 mois
Parent
commit
aee89d626e
5 fichiers modifiés avec 172 ajouts et 9 suppressions
  1. 2 0
      PROJECT.md
  2. 6 0
      README.md
  3. 16 0
      hook/HOOK.md
  4. 40 0
      hook/command-list.json
  5. 108 9
      hook/handler.ts

+ 2 - 0
PROJECT.md

@@ -7,6 +7,8 @@ Provide a standalone hook that keeps OpenClaw agents in sync with Mem0 by captur
 - Auto-capture incoming messages when triggers fire.
 - Auto-recall both personal memories and a dedicated knowledge-user scope before each response.
 - Work with local STT for audio, deduplicate rapid captures, and gracefully degrade when Mem0 is unreachable.
+- If assistant lookup by session key fails, still capture by falling back to the latest assistant turn globally (with warning log).
+- Prefix injected personal memories with human-readable age labels from `created_at` metadata when available.
 - Ship as a repository-enable hook that anyone can install in their OpenClaw gateway.
 
 ## Code layout

+ 6 - 0
README.md

@@ -27,6 +27,7 @@ All options are read from environment variables (prefixed `MEM0_`) or a JSON fil
 | `autoCapture` | `MEM0_AUTO_CAPTURE` | `true` | Enable/disable captures. |
 | `autoRecall` | `MEM0_AUTO_RECALL` | `true` | Enable/disable recalls. |
 | `recentKeep` | `MEM0_RECENT_KEEP` | `5` | How many lines of recent context to bundle for a capture. |
+| `debugCapture` | `MEM0_DEBUG_CAPTURE` | `false` | When `true`, logs full outgoing `messages` payload for capture; otherwise logs only summary counts. |
 
 > Example `~/.openclaw/mem0.json`:
 >
@@ -76,6 +77,11 @@ All options are read from environment variables (prefixed `MEM0_`) or a JSON fil
 - `package.json` + `tsconfig.json` — minimal metadata so other developers can install dependencies and build if needed.
 - `README.md` & `PROJECT.md` — explain intent, configuration, and deployment.
 
+## Recent implementation notes
+- **Assistant lookup fallback:** auto-capture first tries to fetch the previous assistant message by matching `sessionKey` in `/tmp/openclaw-chat.log`. If that fails, it falls back to the latest assistant message globally, logs a warning, and still sends the capture.
+- **Capture payload shape:** auto-capture sends `messages: [...]` to `/memories` in all cases. Minimum payload is one user item; assistant entry is appended when available.
+- **Personal memory age prefix:** injected `[MEMORY - Personal]` lines prepend age from `created_at` when present (for example `[25m ago]` / `[1d ago]`).
+
 ## Next steps
 - Tag releases (e.g., `v1.0.0`) and push to Git so you can `openclaw hooks install` directly from the repo.
 - Automate builds/tests if you plan to extend the hook (e.g., add unit tests around the Mem0 request helpers).

+ 16 - 0
hook/HOOK.md

@@ -0,0 +1,16 @@
+---
+name: mem0-auto-capture
+description: "Auto-capture (please remember + last 2) + auto-recall for mem0"
+metadata:
+  { "openclaw": { "emoji": "🧠", "events": ["message:received", "message:preprocessed", "message:sent"] } }
+---
+
+# mem0-auto-capture
+
+- **Capture**: on `message:received`, only stores when the message contains **"please remember"** (case-insensitive). Captures the **last 2 messages** (previous + current).
+- **Recall**: on `message:preprocessed`, injects top mem0 matches into `bodyForAgent`.
+
+Config flags used:
+- `plugins.entries.openclaw-mem0-python.config.autoCapture`
+- `plugins.entries.openclaw-mem0-python.config.autoRecall`
+- `plugins.entries.openclaw-mem0-python.config.recallLimit`

+ 40 - 0
hook/command-list.json

@@ -0,0 +1,40 @@
+[
+  "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",
+  "status",
+  "refresh",
+  "save",
+  "list",
+  "deploy",
+  "upload"
+]

+ 108 - 9
hook/handler.ts

@@ -2,6 +2,8 @@ import fs from "fs";
 import os from "os";
 import path from "path";
 
+const CHAT_LOG_FILE = "/tmp/openclaw-chat.log";
+
 // ---------------------------------------------------------------------------
 // Config — openclaw does NOT inject cfg into hook events.
 // Read from env vars; fall back to ~/.openclaw/mem0.json for convenience.
@@ -55,6 +57,9 @@ function loadPluginCfg() {
     knowledgeLimit: Number(
       process.env.MEM0_KNOWLEDGE_LIMIT || fileCfg.knowledgeLimit || 5
     ),
+    debugCapture:
+      (process.env.MEM0_DEBUG_CAPTURE ||
+        String(fileCfg.debugCapture ?? "false")) === "true",
   };
 }
 
@@ -160,6 +165,56 @@ function getAudioPath(context: any): string | undefined {
   return undefined;
 }
 
+function readLastAssistantMessage(sessionKey?: string): string | undefined {
+  try {
+    if (!fs.existsSync(CHAT_LOG_FILE)) return undefined;
+    const payload = fs.readFileSync(CHAT_LOG_FILE, "utf8");
+    const lines = payload.split(/\r?\n/).filter(Boolean);
+
+    // First pass: strict session match.
+    for (let i = lines.length - 1; i >= 0; i--) {
+      try {
+        const entry = JSON.parse(lines[i]);
+        if (
+          sessionKey &&
+          entry?.sessionKey &&
+          entry.sessionKey !== sessionKey
+        ) {
+          continue;
+        }
+        const assistantText = entry?.messages?.[1]?.content;
+        if (typeof assistantText === "string" && assistantText.trim()) {
+          return assistantText.trim();
+        }
+      } catch {
+        // skip malformed lines
+      }
+    }
+
+    // Second pass fallback: latest assistant message from any session.
+    if (sessionKey) {
+      for (let i = lines.length - 1; i >= 0; i--) {
+        try {
+          const entry = JSON.parse(lines[i]);
+          const assistantText = entry?.messages?.[1]?.content;
+          if (typeof assistantText === "string" && assistantText.trim()) {
+            console.warn(
+              "[mem0-auto-capture] assistant lookup fallback to global latest",
+              { requestedSessionKey: sessionKey, logSessionKey: entry?.sessionKey }
+            );
+            return assistantText.trim();
+          }
+        } catch {
+          // skip malformed lines
+        }
+      }
+    }
+  } catch {
+    // ignore missing or unreadable log
+  }
+  return undefined;
+}
+
 async function transcribeAudio(localPath: string): Promise<string> {
   const buffer = fs.readFileSync(localPath);
   const blob = new Blob([buffer]);
@@ -250,6 +305,23 @@ function formatKnowledgeCitation(meta: Record<string, any> = {}): string {
   return parts.length > 0 ? `(from: ${parts.join(", ")})` : "";
 }
 
+function formatAge(createdAt?: string): string {
+  if (!createdAt) return "";
+  const created = new Date(createdAt).getTime();
+  if (!Number.isFinite(created)) return "";
+  const deltaMs = Date.now() - created;
+  if (deltaMs < 0) return "just now";
+
+  const minute = 60_000;
+  const hour = 60 * minute;
+  const day = 24 * hour;
+
+  if (deltaMs < minute) return "just now";
+  if (deltaMs < hour) return `${Math.floor(deltaMs / minute)}m ago`;
+  if (deltaMs < day) return `${Math.floor(deltaMs / hour)}h ago`;
+  return `${Math.floor(deltaMs / day)}d ago`;
+}
+
 function buildInjectionBlock(
   personalResults: MemoryResult[] | null,
   knowledgeResults: MemoryResult[] | null,
@@ -266,7 +338,13 @@ function buildInjectionBlock(
     // endpoint was down — omit the section entirely, already logged
   } else if (personal.length > 0) {
     const lines = personal
-      .map((r) => `- ${r.memory}`)
+      .map((r) => {
+        const createdAt =
+          (r as any)?.created_at ||
+          (r.metadata && (r.metadata as any).created_at);
+        const age = formatAge(createdAt);
+        return age ? `- [${age}] ${r.memory}` : `- ${r.memory}`;
+      })
       .join("\n");
     sections.push(`[MEMORY - Personal]\n${lines}`);
   }
@@ -451,8 +529,17 @@ export default async function handler(event: HookEvent) {
     }
     if (!shouldCapture) return;
 
-    const captureText = recent.join("\n");
-    const hash = simpleHash(captureText);
+    const assistantMessage = readLastAssistantMessage(event.sessionKey);
+    const userCaptureText = recent.join("\n");
+
+    const captureMessages: Array<{ role: "user" | "assistant"; content: string }> = [
+      { role: "user", content: userCaptureText },
+    ];
+    if (assistantMessage) {
+      captureMessages.push({ role: "assistant", content: assistantMessage });
+    }
+
+    const hash = simpleHash(JSON.stringify(captureMessages));
     const lastCapture = recentlyCaptured.get(hash);
     if (lastCapture && Date.now() - lastCapture < CAPTURE_DEDUP_MS) {
       console.log("[mem0-auto-capture] skipped duplicate");
@@ -461,15 +548,27 @@ export default async function handler(event: HookEvent) {
     recentlyCaptured.set(hash, Date.now());
 
     try {
-      console.log("[mem0-auto-capture]", {
-        userId,
-        captureTrigger,
-        text: captureText.slice(0, 160),
-      });
+      if (pluginCfg.debugCapture) {
+        console.log("[mem0-auto-capture]", {
+          sessionKey: event.sessionKey,
+          userId,
+          captureTrigger,
+          hasAssistantMessage: !!assistantMessage,
+          messages: captureMessages,
+        });
+      } else {
+        console.log("[mem0-auto-capture]", {
+          sessionKey: event.sessionKey,
+          userId,
+          captureTrigger,
+          hasAssistantMessage: !!assistantMessage,
+          messageCount: captureMessages.length,
+        });
+      }
       await fetch(`${pluginCfg.baseUrl}/memories`, {
         method: "POST",
         headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ text: captureText, userId }),
+        body: JSON.stringify({ userId, messages: captureMessages }),
       });
     } catch (err) {
       console.error("[mem0-auto-capture] write failed:", err);